Skip to content

Commit 17955fd

Browse files
committed
keep demo media local only
1 parent f521f8b commit 17955fd

6 files changed

Lines changed: 203 additions & 18 deletions

File tree

.env.example

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ NEXT_PUBLIC_SITE_URL=http://localhost:3000
55

66
# Local-first Supabase workflow:
77
# 1. Run `npm run supabase:start`
8-
# 2. Copy the values from `npm run supabase:env`
9-
# 3. Paste NEXT_PUBLIC_SUPABASE_ANON_KEY below
8+
# 2. Run `npm run supabase:reset`
9+
# 3. Run `npm run seed:local-media`
10+
# 4. Copy the values from `npm run supabase:env`
11+
# 5. Paste NEXT_PUBLIC_SUPABASE_ANON_KEY below
1012
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54331
1113
NEXT_PUBLIC_SUPABASE_ANON_KEY=
1214

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,12 @@ Use local Studio, `psql`, or the hosted Supabase dashboard for browsing rows and
145145

146146
- Bucket configuration lives in `supabase/config.toml`
147147
- Local reset data lives in `supabase/seed.sql`
148-
- Local bucket objects live in `supabase/storage/`
148+
- Local demo bucket objects live in `supabase/storage/`
149149
- Local bucket policies are backfilled via SQL migrations so local uploads behave like the hosted project
150-
- `npm run supabase:reset` rebuilds the database and re-uploads seeded local media
151-
- `npm run supabase:seed-buckets` re-uploads local bucket media without resetting the database
150+
- `npm run supabase:reset` rebuilds the local database schema and data
151+
- `npm run seed:local-media` uploads local demo media into local Supabase buckets
152+
153+
The demo media under `supabase/storage/avatars`, `supabase/storage/listing_avatars`, and `supabase/storage/listing_photos` is for local development only. Hosted Supabase branches and production should not sync those objects from Git.
152154

153155
Keep all local and preview data sanitized. Do not export or commit production data.
154156

docs/supabase-local-first.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ What this does:
3434
- A PR branch that changes `supabase/**` gets its own Supabase preview branch.
3535
- That preview branch runs migrations and bucket config from Git.
3636
- Seed data comes from `supabase/seed.sql`, not from production data.
37-
- Local bucket objects come from `supabase/storage/`, not from production storage.
37+
- Local demo bucket objects come from `supabase/storage/`, not from production storage.
3838

3939
### 2. Require the Supabase PR Check in GitHub
4040

@@ -79,7 +79,7 @@ npm install
7979
cp .env.example .env.local
8080
npm run supabase:start
8181
npm run supabase:reset
82-
npm run supabase:seed-buckets
82+
npm run seed:local-media
8383
npm run supabase:env
8484
```
8585

@@ -124,7 +124,7 @@ The seed also creates:
124124

125125
The demo data is synthetic and safe to keep in Git.
126126

127-
When `NEXT_PUBLIC_SUPABASE_URL` points at `http://127.0.0.1:54331`, avatar and listing-photo uploads also go to the local Supabase buckets rather than production.
127+
When `NEXT_PUBLIC_SUPABASE_URL` points at `http://127.0.0.1:54331`, avatar and listing-photo uploads also go to the local Supabase buckets rather than production. The repo-tracked demo media is uploaded only by `npm run seed:local-media`, not by hosted Supabase deploys.
128128

129129
## Fresh Computer Setup
130130

@@ -144,11 +144,12 @@ Use this when setting up Peels on a new machine.
144144
3. Copy `.env.example` to `.env.local`.
145145
4. Run `npm run supabase:start`.
146146
5. Run `npm run supabase:reset`.
147-
6. Run `npm run supabase:env`.
148-
7. Set `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54331` in `.env.local`.
149-
8. Copy the printed `ANON_KEY` into `NEXT_PUBLIC_SUPABASE_ANON_KEY` in `.env.local`.
150-
9. Run `npm run dev`.
151-
10. Sign in with one of the demo accounts above.
147+
6. Run `npm run seed:local-media`.
148+
7. Run `npm run supabase:env`.
149+
8. Set `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54331` in `.env.local`.
150+
9. Copy the printed `ANON_KEY` into `NEXT_PUBLIC_SUPABASE_ANON_KEY` in `.env.local`.
151+
10. Run `npm run dev`.
152+
11. Sign in with one of the demo accounts above.
152153

153154
If the app still shows old environment values, clear the build cache and restart:
154155

@@ -163,7 +164,7 @@ npm run dev
163164
2. Make schema changes locally.
164165
3. Add or edit SQL migrations under `supabase/migrations/`.
165166
4. Rebuild from scratch with `npm run supabase:reset`.
166-
5. Re-upload only local bucket media with `npm run supabase:seed-buckets` when you do not need a full DB reset.
167+
5. Upload local demo media with `npm run seed:local-media` after any reset that needs repo-tracked avatars or listing photos.
167168
6. Run `npm run dev`.
168169
7. Test the flow locally.
169170
8. Commit app and migration changes together.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"supabase:status": "supabase status",
1111
"supabase:env": "supabase status -o env",
1212
"supabase:reset": "supabase db reset",
13-
"supabase:seed-buckets": "supabase seed buckets --yes",
13+
"seed:local-media": "node scripts/seed-local-media.mjs",
1414
"supabase:diff": "supabase db diff",
1515
"format": "prettier --write .",
1616
"format:check": "prettier --check ."

scripts/seed-local-media.mjs

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { execFileSync } from "node:child_process";
2+
import { readdirSync, readFileSync, statSync } from "node:fs";
3+
import path from "node:path";
4+
import { fileURLToPath } from "node:url";
5+
6+
import { createClient } from "@supabase/supabase-js";
7+
8+
const __filename = fileURLToPath(import.meta.url);
9+
const __dirname = path.dirname(__filename);
10+
const repoRoot = path.resolve(__dirname, "..");
11+
12+
const bucketConfigs = {
13+
avatars: {
14+
public: true,
15+
fileSizeLimit: "10MiB",
16+
allowedMimeTypes: ["image/png", "image/jpeg", "image/webp"],
17+
sourceDir: path.join(repoRoot, "supabase", "storage", "avatars"),
18+
},
19+
listing_avatars: {
20+
public: true,
21+
fileSizeLimit: "10MiB",
22+
allowedMimeTypes: ["image/png", "image/jpeg", "image/webp"],
23+
sourceDir: path.join(repoRoot, "supabase", "storage", "listing_avatars"),
24+
},
25+
listing_photos: {
26+
public: true,
27+
fileSizeLimit: "50MiB",
28+
allowedMimeTypes: ["image/png", "image/jpeg", "image/webp"],
29+
sourceDir: path.join(repoRoot, "supabase", "storage", "listing_photos"),
30+
},
31+
};
32+
33+
function parseStatusEnv() {
34+
const output = execFileSync("supabase", ["status", "-o", "env"], {
35+
cwd: repoRoot,
36+
encoding: "utf8",
37+
});
38+
39+
return output
40+
.split("\n")
41+
.map((line) => line.trim())
42+
.filter(Boolean)
43+
.reduce((env, line) => {
44+
const separatorIndex = line.indexOf("=");
45+
if (separatorIndex === -1) return env;
46+
47+
const key = line.slice(0, separatorIndex);
48+
const rawValue = line.slice(separatorIndex + 1).trim();
49+
const value = rawValue.replace(/^"(.*)"$/, "$1");
50+
env[key] = value;
51+
return env;
52+
}, {});
53+
}
54+
55+
function assertLocalApiUrl(apiUrl) {
56+
if (!apiUrl) {
57+
throw new Error("Missing API_URL from `supabase status -o env`.");
58+
}
59+
60+
const hostname = new URL(apiUrl).hostname;
61+
if (hostname !== "127.0.0.1" && hostname !== "localhost") {
62+
throw new Error(
63+
`Refusing to seed demo media into non-local Supabase API: ${apiUrl}`
64+
);
65+
}
66+
}
67+
68+
function walkFiles(sourceDir, currentDir = sourceDir) {
69+
return readdirSync(currentDir, { withFileTypes: true }).flatMap((entry) => {
70+
const absolutePath = path.join(currentDir, entry.name);
71+
72+
if (entry.isDirectory()) {
73+
return walkFiles(sourceDir, absolutePath);
74+
}
75+
76+
if (!entry.isFile()) {
77+
return [];
78+
}
79+
80+
return {
81+
absolutePath,
82+
objectPath: path.relative(sourceDir, absolutePath).split(path.sep).join("/"),
83+
};
84+
});
85+
}
86+
87+
function getContentType(filePath) {
88+
const extension = path.extname(filePath).toLowerCase();
89+
90+
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
91+
if (extension === ".png") return "image/png";
92+
if (extension === ".webp") return "image/webp";
93+
94+
return "application/octet-stream";
95+
}
96+
97+
function normalizeFileSizeLimit(fileSizeLimit) {
98+
return fileSizeLimit.replace("MiB", "MB").replace("KiB", "KB");
99+
}
100+
101+
async function ensureBucket(supabase, bucketName, bucketConfig) {
102+
const { error } = await supabase.storage.createBucket(bucketName, {
103+
public: bucketConfig.public,
104+
fileSizeLimit: normalizeFileSizeLimit(bucketConfig.fileSizeLimit),
105+
allowedMimeTypes: bucketConfig.allowedMimeTypes,
106+
});
107+
108+
if (!error) return;
109+
110+
const message = error.message?.toLowerCase() ?? "";
111+
const alreadyExists =
112+
message.includes("already exists") || message.includes("duplicate");
113+
114+
if (!alreadyExists) {
115+
throw error;
116+
}
117+
118+
const { error: updateError } = await supabase.storage.updateBucket(bucketName, {
119+
public: bucketConfig.public,
120+
fileSizeLimit: normalizeFileSizeLimit(bucketConfig.fileSizeLimit),
121+
allowedMimeTypes: bucketConfig.allowedMimeTypes,
122+
});
123+
124+
if (updateError) {
125+
throw updateError;
126+
}
127+
}
128+
129+
async function uploadBucketObjects(supabase, bucketName, bucketConfig) {
130+
await ensureBucket(supabase, bucketName, bucketConfig);
131+
132+
const files = walkFiles(bucketConfig.sourceDir);
133+
134+
for (const file of files) {
135+
const body = readFileSync(file.absolutePath);
136+
const contentType = getContentType(file.absolutePath);
137+
138+
const { error } = await supabase.storage.from(bucketName).upload(
139+
file.objectPath,
140+
body,
141+
{
142+
contentType,
143+
upsert: true,
144+
}
145+
);
146+
147+
if (error) {
148+
throw new Error(
149+
`Failed to upload ${bucketName}/${file.objectPath}: ${error.message}`
150+
);
151+
}
152+
153+
const size = Math.round(statSync(file.absolutePath).size / 1024);
154+
console.log(`Uploaded ${bucketName}/${file.objectPath} (${size} KB)`);
155+
}
156+
}
157+
158+
async function main() {
159+
const env = parseStatusEnv();
160+
assertLocalApiUrl(env.API_URL);
161+
162+
if (!env.SERVICE_ROLE_KEY) {
163+
throw new Error("Missing SERVICE_ROLE_KEY from `supabase status -o env`.");
164+
}
165+
166+
const supabase = createClient(env.API_URL, env.SERVICE_ROLE_KEY, {
167+
auth: {
168+
autoRefreshToken: false,
169+
persistSession: false,
170+
},
171+
});
172+
173+
for (const [bucketName, bucketConfig] of Object.entries(bucketConfigs)) {
174+
await uploadBucketObjects(supabase, bucketName, bucketConfig);
175+
}
176+
177+
console.log("Local demo media seeding complete.");
178+
}
179+
180+
main().catch((error) => {
181+
console.error(error.message || error);
182+
process.exit(1);
183+
});

supabase/config.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,19 +91,16 @@ file_size_limit = "50MiB"
9191
public = true
9292
file_size_limit = "10MiB"
9393
allowed_mime_types = ["image/png", "image/jpeg", "image/webp"]
94-
objects_path = "./storage/avatars"
9594

9695
[storage.buckets.listing_avatars]
9796
public = true
9897
file_size_limit = "10MiB"
9998
allowed_mime_types = ["image/png", "image/jpeg", "image/webp"]
100-
objects_path = "./storage/listing_avatars"
10199

102100
[storage.buckets.listing_photos]
103101
public = true
104102
file_size_limit = "50MiB"
105103
allowed_mime_types = ["image/png", "image/jpeg", "image/webp"]
106-
objects_path = "./storage/listing_photos"
107104

108105
[storage.buckets.static]
109106
public = true

0 commit comments

Comments
 (0)