@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.
@@ -0,0 +1,100 @@
1
+ // The sql channel of the mutation io (cut from exec.mjs's
2
+ // realIo): psql in the db container, else `supabase db query --local`, resolved
3
+ // on the first call. `sqlIo({ exec, sh })` returns the io methods the sql
4
+ // session drives.
5
+ import { readFileSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+
9
+ import { ROOT } from '#engine/root.mjs';
10
+ import { LIMITS, tail } from '#engine/mutations/verdict.mjs';
11
+ import {
12
+ dbContainer,
13
+ DIGEST_SQL,
14
+ parseDigest,
15
+ parseSnapshot,
16
+ SNAPSHOT_SQL,
17
+ wrapForCli,
18
+ } from './snapshot.mjs';
19
+
20
+ const PSQL = ['psql', '-U', 'postgres', '-d', 'postgres', '-X', '-q', '-At'];
21
+
22
+ /** The stack preflight the sql session runs before it opens. */
23
+ export const PREFLIGHT = Object.freeze({
24
+ cmd: 'node',
25
+ args: ['tools/stack-preflight.mjs', '--steps=db', '--wait=20'],
26
+ limitS: 30,
27
+ });
28
+
29
+ /**
30
+ * @param {{ exec: Function, sh: Function }} h the engine's child runners
31
+ * @param {{ preflight?: { cmd: string, args: string[], limitS?: number } }} [opts]
32
+ */
33
+ export function sqlIo({ exec, sh }, { preflight = PREFLIGHT } = {}) {
34
+ // Resolved on the first SQL call: psql in the db container, else the CLI.
35
+ let channel = null;
36
+ const resolveChannel = async () => {
37
+ if (channel) return channel;
38
+ const container = dbContainer(
39
+ readFileSync(join(ROOT, 'supabase', 'config.toml'), 'utf8'),
40
+ );
41
+ const probe = await exec(
42
+ 'docker',
43
+ ['exec', '-i', container, ...PSQL, '-c', 'select 1'],
44
+ { limitS: LIMITS.probe },
45
+ );
46
+ if (probe.code === 0 && probe.out.trim().split('\n').includes('1')) {
47
+ channel = { kind: 'psql', container };
48
+ console.log(`sql channel: psql (docker exec ${container})`);
49
+ } else {
50
+ const why = probe.out.trim().split('\n')[0] || 'docker not available';
51
+ channel = { kind: 'cli' };
52
+ console.log(`sql channel: supabase db query --local (${why})`);
53
+ }
54
+ return channel;
55
+ };
56
+ const sqlRun = async (sql) => {
57
+ const ch = await resolveChannel();
58
+ if (ch.kind === 'psql') {
59
+ return exec(
60
+ 'docker',
61
+ [
62
+ 'exec',
63
+ '-i',
64
+ ch.container,
65
+ ...PSQL,
66
+ '-v',
67
+ 'ON_ERROR_STOP=1',
68
+ '--single-transaction',
69
+ '-f',
70
+ '-',
71
+ ],
72
+ { input: sql, limitS: LIMITS.sql },
73
+ );
74
+ }
75
+ let wrapped;
76
+ try {
77
+ wrapped = wrapForCli(sql);
78
+ } catch (err) {
79
+ return { code: 1, out: err.message };
80
+ }
81
+ const f = join(tmpdir(), `mutate-check-${process.pid}.sql`);
82
+ writeFileSync(f, wrapped);
83
+ return sh(
84
+ `pnpm supabase db query --local -f ${JSON.stringify(f)}`,
85
+ LIMITS.sql,
86
+ );
87
+ };
88
+ const query = async (sql, parse) => {
89
+ const r = await sqlRun(sql);
90
+ if (r.code !== 0) throw new Error(`catalog query failed: ${tail(r.out)}`);
91
+ return parse(r.out);
92
+ };
93
+ return {
94
+ preflight: () =>
95
+ exec(preflight.cmd, preflight.args, { limitS: preflight.limitS ?? 30 }),
96
+ applySql: sqlRun,
97
+ digest: () => query(DIGEST_SQL, parseDigest),
98
+ snapshot: () => query(SNAPSHOT_SQL, parseSnapshot),
99
+ };
100
+ }
@@ -0,0 +1,18 @@
1
+ import { snapshotPath } from './sql-kind.mjs';
2
+
3
+ const FUNCTION_SNAPSHOT =
4
+ /^supabase\/schema\/functions\/([a-z_][a-z0-9_]*)\.([a-z_][a-z0-9_]*)\.sql$/;
5
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6
+
7
+ /** The snapshot files an sql entry (and its `also` edits) guards. */
8
+ function guardPaths(e) {
9
+ const objects = [e.object, ...(e.also ?? []).map((a) => a.object)];
10
+ return objects.flatMap((o) => snapshotPath(o));
11
+ }
12
+
13
+ function schemaChanged(ctx, key, rec) {
14
+ const e = rec.entry;
15
+ return e.kind === 'sql' && guardPaths(e).some((p) => ctx.changed.has(p));
16
+ }
17
+
18
+ export { FUNCTION_SNAPSHOT, escapeRe, guardPaths, schemaChanged };
@@ -0,0 +1,433 @@
1
+ import { diffCatalog, buildRestore, uncoveredStatement } from './snapshot.mjs';
2
+ import { checkSqlEntry, expandSql } from './sql-object.mjs';
3
+ import {
4
+ FUNCTION_SNAPSHOT,
5
+ escapeRe,
6
+ guardPaths,
7
+ schemaChanged,
8
+ } from './sql-check.mjs';
9
+ import { LIMITS, tail, verdict } from '#engine/mutations/verdict.mjs';
10
+ import { PREFLIGHT, sqlIo } from './sql-channel.mjs';
11
+
12
+ export { PREFLIGHT };
13
+
14
+ /**
15
+ * Parses an entry's `object`: `function <s>.<n>(<args>)`, `table <s>.<n>`,
16
+ * `type <s>.<n>`, `cron <job>`, and `trigger|policy|constraint
17
+ * <name> on <s>.<t>` (name = the object's own name, schema and table = the
18
+ * table's). null for anything else. This is the one parser.
19
+ */
20
+ export function parseObject(o) {
21
+ if (typeof o !== 'string') return null;
22
+ const f = /^function ([a-z_][a-z0-9_]*)\.([a-z_][a-z0-9_]*)\((.*)\)$/s.exec(
23
+ o,
24
+ );
25
+ if (f) return { kind: 'function', schema: f[1], name: f[2], args: f[3] };
26
+ const t = /^(table|type) ([a-z_][a-z0-9_]*)\.([a-z_][a-z0-9_]*)$/.exec(o);
27
+ if (t) return { kind: t[1], schema: t[2], name: t[3] };
28
+ const c = /^cron (\S+)$/.exec(o);
29
+ if (c) return { kind: 'cron', name: c[1] };
30
+ const n =
31
+ /^(trigger|policy|constraint) ([a-z_][a-z0-9_]*) on ([a-z_][a-z0-9_]*)\.([a-z_][a-z0-9_]*)$/.exec(
32
+ o,
33
+ );
34
+ if (n) return { kind: n[1], name: n[2], schema: n[3], table: n[4] };
35
+ return null;
36
+ }
37
+
38
+ export function objectKind(object) {
39
+ return parseObject(object)?.kind ?? null;
40
+ }
41
+
42
+ /** Candidate snapshot paths for an `object`; [] when it does not parse. */
43
+ export function snapshotPath(object) {
44
+ const o = parseObject(object);
45
+ if (!o) return [];
46
+ if (o.kind === 'function')
47
+ return [`supabase/schema/functions/${o.schema}.${o.name}.sql`];
48
+ if (o.kind === 'cron') return ['supabase/schema/cron/jobs.sql'];
49
+ if (o.kind === 'table')
50
+ return [`supabase/schema/tables/${o.schema}.${o.name}.sql`];
51
+ if (o.table !== undefined)
52
+ return [`supabase/schema/tables/${o.schema}.${o.table}.sql`];
53
+ if (o.kind === 'type')
54
+ return [`supabase/schema/types/${o.schema}.${o.name}.sql`];
55
+ return [];
56
+ }
57
+
58
+ /** The lean reset: seed catalog and vault, no db:types / db:schema (tracked files stay put). */
59
+ // prettier-ignore
60
+ export const LEAN_RESET = 'pnpm supabase db reset && pnpm supabase db query --local -f tools/local-vault.sql';
61
+
62
+ // The sql session's own bookkeeping lives on the engine's run context.
63
+ function sqlCtx(io, ctx) {
64
+ if (ctx.sqlTests === undefined)
65
+ Object.assign(ctx, {
66
+ baseDigest: null,
67
+ baseSnap: null,
68
+ sqlRan: false,
69
+ sqlTests: new Set(),
70
+ proven: async () => (await io.digest()) === ctx.baseDigest,
71
+ });
72
+ return ctx;
73
+ }
74
+
75
+ const kindsOf = (diff) =>
76
+ [...new Set(diff.map((d) => d.kind))].sort().join(', ');
77
+
78
+ async function readBaseline(io, ctx) {
79
+ if (ctx.baseDigest !== null) return;
80
+ try {
81
+ const digest = await io.digest();
82
+ const snap = await io.snapshot();
83
+ ctx.baseDigest = digest;
84
+ ctx.baseSnap = snap;
85
+ } catch (err) {
86
+ ctx.fatal = `baseline not readable: ${tail(String(err?.message ?? err))}`;
87
+ }
88
+ }
89
+
90
+ // sqlApplied stays set across the reset: a Ctrl-C mid-reset still ends at the seed.
91
+ async function finalReset(io, state, ctx) {
92
+ state.sqlApplied = true;
93
+ const r = await io.resetDb();
94
+ if (r.code !== 0) {
95
+ ctx.fatal = `baseline not reproducible: lean db:reset failed after the last mutation: ${tail(r.out)}`;
96
+ return;
97
+ }
98
+ try {
99
+ if (!(await ctx.proven())) ctx.fatal = 'baseline not reproducible (final)';
100
+ } catch (err) {
101
+ ctx.fatal = `baseline not reproducible: ${tail(String(err?.message ?? err))}`;
102
+ }
103
+ }
104
+
105
+ // A killed result is trusted only if the test is also red on a freshly reset db.
106
+ async function rerunWithReset(m, io, state, ctx) {
107
+ state.sqlApplied = true;
108
+ const r = await io.resetDb();
109
+ if (r.code !== 0) {
110
+ ctx.fatal = `baseline not reproducible: lean db:reset failed after ${m.id}: ${tail(r.out)}`;
111
+ return { id: m.id, status: 'error', detail: ctx.fatal };
112
+ }
113
+ try {
114
+ if (!(await ctx.proven())) {
115
+ ctx.fatal = `baseline not reproducible (re-run of ${m.id})`;
116
+ return { id: m.id, status: 'error', detail: ctx.fatal };
117
+ }
118
+ } catch (err) {
119
+ ctx.fatal = `baseline not reproducible: ${tail(String(err?.message ?? err))}`;
120
+ return { id: m.id, status: 'error', detail: ctx.fatal };
121
+ }
122
+ return await (ctx.runOne ?? runOne)(m, io, state, ctx);
123
+ }
124
+
125
+ // Re-creates what the mutation changed from the baseline snapshot. { code: 0 }
126
+ // when the catalog is proven back at the baseline digest, else { code: 1, out:
127
+ // <reason to reset> }.
128
+ async function autoRestore(io, ctx) {
129
+ for (let pass = 0; pass < 2; pass++) {
130
+ const diff = diffCatalog(ctx.baseSnap, await io.snapshot());
131
+ const restore = buildRestore(diff);
132
+ if (restore.unrestorable.length)
133
+ return { code: 1, out: restore.unrestorable.join(', ') };
134
+ if (restore.sql !== null) {
135
+ const r = await io.applySql(restore.sql);
136
+ if (r.code !== 0) return { code: 1, out: 'restore failed' };
137
+ }
138
+ if (await ctx.proven()) return { code: 0, out: '' };
139
+ }
140
+ const left = kindsOf(diffCatalog(ctx.baseSnap, await io.snapshot()));
141
+ return { code: 1, out: left || 'digest differs' };
142
+ }
143
+
144
+ async function runOne(m, io, state, ctx) {
145
+ await readBaseline(io, ctx);
146
+ if (ctx.fatal) return { id: m.id, status: 'error', detail: ctx.fatal };
147
+ let result;
148
+ let resetReason = null;
149
+ state.restore = null;
150
+ state.sqlApplied = true;
151
+ ctx.sqlRan = true;
152
+ ctx.sqlTests.add(m.test);
153
+ let a;
154
+ try {
155
+ a = await io.applySql(expandSql(m));
156
+ } catch (err) {
157
+ a = { code: 1, out: String(err?.message ?? err) };
158
+ }
159
+ if (a.code !== 0) {
160
+ resetReason = 'apply failed';
161
+ result = {
162
+ id: m.id,
163
+ status: 'error',
164
+ detail: `apply failed: ${tail(a.out)}`,
165
+ };
166
+ } else {
167
+ try {
168
+ result = verdict(m, await io.run(m.test));
169
+ } catch (err) {
170
+ result = {
171
+ id: m.id,
172
+ status: 'error',
173
+ detail: String(err?.message ?? err),
174
+ };
175
+ }
176
+ }
177
+ const uncovered = uncoveredStatement(expandSql(m));
178
+ if (uncovered) resetReason = `uncovered: ${uncovered}`;
179
+ if (resetReason === null) {
180
+ try {
181
+ // prettier-ignore
182
+ const r = m.restore !== undefined ? await io.applySql(m.restore) : await autoRestore(io, ctx);
183
+ if (r.code !== 0)
184
+ resetReason = m.restore !== undefined ? 'restore failed' : r.out;
185
+ else if (m.restore !== undefined && !(await ctx.proven()))
186
+ resetReason = 'restore failed';
187
+ } catch {
188
+ resetReason = 'restore failed';
189
+ }
190
+ }
191
+ if (resetReason === null) {
192
+ state.sqlApplied = false;
193
+ return result;
194
+ }
195
+ const r = await io.resetDb();
196
+ if (r.code !== 0) {
197
+ ctx.fatal = `baseline not reproducible: lean db:reset failed after ${m.id}: ${tail(r.out)}`;
198
+ return { id: m.id, status: 'error', detail: ctx.fatal };
199
+ }
200
+ state.sqlApplied = false;
201
+ try {
202
+ if (!(await ctx.proven())) {
203
+ ctx.fatal = `baseline not reproducible (${resetReason})`;
204
+ return {
205
+ id: m.id,
206
+ status: 'error',
207
+ detail: `${result.detail}\n${ctx.fatal}`,
208
+ };
209
+ }
210
+ } catch (err) {
211
+ ctx.fatal = `baseline not reproducible: ${tail(String(err?.message ?? err))}`;
212
+ return { id: m.id, status: 'error', detail: ctx.fatal };
213
+ }
214
+ return {
215
+ ...result,
216
+ detail: `${result.detail}\nrestored by reset (${resetReason})`,
217
+ };
218
+ }
219
+
220
+ const isStr = (v) => typeof v === 'string' && v.trim() !== '';
221
+
222
+ const OBJECT_GRAMMAR =
223
+ 'object must be function <s>.<n>(<args>), table <s>.<n>, type <s>.<n>, cron <job> or trigger|policy|constraint <name> on <s>.<t>';
224
+
225
+ // The first snapshot that exists, { path, text }, read through readFile (which throws when missing).
226
+ function firstReadable(readFile, paths) {
227
+ for (const path of paths) {
228
+ try {
229
+ return { path, text: readFile(path) };
230
+ } catch {
231
+ /* try the next candidate */
232
+ }
233
+ }
234
+ return null;
235
+ }
236
+
237
+ /**
238
+ * The `sql` kind: schema and stale rules, the registry area,
239
+ * the select rule, the function-snapshot rename repair, the plan note and the
240
+ * inline session (open = stack preflight, runOne, settle = pollution re-run,
241
+ * close = final reset, interrupt = one lean reset).
242
+ */
243
+ export const sqlKind = {
244
+ name: 'sql',
245
+ runs: true,
246
+ fields: [
247
+ 'protects',
248
+ 'object',
249
+ 'find',
250
+ 'replace',
251
+ 'apply',
252
+ 'restore',
253
+ 'also',
254
+ 'test',
255
+ 'testFile',
256
+ 'testName',
257
+ ],
258
+ runFields: [
259
+ 'file',
260
+ 'within',
261
+ 'find',
262
+ 'replace',
263
+ 'apply',
264
+ 'restore',
265
+ 'test',
266
+ 'object',
267
+ 'also',
268
+ ],
269
+ shape(e, readFile) {
270
+ const out = [];
271
+ for (const f of ['protects', 'object', 'test', 'testFile'])
272
+ if (!isStr(e[f])) out.push(`${f} must be a non-empty string`);
273
+ // The object form edits a function (find + replace); apply is then optional.
274
+ if ((e.find === undefined || e.apply !== undefined) && !isStr(e.apply))
275
+ out.push('apply must be a non-empty string');
276
+ if (isStr(e.object) && !parseObject(e.object)) out.push(OBJECT_GRAMMAR);
277
+ if (e.restore !== undefined && typeof e.restore !== 'string')
278
+ out.push('restore must be a string');
279
+ out.push(...checkSqlEntry(e, readFile, { shapeOnly: true }));
280
+ return out;
281
+ },
282
+ // What validateSpec adds for a runnable spec entry: apply/restore text, then the
283
+ // shape and snapshot rules of checkSqlEntry (messages as before the split).
284
+ validate(m, readFile) {
285
+ const out = [];
286
+ if (
287
+ m.restore !== undefined &&
288
+ (typeof m.restore !== 'string' || m.restore.trim() === '')
289
+ )
290
+ out.push('"restore" must be a non-empty string');
291
+ // The object form edits a function with find + replace; its
292
+ // "apply" is optional. Every other sql mutation needs one.
293
+ const objectForm = m.find !== undefined;
294
+ if ((m.apply !== undefined || !objectForm) && !isStr(m.apply))
295
+ out.push('sql mutation needs a non-empty "apply"');
296
+ out.push(...checkSqlEntry(m, readFile));
297
+ return out;
298
+ },
299
+ // Existence, the named object and the find against the snapshot body: the
300
+ // snapshot rules of checkSqlEntry (the shape rules are reported apart).
301
+ stale(e, readFile) {
302
+ const out = [];
303
+ const shape = new Set(checkSqlEntry(e, readFile, { shapeOnly: true }));
304
+ for (const why of checkSqlEntry(e, readFile))
305
+ if (!shape.has(why)) out.push(why);
306
+ const o = parseObject(e.object);
307
+ if (o?.kind === 'cron') {
308
+ const found = firstReadable(readFile, snapshotPath(e.object));
309
+ if (found && !found.text.includes(`'${o.name}'`))
310
+ out.push(
311
+ `snapshot ${found.path} does not mention cron job '${o.name}'`,
312
+ );
313
+ }
314
+ return out;
315
+ },
316
+ area(e, dir) {
317
+ const o = parseObject(e.object);
318
+ const stem = !o
319
+ ? 'unknown'
320
+ : o.kind === 'cron'
321
+ ? `cron.${o.name}`
322
+ : `${o.schema}.${o.table ?? o.name}`;
323
+ return `${dir}/db/${stem}.json`;
324
+ },
325
+ // The snapshot files an entry guards, for `mutations:affected`.
326
+ paths: guardPaths,
327
+ selectRules: [['schema', schemaChanged]],
328
+ // A moved function snapshot renames the object (and its apply/restore text).
329
+ // { entry, notes: [[from, to]] } when it applies (or nothing moved),
330
+ // { left: why } when a rename cannot be followed.
331
+ repair(entry, diff) {
332
+ const next = structuredClone(entry);
333
+ const notes = [];
334
+ const o = parseObject(next.object);
335
+ const snap = snapshotPath(next.object)[0];
336
+ const to = snap && diff.renames.get(snap);
337
+ if (o && to) {
338
+ const m = FUNCTION_SNAPSHOT.exec(to);
339
+ if (o.kind !== 'function' || !m)
340
+ return {
341
+ left: `${o.kind} snapshot moved ${snap} -> ${to}; re-aim it by hand`,
342
+ };
343
+ const oldName = `${o.schema}.${o.name}`;
344
+ const newName = `${m[1]}.${m[2]}`;
345
+ const whole = new RegExp(
346
+ `(?<![\\w.$"])${escapeRe(oldName)}(?![\\w$"])`,
347
+ 'g',
348
+ );
349
+ next.object = `function ${newName}(${o.args})`;
350
+ for (const f of ['apply', 'restore'])
351
+ if (typeof next[f] === 'string')
352
+ next[f] = next[f].replace(whole, newName);
353
+ notes.push([snap, to]);
354
+ }
355
+ return { entry: next, notes };
356
+ },
357
+ planNote(m) {
358
+ const unc = uncoveredStatement(expandSql(m));
359
+ return unc ? `[reset: uncovered ${unc}]` : null;
360
+ },
361
+ session: {
362
+ name: 'sql',
363
+ order: 0,
364
+ inline: true,
365
+ ioMethods: (h) => ({
366
+ resetDb: () => h.sh(LEAN_RESET, LIMITS.reset),
367
+ ...sqlIo(h),
368
+ }),
369
+ async open(_entries, io) {
370
+ const pre = await io.preflight();
371
+ return pre.code !== 0
372
+ ? { fatal: `stack preflight failed: ${tail(pre.out)}` }
373
+ : {};
374
+ },
375
+ runOne: (m, io, state, ctx) => runOne(m, io, state, sqlCtx(io, ctx)),
376
+ // A killed result is trusted only if the test is also red on a freshly reset db.
377
+ async settle(results, entries, io, state, ctx) {
378
+ const c = sqlCtx(io, ctx);
379
+ if (c.sqlTests.size === 0) return { results, rerun: 0 };
380
+ const red = new Set();
381
+ for (const cmd of c.sqlTests) {
382
+ if ((await io.run(cmd)).code !== 0) red.add(cmd);
383
+ }
384
+ let rerun = 0;
385
+ for (let i = 0; i < results.length && !c.fatal; i++) {
386
+ const m = entries.find((x) => x.id === results[i].id);
387
+ if (!m || results[i].status !== 'killed' || !red.has(m.test)) continue;
388
+ results[i] = await rerunWithReset(m, io, state, c);
389
+ rerun++;
390
+ }
391
+ return { results, rerun };
392
+ },
393
+ async close(io, state, ctx) {
394
+ const c = sqlCtx(io, ctx);
395
+ if (c.sqlRan) await finalReset(io, state, c);
396
+ return c.fatal ? { fatal: c.fatal } : {};
397
+ },
398
+ // A sql mutation in flight ends at the seed via one lean reset.
399
+ async interrupt(state, io, setup) {
400
+ if (state.sqlApplied && (await io.resetDb()).code !== 0)
401
+ console.error(setup.commands.afterFatal);
402
+ },
403
+ },
404
+ };
405
+
406
+ export { finalReset, rerunWithReset, runOne };
407
+
408
+ const SQL_OPTIONS = ['reset', 'preflight'];
409
+
410
+ /**
411
+ * The sql kind with a project's own lean reset command and stack preflight; both default to
412
+ * the literals above. Only the session's io methods differ from `sqlKind`.
413
+ */
414
+ export function sqlKindWith({ reset = LEAN_RESET, preflight = PREFLIGHT } = {}) {
415
+ return {
416
+ ...sqlKind,
417
+ session: {
418
+ ...sqlKind.session,
419
+ ioMethods: (h) => ({
420
+ resetDb: () => h.sh(reset, LIMITS.reset),
421
+ ...sqlIo(h, { preflight }),
422
+ }),
423
+ },
424
+ };
425
+ }
426
+
427
+ /** Validates `supabaseNext({ sql })` and returns the kind it selects. */
428
+ export function sqlKindFrom(sql = {}) {
429
+ for (const k of Object.keys(sql))
430
+ if (!SQL_OPTIONS.includes(k))
431
+ throw new Error(`supabase-next: unknown sql option ${k}`);
432
+ return sqlKindWith(sql);
433
+ }