@ticket-gate/supabase-next 0.0.0-stage → 0.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +54 -2
- package/checks/db-schema.mjs +455 -0
- package/checks/env-keys.mjs +122 -0
- package/checks/kill-group.mjs +13 -0
- package/checks/stack-preflight.mjs +482 -0
- package/checks/stack.mjs +29 -0
- package/commands.mjs +54 -0
- package/index.mjs +41 -0
- package/mutations/next-served.mjs +25 -0
- package/mutations/next-typecheck.mjs +180 -0
- package/mutations/pack.mjs +12 -0
- package/mutations/snapshot.mjs +421 -0
- package/mutations/sql-channel.mjs +100 -0
- package/mutations/sql-check.mjs +18 -0
- package/mutations/sql-kind.mjs +433 -0
- package/mutations/sql-object.mjs +214 -0
- package/nightly/stack-payload.mjs +48 -0
- package/package.json +19 -4
- package/profile-build.mjs +10 -0
- package/profile-hooks.mjs +72 -0
- package/score.mjs +26 -0
- package/selection/next-routes.mjs +40 -0
- package/selection/supabase-rules.mjs +18 -0
- package/selection/workspace-exports.mjs +44 -0
- package/sql-ranges.mjs +185 -0
- package/stack.mjs +4 -0
- package/step-defaults.mjs +41 -0
- package/tools.mjs +22 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// db-schema pack: writes a read-only, per-object snapshot of the END STATE of the local database
|
|
2
|
+
// (schemas public and private, plus cron.job rows), so a reader sees a table, function or policy as it is
|
|
3
|
+
// now without replaying the migrations. Generated, never applied, never hand-edited: the migrations stay
|
|
4
|
+
// the history, this is the picture.
|
|
5
|
+
//
|
|
6
|
+
// Every project value (generated header, output folder, tool name, hint commands, usage line) is an
|
|
7
|
+
// option of dbSchema(); a project binds them in a CLI shim or through the pack's db-schema command.
|
|
8
|
+
//
|
|
9
|
+
// run(argv) result: { code, out, err }. code 0 written, 1 error (nothing written or deleted), 2 unknown
|
|
10
|
+
// flag. out and err hold the lines for stdout and stderr.
|
|
11
|
+
//
|
|
12
|
+
// Dependency-free Node ESM, no build step. The dump comes from the stack's own image (docker exec), so no
|
|
13
|
+
// host pg_dump is needed.
|
|
14
|
+
|
|
15
|
+
import { execFileSync } from 'node:child_process';
|
|
16
|
+
import {
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readdirSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
rmdirSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from 'node:fs';
|
|
25
|
+
import { dirname, join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const RESTRICT = /^(?:--\s*)?\\(?:un)?restrict\b/;
|
|
28
|
+
const HEADER =
|
|
29
|
+
/^-- (?:Dumped from database version|Dumped by pg_dump version|PostgreSQL database dump(?: complete)?)/;
|
|
30
|
+
// prettier-ignore
|
|
31
|
+
const SET_LINE = /^SET (?:statement_timeout|lock_timeout|idle_in_transaction_session_timeout|transaction_timeout|client_encoding|standard_conforming_strings|check_function_bodies|xmloption|client_min_messages|row_security|default_tablespace|default_table_access_method) = .*;$/;
|
|
32
|
+
const SET_CONFIG =
|
|
33
|
+
/^SELECT pg_catalog\.set_config\('search_path', '', false\);$/;
|
|
34
|
+
const RESET_ALL = /^RESET ALL;$/;
|
|
35
|
+
const TOC =
|
|
36
|
+
/^-- Name: (.+?); Type: (.+?); Schema: (.+?); Owner: (.*?)(?:; Tablespace: .*)?$/;
|
|
37
|
+
|
|
38
|
+
const IDENT = '"[^"]+"|[A-Za-z0-9_$]+';
|
|
39
|
+
|
|
40
|
+
function isDropped(line) {
|
|
41
|
+
return (
|
|
42
|
+
RESTRICT.test(line) ||
|
|
43
|
+
HEADER.test(line) ||
|
|
44
|
+
SET_LINE.test(line) ||
|
|
45
|
+
SET_CONFIG.test(line) ||
|
|
46
|
+
RESET_ALL.test(line)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isCommentOrBlank(line) {
|
|
51
|
+
return line.trim() === '' || line.startsWith('--');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Raw pg_dump text -> text without the lines that change from run to run.
|
|
55
|
+
// Throws on a stray statement before the first TOC entry.
|
|
56
|
+
export function normalise(dump, tool) {
|
|
57
|
+
const lines = dump
|
|
58
|
+
.split('\n')
|
|
59
|
+
.filter((l) => !isDropped(l))
|
|
60
|
+
.map((l) => l.replace(/[ \t]+$/, ''));
|
|
61
|
+
let seenEntry = false;
|
|
62
|
+
for (let i = 0; i < lines.length; i++) {
|
|
63
|
+
if (isTocHeaderAt(lines, i)) seenEntry = true;
|
|
64
|
+
if (!seenEntry && !isCommentOrBlank(lines[i])) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`unexpected line before the first entry: '${lines[i]}'. Teach normalise() about it in ${tool} if it is pg_dump noise.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
|
71
|
+
return `${lines.join('\n')}\n`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isTocHeaderAt(lines, i) {
|
|
75
|
+
return (
|
|
76
|
+
i > 0 &&
|
|
77
|
+
i < lines.length - 1 &&
|
|
78
|
+
lines[i - 1] === '--' &&
|
|
79
|
+
lines[i + 1] === '--' &&
|
|
80
|
+
TOC.test(lines[i])
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Normalised dump -> one record per TOC entry, in dump order. `text` is the
|
|
85
|
+
// whole block, the `--` lines around the header included.
|
|
86
|
+
export function parseToc(dump) {
|
|
87
|
+
const lines = dump.split('\n');
|
|
88
|
+
const starts = [];
|
|
89
|
+
for (let i = 0; i < lines.length; i++) {
|
|
90
|
+
if (isTocHeaderAt(lines, i)) starts.push(i - 1);
|
|
91
|
+
}
|
|
92
|
+
return starts.map((start, k) => {
|
|
93
|
+
const end = k + 1 < starts.length ? starts[k + 1] : lines.length;
|
|
94
|
+
const block = lines.slice(start, end);
|
|
95
|
+
while (
|
|
96
|
+
block.length > 3 &&
|
|
97
|
+
(block[block.length - 1] === '' || block[block.length - 1] === '--')
|
|
98
|
+
) {
|
|
99
|
+
block.pop();
|
|
100
|
+
}
|
|
101
|
+
const m = TOC.exec(lines[start + 1]);
|
|
102
|
+
return {
|
|
103
|
+
name: m[1],
|
|
104
|
+
type: m[2],
|
|
105
|
+
schema: m[3],
|
|
106
|
+
owner: m[4],
|
|
107
|
+
text: block.join('\n'),
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function stripQuotes(s) {
|
|
113
|
+
return s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// "schema.table" or "table" (optionally quoted) at the start of s.
|
|
117
|
+
function leadingTable(s, defaultSchema) {
|
|
118
|
+
const m = new RegExp(`^(?:(${IDENT})\\.)?(${IDENT})`).exec(s.trim());
|
|
119
|
+
if (!m) return null;
|
|
120
|
+
return {
|
|
121
|
+
schema: m[1] ? stripQuotes(m[1]) : defaultSchema,
|
|
122
|
+
table: stripQuotes(m[2]),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function tablePath(schema, table) {
|
|
127
|
+
return `tables/${schema}.${table}.sql`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Raw pg_dump text -> Map<relPath, content> under OUT_REL. Throws on an entry
|
|
131
|
+
// it cannot place (never a silent drop) and on a dump with no table.
|
|
132
|
+
export function splitDump(dump, { header, tool, reset }) {
|
|
133
|
+
const entries = parseToc(normalise(dump, tool));
|
|
134
|
+
const tables = new Set();
|
|
135
|
+
const owned = new Map();
|
|
136
|
+
for (const e of entries) {
|
|
137
|
+
if (e.type === 'TABLE') tables.add(`${e.schema}.${e.name}`);
|
|
138
|
+
if (e.type === 'SEQUENCE OWNED BY') {
|
|
139
|
+
const m = /OWNED BY ([^;\s]+)/.exec(e.text);
|
|
140
|
+
const t = m
|
|
141
|
+
? leadingTable(m[1].replace(/\.[^."]+$/, ''), e.schema)
|
|
142
|
+
: null;
|
|
143
|
+
if (t) owned.set(`${e.schema}.${e.name}`, t);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const tableFor = (e, t) => {
|
|
148
|
+
if (!t || !tables.has(`${t.schema}.${t.table}`)) {
|
|
149
|
+
throw new Error(`no table for ${e.type} '${e.name}'`);
|
|
150
|
+
}
|
|
151
|
+
return tablePath(t.schema, t.table);
|
|
152
|
+
};
|
|
153
|
+
const onTable = (e, rest) => {
|
|
154
|
+
const m = /(?:^| )ON (.+)$/.exec(rest);
|
|
155
|
+
return tableFor(e, m ? leadingTable(m[1], e.schema) : null);
|
|
156
|
+
};
|
|
157
|
+
const ownerOfSequence = (e, name) =>
|
|
158
|
+
tableFor(e, owned.get(`${e.schema}.${name}`));
|
|
159
|
+
const unknown = (e) => {
|
|
160
|
+
throw new Error(`unknown TOC type '${e.type}' for '${e.name}'`);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// COMMENT and ACL are routed by the prefix of the object they describe.
|
|
164
|
+
const routeDescribed = (e) => {
|
|
165
|
+
const sp = e.name.indexOf(' ');
|
|
166
|
+
const kind = sp < 0 ? e.name : e.name.slice(0, sp);
|
|
167
|
+
const rest = sp < 0 ? '' : e.name.slice(sp + 1);
|
|
168
|
+
switch (kind) {
|
|
169
|
+
case 'SCHEMA':
|
|
170
|
+
return { path: `schemas/${rest}.sql` };
|
|
171
|
+
case 'TYPE':
|
|
172
|
+
return { path: `types/${e.schema}.sql` };
|
|
173
|
+
case 'TABLE':
|
|
174
|
+
return { path: tableFor(e, { schema: e.schema, table: rest }) };
|
|
175
|
+
case 'COLUMN':
|
|
176
|
+
return {
|
|
177
|
+
path: tableFor(e, {
|
|
178
|
+
schema: e.schema,
|
|
179
|
+
table: stripQuotes(rest.split('.')[0]),
|
|
180
|
+
}),
|
|
181
|
+
};
|
|
182
|
+
case 'SEQUENCE':
|
|
183
|
+
return { path: ownerOfSequence(e, rest) };
|
|
184
|
+
case 'FUNCTION':
|
|
185
|
+
return {
|
|
186
|
+
path: `functions/${e.schema}.${rest.split('(')[0]}.sql`,
|
|
187
|
+
sig: rest,
|
|
188
|
+
};
|
|
189
|
+
case 'POLICY':
|
|
190
|
+
case 'TRIGGER':
|
|
191
|
+
case 'CONSTRAINT':
|
|
192
|
+
return { path: onTable(e, rest) };
|
|
193
|
+
default:
|
|
194
|
+
return unknown(e);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const route = (e) => {
|
|
199
|
+
switch (e.type) {
|
|
200
|
+
case 'SCHEMA':
|
|
201
|
+
return { path: `schemas/${e.name}.sql` };
|
|
202
|
+
case 'DEFAULT ACL':
|
|
203
|
+
return { path: `schemas/${e.schema}.sql` };
|
|
204
|
+
case 'TYPE':
|
|
205
|
+
return { path: `types/${e.schema}.sql` };
|
|
206
|
+
case 'TABLE':
|
|
207
|
+
return { path: tablePath(e.schema, e.name) };
|
|
208
|
+
case 'DEFAULT':
|
|
209
|
+
case 'CONSTRAINT':
|
|
210
|
+
case 'FK CONSTRAINT':
|
|
211
|
+
case 'TRIGGER':
|
|
212
|
+
case 'POLICY':
|
|
213
|
+
return {
|
|
214
|
+
path: tableFor(e, {
|
|
215
|
+
schema: e.schema,
|
|
216
|
+
table: stripQuotes(
|
|
217
|
+
new RegExp(`^(${IDENT})`).exec(e.name)?.[1] ?? '',
|
|
218
|
+
),
|
|
219
|
+
}),
|
|
220
|
+
};
|
|
221
|
+
case 'ROW SECURITY':
|
|
222
|
+
return { path: tableFor(e, { schema: e.schema, table: e.name }) };
|
|
223
|
+
case 'INDEX': {
|
|
224
|
+
const m = new RegExp(
|
|
225
|
+
`^CREATE (?:UNIQUE )?INDEX .*? ON (?:ONLY )?((?:${IDENT})(?:\\.(?:${IDENT}))?)`,
|
|
226
|
+
'm',
|
|
227
|
+
).exec(e.text);
|
|
228
|
+
return { path: tableFor(e, m ? leadingTable(m[1], e.schema) : null) };
|
|
229
|
+
}
|
|
230
|
+
case 'SEQUENCE':
|
|
231
|
+
case 'SEQUENCE OWNED BY':
|
|
232
|
+
return { path: ownerOfSequence(e, e.name) };
|
|
233
|
+
case 'FUNCTION': {
|
|
234
|
+
// The header names the argument types only; COMMENT and ACL headers
|
|
235
|
+
// carry names too. ALTER FUNCTION ... OWNER TO has the latter form.
|
|
236
|
+
const name = e.name.split('(')[0];
|
|
237
|
+
const alter = /^ALTER FUNCTION \S+?(\(.*\)) OWNER TO /m.exec(e.text);
|
|
238
|
+
return {
|
|
239
|
+
path: `functions/${e.schema}.${name}.sql`,
|
|
240
|
+
sig: alter ? `${name}${alter[1]}` : e.name,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
case 'COMMENT':
|
|
244
|
+
case 'ACL':
|
|
245
|
+
return routeDescribed(e);
|
|
246
|
+
default:
|
|
247
|
+
return unknown(e);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const byPath = new Map();
|
|
252
|
+
for (const e of entries) {
|
|
253
|
+
const { path, sig } = route(e);
|
|
254
|
+
if (!byPath.has(path)) byPath.set(path, []);
|
|
255
|
+
byPath.get(path).push({ sig, type: e.type, name: e.name, text: e.text });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const files = new Map();
|
|
259
|
+
for (const [path, items] of byPath) {
|
|
260
|
+
let texts;
|
|
261
|
+
if (path.startsWith('functions/')) {
|
|
262
|
+
const bySig = new Map();
|
|
263
|
+
for (const it of items) {
|
|
264
|
+
if (!bySig.has(it.sig)) bySig.set(it.sig, []);
|
|
265
|
+
bySig.get(it.sig).push(it);
|
|
266
|
+
}
|
|
267
|
+
for (const list of bySig.values()) {
|
|
268
|
+
const orphan = list.find((it) => it.type !== 'FUNCTION');
|
|
269
|
+
if (!list.some((it) => it.type === 'FUNCTION') && orphan) {
|
|
270
|
+
throw new Error(`no function for ${orphan.type} '${orphan.name}'`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const group = [...bySig].map(([sig, list]) => ({
|
|
274
|
+
sig,
|
|
275
|
+
list: list.map((it) => it.text),
|
|
276
|
+
}));
|
|
277
|
+
group.sort((a, b) => (a.sig < b.sig ? -1 : a.sig > b.sig ? 1 : 0));
|
|
278
|
+
texts = group.flatMap((g) => g.list);
|
|
279
|
+
} else {
|
|
280
|
+
texts = items.map((it) => it.text);
|
|
281
|
+
}
|
|
282
|
+
files.set(path, `${header}\n\n${texts.join('\n\n')}\n`);
|
|
283
|
+
}
|
|
284
|
+
// prettier-ignore
|
|
285
|
+
if (![...files.keys()].some((p) => p.startsWith('tables/'))) throw new Error(`the dump has no table: run ${reset} first`);
|
|
286
|
+
return files;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// cron.job rows -> content of cron/jobs.sql.
|
|
290
|
+
export function renderCron(rows, header) {
|
|
291
|
+
const q = (s) => s.replaceAll("'", "''");
|
|
292
|
+
const byJob = [...rows];
|
|
293
|
+
byJob.sort((a, b) =>
|
|
294
|
+
a.jobname < b.jobname ? -1 : a.jobname > b.jobname ? 1 : 0,
|
|
295
|
+
);
|
|
296
|
+
const stmts = byJob.map((row) => {
|
|
297
|
+
// prettier-ignore
|
|
298
|
+
if (row.command.includes('$$')) throw new Error(`cron job '${row.jobname}' command contains $$`);
|
|
299
|
+
return `select cron.schedule('${q(row.jobname)}', '${q(row.schedule)}', $$${row.command}$$);`;
|
|
300
|
+
});
|
|
301
|
+
return `${[header, ...(stmts.length ? ['', stmts.join('\n\n')] : [])].join('\n')}\n`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function listFiles(dir, base = '') {
|
|
305
|
+
const out = [];
|
|
306
|
+
for (const d of readdirSync(join(dir, base), { withFileTypes: true })) {
|
|
307
|
+
const rel = base ? `${base}/${d.name}` : d.name;
|
|
308
|
+
if (d.isDirectory()) out.push(...listFiles(dir, rel));
|
|
309
|
+
else out.push(rel);
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function pruneEmptyDirs(dir, base = '') {
|
|
315
|
+
for (const d of readdirSync(join(dir, base), { withFileTypes: true })) {
|
|
316
|
+
if (!d.isDirectory()) continue;
|
|
317
|
+
const rel = base ? `${base}/${d.name}` : d.name;
|
|
318
|
+
pruneEmptyDirs(dir, rel);
|
|
319
|
+
if (readdirSync(join(dir, rel)).length === 0) rmdirSync(join(dir, rel));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Makes outDir hold exactly `files` (Map<relPath, content>).
|
|
324
|
+
export function writeSnapshot(outDir, files) {
|
|
325
|
+
mkdirSync(outDir, { recursive: true });
|
|
326
|
+
const stale = listFiles(outDir)
|
|
327
|
+
.filter((rel) => !files.has(rel))
|
|
328
|
+
.sort();
|
|
329
|
+
for (const rel of stale) rmSync(join(outDir, rel));
|
|
330
|
+
const written = [...files.keys()].sort();
|
|
331
|
+
for (const rel of written) {
|
|
332
|
+
const target = join(outDir, rel);
|
|
333
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
334
|
+
writeFileSync(target, files.get(rel));
|
|
335
|
+
}
|
|
336
|
+
pruneEmptyDirs(outDir);
|
|
337
|
+
return { written, deleted: stale };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function projectId(root) {
|
|
341
|
+
const toml = readFileSync(join(root, 'supabase/config.toml'), 'utf8');
|
|
342
|
+
const m = /^project_id\s*=\s*"([^"]+)"/m.exec(toml);
|
|
343
|
+
if (!m) throw new Error('no project_id in supabase/config.toml');
|
|
344
|
+
return m[1];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function dockerExec(root, args, start, reset) {
|
|
348
|
+
const container = `supabase_db_${projectId(root)}`;
|
|
349
|
+
try {
|
|
350
|
+
return execFileSync('docker', ['exec', container, ...args], {
|
|
351
|
+
encoding: 'utf8',
|
|
352
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
353
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
354
|
+
});
|
|
355
|
+
} catch (err) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`could not read the database from container ${container}: ${String(err.stderr || err.message).trim()}. Is the stack up? Run ${start}, then ${reset}.`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function readDumpFromStack(root, start, reset) {
|
|
363
|
+
return dockerExec(
|
|
364
|
+
root,
|
|
365
|
+
[
|
|
366
|
+
'pg_dump',
|
|
367
|
+
'-U',
|
|
368
|
+
'postgres',
|
|
369
|
+
'--schema-only',
|
|
370
|
+
'--schema=public',
|
|
371
|
+
'--schema=private',
|
|
372
|
+
],
|
|
373
|
+
start,
|
|
374
|
+
reset,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function readCronFromStack(root, start, reset) {
|
|
379
|
+
const out = dockerExec(
|
|
380
|
+
root,
|
|
381
|
+
[
|
|
382
|
+
'psql',
|
|
383
|
+
'-U',
|
|
384
|
+
'postgres',
|
|
385
|
+
'-tAc',
|
|
386
|
+
"select coalesce(json_agg(j), '[]'::json) from (select jobname, schedule, command from cron.job order by jobname) j",
|
|
387
|
+
],
|
|
388
|
+
start,
|
|
389
|
+
reset,
|
|
390
|
+
).trim();
|
|
391
|
+
return JSON.parse(out === '' ? '[]' : out);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Builds every file in memory; nothing touches disk until this returned.
|
|
395
|
+
export function buildSnapshot(dump, cronRows, opts) {
|
|
396
|
+
const files = splitDump(dump, opts);
|
|
397
|
+
files.set('cron/jobs.sql', renderCron(cronRows, opts.header));
|
|
398
|
+
return files;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function parseArgs(argv) {
|
|
402
|
+
const opts = {};
|
|
403
|
+
for (const a of argv) {
|
|
404
|
+
const m = /^--(dump|cron|out)=(.+)$/.exec(a);
|
|
405
|
+
if (!m) return null;
|
|
406
|
+
opts[m[1]] = m[2];
|
|
407
|
+
}
|
|
408
|
+
return opts;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// root: the repo folder; out: snapshot folder relative to root; header: first line of every file;
|
|
412
|
+
// tool: the CLI path named in the normalise() message; name: the prefix of every output line;
|
|
413
|
+
// usage: the line printed on an unknown flag; start, reset: the hint commands in error messages.
|
|
414
|
+
export function dbSchema({
|
|
415
|
+
root,
|
|
416
|
+
out,
|
|
417
|
+
header,
|
|
418
|
+
tool,
|
|
419
|
+
name,
|
|
420
|
+
usage,
|
|
421
|
+
start,
|
|
422
|
+
reset,
|
|
423
|
+
}) {
|
|
424
|
+
const opts = { header, tool, reset };
|
|
425
|
+
return {
|
|
426
|
+
renderCron: (rows) => renderCron(rows, header),
|
|
427
|
+
buildSnapshot: (dump, cron) => buildSnapshot(dump, cron, opts),
|
|
428
|
+
run(argv) {
|
|
429
|
+
const args = parseArgs(argv);
|
|
430
|
+
if (!args) return { code: 2, out: [], err: [usage] };
|
|
431
|
+
const started = Date.now();
|
|
432
|
+
try {
|
|
433
|
+
const dump = args.dump
|
|
434
|
+
? readFileSync(args.dump, 'utf8')
|
|
435
|
+
: readDumpFromStack(root, start, reset);
|
|
436
|
+
const cron = args.cron
|
|
437
|
+
? JSON.parse(readFileSync(args.cron, 'utf8'))
|
|
438
|
+
: readCronFromStack(root, start, reset);
|
|
439
|
+
const files = buildSnapshot(dump, cron, opts);
|
|
440
|
+
const outDir = args.out ?? join(root, out);
|
|
441
|
+
const { written, deleted } = writeSnapshot(outDir, files);
|
|
442
|
+
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
443
|
+
return {
|
|
444
|
+
code: 0,
|
|
445
|
+
out: [
|
|
446
|
+
`${name}: ${written.length} files written, ${deleted.length} deleted in ${secs}s`,
|
|
447
|
+
],
|
|
448
|
+
err: [],
|
|
449
|
+
};
|
|
450
|
+
} catch (err) {
|
|
451
|
+
return { code: 1, out: [], err: [`${name}: ${err.message}`] };
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Asserts that the local demo anon key committed in apps/*/.env.example is a
|
|
2
|
+
// JWT the local Supabase stack will actually accept.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: docs/setup.md tells every new developer to `cp
|
|
5
|
+
// apps/web/.env.example apps/web/.env.local`. A single corrupted character in
|
|
6
|
+
// that key does not fail loudly - GoTrue's unauthenticated endpoints still
|
|
7
|
+
// answer 200, so sign-in appears to work and the first PostgREST call then
|
|
8
|
+
// returns 401 PGRST301 as an opaque data error. One such typo ("supabane-demo"
|
|
9
|
+
// for "supabase-demo", signature unchanged) shipped in both templates and cost
|
|
10
|
+
// a review round to find.
|
|
11
|
+
//
|
|
12
|
+
// It runs offline and always: no Docker, no running stack, no network, no
|
|
13
|
+
// dependency. `supabase status -o json` is the source of truth for the value,
|
|
14
|
+
// but a gate that needs the stack up would be skipped exactly when it matters,
|
|
15
|
+
// and a gate that no-ops when the stack is down is worse than none - it reads
|
|
16
|
+
// as coverage. Instead this verifies the HMAC directly against the demo JWT
|
|
17
|
+
// secret that every `supabase start` stack signs with, which is a published
|
|
18
|
+
// constant, not a secret.
|
|
19
|
+
//
|
|
20
|
+
// Pure: returns { code, out, err } and never prints or exits; a project binds the templates, the
|
|
21
|
+
// label and the fix text in its own CLI.
|
|
22
|
+
|
|
23
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
// The default local JWT secret of every `supabase start` stack. Published by
|
|
28
|
+
// Supabase, identical on every machine, and worthless against any hosted
|
|
29
|
+
// project. It is not a secret.
|
|
30
|
+
const LOCAL_DEMO_JWT_SECRET =
|
|
31
|
+
'super-secret-jwt-token-with-at-least-32-characters-long';
|
|
32
|
+
|
|
33
|
+
const EXPECTED_ISS = 'supabase-demo';
|
|
34
|
+
|
|
35
|
+
/** Reads `NAME=value` from a dotenv-style file, ignoring comments. */
|
|
36
|
+
function readVar(root, file, name) {
|
|
37
|
+
const text = readFileSync(join(root, file), 'utf8');
|
|
38
|
+
for (const line of text.split('\n')) {
|
|
39
|
+
const trimmed = line.trim();
|
|
40
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
41
|
+
const eq = trimmed.indexOf('=');
|
|
42
|
+
if (eq === -1) continue;
|
|
43
|
+
if (trimmed.slice(0, eq).trim() !== name) continue;
|
|
44
|
+
return trimmed.slice(eq + 1).trim();
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function signatureIsValid(header, payload, signature) {
|
|
50
|
+
const expected = createHmac('sha256', LOCAL_DEMO_JWT_SECRET)
|
|
51
|
+
.update(`${header}.${payload}`)
|
|
52
|
+
.digest('base64url');
|
|
53
|
+
const a = Buffer.from(expected);
|
|
54
|
+
const b = Buffer.from(signature);
|
|
55
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {{ root: string, targets: { file: string, name: string }[], label: string, fix: string[] }} cfg
|
|
60
|
+
* @returns {{ code: 0 | 1, out: string[], err: string[] }}
|
|
61
|
+
*/
|
|
62
|
+
export function checkEnvExampleKeys({ root, targets, label, fix }) {
|
|
63
|
+
const problems = [];
|
|
64
|
+
|
|
65
|
+
for (const { file, name } of targets) {
|
|
66
|
+
const value = readVar(root, file, name);
|
|
67
|
+
if (!value) {
|
|
68
|
+
problems.push(`${file}: ${name} is missing or empty.`);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parts = value.split('.');
|
|
73
|
+
if (parts.length !== 3) {
|
|
74
|
+
problems.push(`${file}: ${name} is not a three-segment JWT.`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const [header, payload, signature] = parts;
|
|
78
|
+
|
|
79
|
+
let claims;
|
|
80
|
+
try {
|
|
81
|
+
claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
82
|
+
} catch {
|
|
83
|
+
problems.push(`${file}: ${name} payload is not base64url JSON.`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (claims.iss !== EXPECTED_ISS) {
|
|
88
|
+
problems.push(
|
|
89
|
+
`${file}: ${name} has iss "${claims.iss}", expected "${EXPECTED_ISS}".`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (claims.role !== 'anon') {
|
|
93
|
+
problems.push(
|
|
94
|
+
`${file}: ${name} has role "${claims.role}", expected "anon". Only the anon key belongs in a committed template.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (!signatureIsValid(header, payload, signature)) {
|
|
98
|
+
problems.push(
|
|
99
|
+
`${file}: ${name} signature does not verify against the local demo JWT secret, so the local stack will reject it with 401 PGRST301.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (problems.length) {
|
|
105
|
+
return {
|
|
106
|
+
code: 1,
|
|
107
|
+
out: [],
|
|
108
|
+
err: [
|
|
109
|
+
`${label} local demo anon key is unusable:\n`,
|
|
110
|
+
...problems.map((p) => ` - ${p}`),
|
|
111
|
+
fix.join('\n'),
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
code: 0,
|
|
117
|
+
out: [
|
|
118
|
+
`${label} anon keys verify against the local demo JWT secret (${targets.length} checked).`,
|
|
119
|
+
],
|
|
120
|
+
err: [],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Process-group kill helpers: signal a spawned child's whole group, not only the child. A leaf
|
|
2
|
+
// module with no imports of its own, so any script that spawns a stack, a test run or a server
|
|
3
|
+
// can share it without pulling in a config or another script.
|
|
4
|
+
export const killGroup = (pid, sig) => process.kill(-pid, sig);
|
|
5
|
+
|
|
6
|
+
/** killGroup that ignores ESRCH: the group may already be gone. */
|
|
7
|
+
export function safeKillGroup(pid, sig) {
|
|
8
|
+
try {
|
|
9
|
+
killGroup(pid, sig);
|
|
10
|
+
} catch {
|
|
11
|
+
// ESRCH: the group is already gone
|
|
12
|
+
}
|
|
13
|
+
}
|