@ticket-gate/supabase-next 0.0.0 → 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,180 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname, join, resolve } from 'node:path';
4
+
5
+ import { ROOT } from '#engine/root.mjs';
6
+ import { patchedSource } from '#engine/mutations/locate.mjs';
7
+ import { isServed, needsWebBuild, patchKey } from '#engine/mutations/served.mjs';
8
+
9
+ // The entries `next build` compiles as TypeScript. vitest, jest
10
+ // and node entries run through esbuild, which strips types, so a type error
11
+ // there is never found by mutate:check; packages/core builds with tsup.
12
+ // prettier-ignore
13
+ export const typechecked = (m) => isServed(m) && needsWebBuild(m) && /\.(ts|tsx)$/.test(m.file);
14
+
15
+ /**
16
+ * Type checks each distinct served patch before any build.
17
+ * `checker.check(file, source)` returns the formatted diagnostic lines of that
18
+ * one file; `checker.read(file)` (optional) returns its text. Returns
19
+ * Map<patchKey, { ok: true } | { ok: false, line } | { ok: null, line }>; `null`
20
+ * means the unpatched file already fails, so the patch cannot be judged.
21
+ * Nothing is written.
22
+ */
23
+ export function typecheckPatches(entries, checker) {
24
+ const out = new Map();
25
+ const clean = new Map();
26
+ const seen = new Set();
27
+ const keyed = entries.filter(typechecked).map((m) => [patchKey(m), m]);
28
+ for (const [key, m] of keyed) {
29
+ if (seen.has(key)) continue;
30
+ seen.add(key);
31
+ const original = checker.read
32
+ ? checker.read(m.file)
33
+ : readFileSync(join(ROOT, m.file), 'utf8');
34
+ if (!clean.has(m.file)) clean.set(m.file, checker.check(m.file, original));
35
+ const already = clean.get(m.file);
36
+ if (already.length > 0) {
37
+ out.set(key, {
38
+ ok: null,
39
+ line: `clean ${m.file} already fails: ${already[0]}`,
40
+ });
41
+ continue;
42
+ }
43
+ let lines;
44
+ try {
45
+ lines = checker.check(m.file, patchedSource(original, m));
46
+ } catch (err) {
47
+ out.set(key, { ok: null, line: `${m.file}: ${err.message}` });
48
+ continue;
49
+ }
50
+ out.set(
51
+ key,
52
+ lines.length > 0 ? { ok: false, line: lines[0] } : { ok: true },
53
+ );
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * R6: one in-memory TS program from apps/web/tsconfig.json (TypeScript resolved
60
+ * from apps/web, the version next build uses). The patched text is served for
61
+ * the one file through the compiler host; diagnostics of that file only. No file
62
+ * is written and `incremental` is off, so no .tsbuildinfo appears.
63
+ */
64
+ function tsChecker(root) {
65
+ let ts;
66
+ let setup;
67
+ let program;
68
+ const cache = new Map();
69
+ const init = () => {
70
+ if (setup) return setup;
71
+ ts = createRequire(join(root, 'apps/web/package.json'))('typescript');
72
+ const cfg = join(root, 'apps/web/tsconfig.json');
73
+ const read = ts.readConfigFile(cfg, ts.sys.readFile);
74
+ const parsed = ts.parseJsonConfigFileContent(
75
+ read.config,
76
+ ts.sys,
77
+ dirname(cfg),
78
+ );
79
+ const options = {
80
+ ...parsed.options,
81
+ incremental: false,
82
+ composite: false,
83
+ noEmit: true,
84
+ tsBuildInfoFile: undefined,
85
+ };
86
+ setup = {
87
+ options,
88
+ names: parsed.fileNames,
89
+ host: ts.createCompilerHost(options, true),
90
+ };
91
+ return setup;
92
+ };
93
+ return {
94
+ read: (file) => readFileSync(join(root, file), 'utf8'),
95
+ check(file, source) {
96
+ const { options, names, host } = init();
97
+ const abs = resolve(root, file);
98
+ const same = (n) => resolve(n) === abs;
99
+ const h = {
100
+ ...host,
101
+ getSourceFile(name, lang, onErr, shouldCreate) {
102
+ if (same(name)) return ts.createSourceFile(name, source, lang, true);
103
+ if (!cache.has(name))
104
+ cache.set(
105
+ name,
106
+ host.getSourceFile(name, lang, onErr, shouldCreate),
107
+ );
108
+ return cache.get(name);
109
+ },
110
+ };
111
+ program = ts.createProgram({
112
+ rootNames: names.some(same) ? names : [...names, abs],
113
+ options,
114
+ host: h,
115
+ oldProgram: program,
116
+ });
117
+ const sf = program.getSourceFile(abs);
118
+ const diags = [
119
+ ...program.getSyntacticDiagnostics(sf),
120
+ ...program.getSemanticDiagnostics(sf),
121
+ ].filter((d) => d.category === ts.DiagnosticCategory.Error);
122
+ return diags.map((d) => {
123
+ const at =
124
+ d.file && d.start !== undefined
125
+ ? d.file.getLineAndCharacterOfPosition(d.start)
126
+ : { line: 0, character: 0 };
127
+ return `${file}(${at.line + 1},${at.character + 1}): error TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`;
128
+ });
129
+ },
130
+ };
131
+ }
132
+
133
+ const typecheckSummary = (id, verdicts, checks = 0) => {
134
+ const n = (v) => verdicts.filter((x) => x === v).length;
135
+ return `mutate:check ${id} typecheck: ${n('ok')} ok, ${n('error')} error, ${n('n/a')} n/a, ${checks} checks`;
136
+ };
137
+
138
+ /** `--typecheck`: prints one line per entry and the summary; returns the exit code. */
139
+ async function typecheckMain(spec, todo, id) {
140
+ const real = tsChecker(ROOT);
141
+ let checks = 0;
142
+ const counting = {
143
+ read: real.read,
144
+ check: (file, source) => {
145
+ checks++;
146
+ return real.check(file, source);
147
+ },
148
+ };
149
+ console.log(
150
+ `typecheck: checking ${todo.length} entr${todo.length === 1 ? 'y' : 'ies'} (in memory, no file is written)`,
151
+ );
152
+ const tc = typecheckPatches(todo, counting);
153
+ // Let a queued SIGINT/SIGTERM run its handler before any result is printed.
154
+ await new Promise((r) => setTimeout(r, 25));
155
+ const verdicts = todo.map((m) => {
156
+ if (!typechecked(m)) {
157
+ const why =
158
+ isServed(m) && needsWebBuild(m)
159
+ ? 'not a .ts/.tsx file'
160
+ : 'not built by next build';
161
+ console.log(`${m.id} typecheck: n/a (${why})`);
162
+ return 'n/a';
163
+ }
164
+ const v = tc.get(patchKey(m));
165
+ if (v.ok === true) {
166
+ console.log(`${m.id} typecheck: ok`);
167
+ return 'ok';
168
+ }
169
+ if (v.ok === null) {
170
+ console.log(`${m.id} typecheck: n/a (${v.line})`);
171
+ return 'n/a';
172
+ }
173
+ console.log(`${m.id} typecheck: error ${v.line}`);
174
+ return 'error';
175
+ });
176
+ console.log(`\n${typecheckSummary(spec.ticket ?? id, verdicts, checks)}`);
177
+ return verdicts.includes('error') ? 1 : 0;
178
+ }
179
+
180
+ export { tsChecker, typecheckMain, typecheckSummary };
@@ -0,0 +1,12 @@
1
+ // The supabase-next pack: the kinds and harnesses a project
2
+ // hands to resolveSetup.
3
+ import { nextServed } from './next-served.mjs';
4
+ import { sqlKind, sqlKindFrom } from './sql-kind.mjs';
5
+
6
+ export const mutationKinds = { sql: sqlKind };
7
+
8
+ /** The kinds when a project sets `sql` options (reset, preflight). */
9
+ export const mutationKindsWith = (sql) => ({ sql: sqlKindFrom(sql) });
10
+
11
+ /** @param {object} opts serve options of the next-served harness (see served()) */
12
+ export const mutationHarnesses = (opts) => [nextServed(opts)];
@@ -0,0 +1,421 @@
1
+ // Pure catalog logic for tools/mutate-check.mjs: the SQL that
2
+ // fingerprints the local database, the parsers for its output, the diff between
3
+ // two snapshots, the SQL that restores a diff, and the CLI-channel statement
4
+ // wrapper. Nothing here spawns a process or touches the filesystem; the loop,
5
+ // the channel and the stack live in mutate-check.mjs.
6
+ //
7
+ // Dependency-free Node ESM.
8
+
9
+ const SCHEMAS = `('public', 'private', 'cron')`;
10
+
11
+ // ACL rows as 'grantee|privilege|grantable', sorted. `acl` is the raw aclitem[]
12
+ // column, `kind` the acldefault object type ('f' function, 'r' relation).
13
+ const aclSql = (
14
+ acl,
15
+ kind,
16
+ owner,
17
+ ) => String.raw`(select coalesce(array_agg(x.g || '|' || x.p || '|' || x.gr order by x.g collate "C", x.p collate "C", x.gr collate "C"), '{}'::text[])
18
+ from (select case a.grantee when 0 then 'PUBLIC' else pg_get_userbyid(a.grantee) end as g,
19
+ a.privilege_type::text as p, a.is_grantable::text as gr
20
+ from aclexplode(coalesce(${acl}, acldefault('${kind}', ${owner}))) a) x)`;
21
+
22
+ const US = String.raw`E'\x1f'`;
23
+
24
+ /**
25
+ * One row per catalog object: kind, key, fp, "create", "drop", extra (jsonb).
26
+ * The single source for DIGEST_SQL and SNAPSHOT_SQL (equal digests imply equal
27
+ * fp per object). Schemas public, private, cron; extension members skipped.
28
+ */
29
+ export const OBJECTS_SQL = String.raw`select 'function'::text as kind,
30
+ format('%I.%I(%s)', n.nspname, p.proname, pg_get_function_identity_arguments(p.oid)) as key,
31
+ concat_ws(${US}, pg_get_functiondef(p.oid), p.prosecdef::text, coalesce(array_to_string(p.proconfig, ','), ''),
32
+ array_to_string(${aclSql('p.proacl', 'f', 'p.proowner')}, ',')) as fp,
33
+ rtrim(pg_get_functiondef(p.oid), E' \n\t') as "create",
34
+ format('drop function %s.%s(%s) cascade', quote_ident(n.nspname), quote_ident(p.proname), pg_get_function_identity_arguments(p.oid)) as "drop",
35
+ jsonb_build_object('args', pg_get_function_arguments(p.oid), 'result', coalesce(pg_get_function_result(p.oid), ''),
36
+ 'owner', pg_get_userbyid(p.proowner), 'acl', to_jsonb(${aclSql('p.proacl', 'f', 'p.proowner')})) as extra
37
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
38
+ where n.nspname in ${SCHEMAS} and p.prokind in ('f', 'p')
39
+ and not exists (select 1 from pg_depend d where d.classid = 'pg_proc'::regclass and d.objid = p.oid and d.deptype = 'e')
40
+ union all
41
+ select 'policy', format('%I.%I.%I', pl.schemaname, pl.tablename, pl.policyname),
42
+ concat_ws(${US}, pl.permissive, coalesce((select string_agg(r::text, ',' order by r::text collate "C") from unnest(pl.roles) r), ''),
43
+ pl.cmd, coalesce(pl.qual, ''), coalesce(pl.with_check, '')),
44
+ format('create policy %I on %I.%I as %s for %s to %s%s%s', pl.policyname, pl.schemaname, pl.tablename, lower(pl.permissive), pl.cmd,
45
+ (select string_agg(case when r::text = 'public' then 'public' else quote_ident(r::text) end, ', ' order by r::text collate "C") from unnest(pl.roles) r),
46
+ case when pl.qual is not null then ' using (' || pl.qual || ')' else '' end,
47
+ case when pl.with_check is not null then ' with check (' || pl.with_check || ')' else '' end),
48
+ format('drop policy if exists %I on %I.%I', pl.policyname, pl.schemaname, pl.tablename),
49
+ '{}'::jsonb
50
+ from pg_policies pl
51
+ where pl.schemaname in ${SCHEMAS}
52
+ and not exists (select 1 from pg_class pc join pg_namespace pn on pn.oid = pc.relnamespace join pg_depend d on d.classid = 'pg_class'::regclass and d.objid = pc.oid and d.deptype = 'e'
53
+ where pn.nspname = pl.schemaname and pc.relname = pl.tablename)
54
+ union all
55
+ select 'trigger', format('%I.%I.%I', n.nspname, c.relname, t.tgname),
56
+ concat_ws(${US}, pg_get_triggerdef(t.oid), t.tgenabled::text),
57
+ pg_get_triggerdef(t.oid) || case t.tgenabled
58
+ when 'D' then format('; alter table %I.%I disable trigger %I', n.nspname, c.relname, t.tgname)
59
+ when 'R' then format('; alter table %I.%I enable replica trigger %I', n.nspname, c.relname, t.tgname)
60
+ when 'A' then format('; alter table %I.%I enable always trigger %I', n.nspname, c.relname, t.tgname)
61
+ else '' end,
62
+ format('drop trigger if exists %I on %I.%I', t.tgname, n.nspname, c.relname),
63
+ '{}'::jsonb
64
+ from pg_trigger t join pg_class c on c.oid = t.tgrelid join pg_namespace n on n.oid = c.relnamespace
65
+ where not t.tgisinternal and n.nspname in ${SCHEMAS}
66
+ and not exists (select 1 from pg_depend d where d.classid = 'pg_class'::regclass and d.objid = c.oid and d.deptype = 'e')
67
+ union all
68
+ select 'constraint', format('%I.%I.%I', n.nspname, c.relname, k.conname),
69
+ concat_ws(${US}, k.contype::text, pg_get_constraintdef(k.oid)),
70
+ format('alter table %I.%I add constraint %I %s', n.nspname, c.relname, k.conname, pg_get_constraintdef(k.oid)),
71
+ format('alter table %I.%I drop constraint if exists %I', n.nspname, c.relname, k.conname),
72
+ jsonb_build_object('contype', k.contype::text)
73
+ from pg_constraint k join pg_class c on c.oid = k.conrelid join pg_namespace n on n.oid = c.relnamespace
74
+ where k.conrelid <> 0 and k.contype not in ('t', 'n') and k.conislocal and n.nspname in ${SCHEMAS}
75
+ and not exists (select 1 from pg_depend d where d.classid = 'pg_class'::regclass and d.objid = c.oid and d.deptype = 'e')
76
+ union all
77
+ select 'index', format('%I.%I', n.nspname, ic.relname),
78
+ pg_get_indexdef(i.indexrelid),
79
+ pg_get_indexdef(i.indexrelid),
80
+ format('drop index if exists %I.%I', n.nspname, ic.relname),
81
+ '{}'::jsonb
82
+ from pg_index i join pg_class ic on ic.oid = i.indexrelid join pg_namespace n on n.oid = ic.relnamespace
83
+ where n.nspname in ${SCHEMAS}
84
+ and not exists (select 1 from pg_depend d where d.classid = 'pg_class'::regclass and d.objid = i.indrelid and d.deptype = 'e')
85
+ and not exists (select 1 from pg_constraint k where k.conindid = i.indexrelid and k.contype in ('p', 'u', 'x'))
86
+ union all
87
+ select 'table', format('%I.%I', n.nspname, c.relname),
88
+ concat_ws(${US}, c.relrowsecurity::text, c.relforcerowsecurity::text, array_to_string(${aclSql('c.relacl', 'r', 'c.relowner')}, ',')),
89
+ null::text, null::text,
90
+ jsonb_build_object('relkind', c.relkind::text, 'rls', c.relrowsecurity, 'force', c.relforcerowsecurity,
91
+ 'owner', pg_get_userbyid(c.relowner), 'acl', to_jsonb(${aclSql('c.relacl', 'r', 'c.relowner')}))
92
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace
93
+ where c.relkind in ('r', 'p', 'v', 'm', 'f') and n.nspname in ${SCHEMAS}
94
+ and not exists (select 1 from pg_depend d where d.classid = 'pg_class'::regclass and d.objid = c.oid and d.deptype = 'e')
95
+ union all
96
+ select 'enum', format('%I.%I', n.nspname, t.typname),
97
+ (select string_agg(e.enumlabel, ',' order by e.enumsortorder) from pg_enum e where e.enumtypid = t.oid),
98
+ null::text, null::text, '{}'::jsonb
99
+ from pg_type t join pg_namespace n on n.oid = t.typnamespace
100
+ where t.typtype = 'e' and n.nspname in ${SCHEMAS}
101
+ union all
102
+ select 'cron', coalesce(j.jobname, '#' || md5(j.schedule || j.command)),
103
+ concat_ws(${US}, j.schedule, j.command, j.active::text),
104
+ case when j.active then 'select ' || s.call else format('select cron.alter_job(%s, active := false)', s.call) end,
105
+ format('select cron.unschedule(%s::bigint)', j.jobid),
106
+ jsonb_build_object('jobid', j.jobid)
107
+ from cron.job j
108
+ cross join lateral (select case when j.jobname is null then format('cron.schedule(%L, %L)', j.schedule, j.command)
109
+ else format('cron.schedule(%L, %L, %L)', j.jobname, j.schedule, j.command) end as call) s`;
110
+
111
+ /** One text value: 'MCDIGEST:' || md5 over kind, key and fp of every object || ':MCDIGEST'. */
112
+ export const DIGEST_SQL = String.raw`select 'MCDIGEST:' || md5(coalesce(string_agg(o.kind || ${US} || o.key || ${US} || coalesce(o.fp, ''), E'\x1e' order by o.kind collate "C", o.key collate "C"), '')) || ':MCDIGEST'
113
+ from (${OBJECTS_SQL}) o`;
114
+
115
+ /** One text value: 'MCSNAP:' || base64(json array of every object) || ':MCSNAP'. */
116
+ export const SNAPSHOT_SQL = String.raw`select 'MCSNAP:' || translate(encode(convert_to(coalesce(json_agg(row_to_json(o) order by o.kind collate "C", o.key collate "C")::text, '[]'), 'UTF8'), 'base64'), E'\n', '') || ':MCSNAP'
117
+ from (${OBJECTS_SQL}) o`;
118
+
119
+ /** The 32 hex digits between the MCDIGEST markers, wherever they sit in stdout. */
120
+ export function parseDigest(out) {
121
+ const m = /MCDIGEST:([0-9a-f]{32}):MCDIGEST/.exec(String(out));
122
+ if (!m)
123
+ throw new Error(
124
+ 'no MCDIGEST marker in the output; run pnpm mutate:check again after pnpm db:reset',
125
+ );
126
+ return m[1];
127
+ }
128
+
129
+ /** CatalogObject[] from the base64 JSON between the MCSNAP markers (extra is flattened in). */
130
+ export function parseSnapshot(out) {
131
+ const m = /MCSNAP:([A-Za-z0-9+/=]*):MCSNAP/.exec(String(out));
132
+ if (!m)
133
+ throw new Error(
134
+ 'no MCSNAP marker in the output; run pnpm mutate:check again after pnpm db:reset',
135
+ );
136
+ const rows = JSON.parse(Buffer.from(m[1], 'base64').toString('utf8'));
137
+ return rows.map(({ extra, ...row }) => ({ ...row, ...(extra ?? {}) }));
138
+ }
139
+
140
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
141
+
142
+ /** appeared / missing / changed per kind + key, sorted by kind then key; unchanged omitted. */
143
+ export function diffCatalog(base, now) {
144
+ const id = (o) => `${o.kind}\u0000${o.key}`;
145
+ const b = new Map(base.map((o) => [id(o), o]));
146
+ const n = new Map(now.map((o) => [id(o), o]));
147
+ const out = [];
148
+ for (const [k, bo] of b) {
149
+ const no = n.get(k);
150
+ if (!no)
151
+ out.push({ kind: bo.kind, key: bo.key, change: 'missing', base: bo });
152
+ else if (bo.fp !== no.fp)
153
+ out.push({
154
+ kind: bo.kind,
155
+ key: bo.key,
156
+ change: 'changed',
157
+ base: bo,
158
+ now: no,
159
+ });
160
+ }
161
+ for (const [k, no] of n)
162
+ if (!b.has(k))
163
+ out.push({ kind: no.kind, key: no.key, change: 'appeared', now: no });
164
+ return out.sort((x, y) => cmp(x.kind, y.kind) || cmp(x.key, y.key));
165
+ }
166
+
167
+ // prettier-ignore
168
+ export const DROP_ORDER = ['policy', 'trigger', 'constraint', 'index', 'cron', 'function'];
169
+ // prettier-ignore
170
+ export const CREATE_ORDER = ['function', 'table', 'index', 'constraint', 'trigger', 'policy', 'cron'];
171
+
172
+ // Foreign keys are dropped first and created last (they depend on other constraints/indexes).
173
+ const fkRank = (d, last) => {
174
+ if (d.kind !== 'constraint') return 0;
175
+ const fk = (d.now ?? d.base)?.contype === 'f' ? 1 : 0;
176
+ return last ? fk : -fk;
177
+ };
178
+
179
+ const ident = (s) =>
180
+ s === 'PUBLIC'
181
+ ? 'public'
182
+ : /^[a-z_][a-z0-9_$]*$/.test(s)
183
+ ? s
184
+ : `"${s.replaceAll('"', '""')}"`;
185
+ const splitAcl = (acl) =>
186
+ (acl ?? []).map((r) => {
187
+ const [grantee, privilege, grantable] = r.split('|');
188
+ return { grantee, privilege, grantable: grantable === 'true' };
189
+ });
190
+
191
+ // revoke everything non-owner, then grant back the base rows (owner rows never named).
192
+ function aclLines(what, key, base, now) {
193
+ const owner = (base ?? now)?.owner;
194
+ const baseRows = splitAcl(base?.acl).filter((r) => r.grantee !== owner);
195
+ const nowRows = splitAcl(now?.acl).filter((r) => r.grantee !== owner);
196
+ const grantees = [
197
+ ...new Set(
198
+ [...baseRows, ...nowRows]
199
+ .map((r) => r.grantee)
200
+ .filter((g) => g !== 'PUBLIC'),
201
+ ),
202
+ ].sort(cmp);
203
+ return [
204
+ `revoke all on ${what} ${key} from ${['public', ...grantees.map(ident)].join(', ')}`,
205
+ ...baseRows.map(
206
+ (r) =>
207
+ `grant ${r.privilege.toLowerCase()} on ${what} ${key} to ${ident(r.grantee)}${r.grantable ? ' with grant option' : ''}`,
208
+ ),
209
+ ];
210
+ }
211
+
212
+ /**
213
+ * The restore for a diff (base is the target). { sql, unrestorable }: sql is one
214
+ * atomic do-block, null for an empty diff or when anything is unrestorable.
215
+ */
216
+ export function buildRestore(diff) {
217
+ const unrestorable = new Set();
218
+ const drops = [];
219
+ const creates = [];
220
+ for (const d of diff) {
221
+ const { kind, change, base, now } = d;
222
+ if (kind === 'enum') {
223
+ unrestorable.add('enum');
224
+ continue;
225
+ }
226
+ if (kind === 'table' || kind === 'function') {
227
+ if (base && now && base.owner !== now.owner) unrestorable.add('owner');
228
+ }
229
+ if (kind === 'table' && change !== 'changed') {
230
+ unrestorable.add('table');
231
+ continue;
232
+ }
233
+ if (kind === 'function') {
234
+ const inPlace =
235
+ change === 'changed' &&
236
+ base.args === now.args &&
237
+ base.result === now.result;
238
+ if (!inPlace && (change === 'appeared' || change === 'changed'))
239
+ drops.push(d);
240
+ } else if (change === 'appeared' || change === 'changed') {
241
+ if (kind !== 'table') drops.push(d);
242
+ }
243
+ if (change === 'missing' || change === 'changed') creates.push(d);
244
+ }
245
+ if (unrestorable.size)
246
+ return { sql: null, unrestorable: [...unrestorable].sort() };
247
+
248
+ const stmts = [];
249
+ for (const kind of DROP_ORDER)
250
+ for (const d of drops
251
+ .filter((x) => x.kind === kind)
252
+ .sort((x, y) => fkRank(x, false) - fkRank(y, false) || cmp(x.key, y.key)))
253
+ if (d.now.drop) stmts.push(d.now.drop);
254
+ for (const kind of CREATE_ORDER)
255
+ for (const d of creates
256
+ .filter((x) => x.kind === kind)
257
+ .sort((x, y) => fkRank(x, true) - fkRank(y, true) || cmp(x.key, y.key))) {
258
+ const { base, now, key } = d;
259
+ if (kind === 'table') {
260
+ if (base.relkind === 'r' || base.relkind === 'p') {
261
+ stmts.push(
262
+ `alter table ${key} ${base.rls ? 'enable' : 'disable'} row level security`,
263
+ `alter table ${key} ${base.force ? 'force' : 'no force'} row level security`,
264
+ );
265
+ }
266
+ stmts.push(...aclLines('table', key, base, now));
267
+ continue;
268
+ }
269
+ if (base.create) stmts.push(base.create);
270
+ if (kind === 'function')
271
+ stmts.push(...aclLines('function', key, base, now));
272
+ }
273
+ if (!stmts.length) return { sql: null, unrestorable: [] };
274
+ stmts.push(`select pg_notify('pgrst', 'reload schema')`);
275
+ return {
276
+ sql: `do $mutate_check_restore$ begin\n${stmts.map(toPlpgsql).join(';\n')};\nend $mutate_check_restore$;`,
277
+ unrestorable: [],
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Top-level statements of a SQL script. A `;` inside '..', E'..' (backslash
283
+ * escapes), "..", $tag$..$tag$, -- comments and nested block comments does not
284
+ * split. Blank and comment-only pieces are dropped.
285
+ */
286
+ export function splitStatements(sql) {
287
+ const out = [];
288
+ let start = 0;
289
+ let meaningful = false;
290
+ const isId = (c) => c !== undefined && /[A-Za-z0-9_$]/.test(c);
291
+ const push = (end) => {
292
+ if (meaningful) out.push(sql.slice(start, end).trim());
293
+ meaningful = false;
294
+ };
295
+ let i = 0;
296
+ while (i < sql.length) {
297
+ const c = sql[i];
298
+ const two = sql.slice(i, i + 2);
299
+ if (two === '--') {
300
+ const nl = sql.indexOf('\n', i);
301
+ i = nl === -1 ? sql.length : nl + 1;
302
+ } else if (two === '/*') {
303
+ let depth = 1;
304
+ i += 2;
305
+ while (i < sql.length && depth > 0) {
306
+ if (sql.startsWith('/*', i)) (depth++, (i += 2));
307
+ else if (sql.startsWith('*/', i)) (depth--, (i += 2));
308
+ else i++;
309
+ }
310
+ } else if (c === "'") {
311
+ const esc = /[eE]/.test(sql[i - 1] ?? '') && !isId(sql[i - 2]);
312
+ meaningful = true;
313
+ i++;
314
+ while (i < sql.length) {
315
+ if (esc && sql[i] === '\\') i += 2;
316
+ else if (sql[i] === "'" && sql[i + 1] === "'") i += 2;
317
+ else if (sql[i] === "'") break;
318
+ else i++;
319
+ }
320
+ i++;
321
+ } else if (c === '"') {
322
+ meaningful = true;
323
+ const end = sql.indexOf('"', i + 1);
324
+ i = end === -1 ? sql.length : end + 1;
325
+ } else if (c === '$' && !isId(sql[i - 1])) {
326
+ const tag = /^\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i));
327
+ meaningful = true;
328
+ if (!tag) i++;
329
+ else {
330
+ const end = sql.indexOf(tag[0], i + tag[0].length);
331
+ i = end === -1 ? sql.length : end + tag[0].length;
332
+ }
333
+ } else if (c === ';') {
334
+ push(i);
335
+ start = ++i;
336
+ } else {
337
+ if (!/\s/.test(c)) meaningful = true;
338
+ i++;
339
+ }
340
+ }
341
+ push(sql.length);
342
+ return out;
343
+ }
344
+
345
+ const stripLeadingComments = (s) => {
346
+ let t = s.trimStart();
347
+ for (;;) {
348
+ if (t.startsWith('--'))
349
+ t = t
350
+ .slice(t.indexOf('\n') === -1 ? t.length : t.indexOf('\n'))
351
+ .trimStart();
352
+ else if (t.startsWith('/*')) {
353
+ const end = t.indexOf('*/');
354
+ t = (end === -1 ? '' : t.slice(end + 2)).trimStart();
355
+ } else return t;
356
+ }
357
+ };
358
+
359
+ /** A statement as plpgsql: no trailing `;`, a leading `select` becomes `perform`. */
360
+ export function toPlpgsql(stmt) {
361
+ const body = stmt.trim().replace(/;+\s*$/, '');
362
+ const head = /^(\s*(?:--[^\n]*\n\s*|\/\*[\s\S]*?\*\/\s*)*)select\b/i.exec(
363
+ body,
364
+ );
365
+ return head ? `${head[1]}perform${body.slice(head[0].length)}` : body;
366
+ }
367
+
368
+ const NO_WRAP = new Set([
369
+ 'do',
370
+ 'begin',
371
+ 'start',
372
+ 'commit',
373
+ 'end',
374
+ 'rollback',
375
+ 'savepoint',
376
+ 'release',
377
+ 'with',
378
+ 'copy',
379
+ 'vacuum',
380
+ ]);
381
+
382
+ /**
383
+ * The CLI channel runs one statement per call, so a multi-statement apply goes
384
+ * inside one do-block. A single statement (a `do` block included) is untouched.
385
+ */
386
+ export function wrapForCli(sql) {
387
+ const stmts = splitStatements(sql);
388
+ if (stmts.length < 2) return sql;
389
+ if (sql.includes('$mutate_check_wrap$'))
390
+ throw new Error(
391
+ 'cannot wrap for the CLI channel: the apply already uses $mutate_check_wrap$',
392
+ );
393
+ for (const s of stmts) {
394
+ const word = /^[A-Za-z]+/.exec(stripLeadingComments(s))?.[0].toLowerCase();
395
+ if (word && NO_WRAP.has(word))
396
+ throw new Error(
397
+ `cannot wrap for the CLI channel: a statement starts with "${word}"; put the apply in one do $$ ... $$ block`,
398
+ );
399
+ }
400
+ return `do $mutate_check_wrap$ begin\n${stmts.map(toPlpgsql).join(';\n')};\nend $mutate_check_wrap$;`;
401
+ }
402
+
403
+ const UNCOVERED =
404
+ /\b(?:alter\s+column|(?:add|drop)\s+column|alter\s+(?:type|domain|view|materialized\s+view|sequence|schema|extension|rule)|(?:create|drop)\s+(?:or\s+replace\s+)?(?:type|domain|view|materialized\s+view|sequence|schema|extension|rule|table)|comment\s+on|rename\s+(?:to|column)|(?:set|drop)\s+not\s+null|(?:set|drop)\s+default|owner\s+to|security\s+label|default\s+privileges)\b/i;
405
+
406
+ /** The first statement shape the digest cannot see (so it cannot prove a restore), or null. */
407
+ export function uncoveredStatement(sql) {
408
+ const m = UNCOVERED.exec(sql);
409
+ return m ? m[0].toLowerCase().replace(/\s+/g, ' ') : null;
410
+ }
411
+
412
+ /** supabase_db_<project_id> from the text of supabase/config.toml. */
413
+ export function dbContainer(configToml) {
414
+ for (const line of String(configToml).split('\n')) {
415
+ const m = /^\s*project_id\s*=\s*"([^"]+)"/.exec(line);
416
+ if (m) return `supabase_db_${m[1]}`;
417
+ }
418
+ throw new Error(
419
+ 'no project_id in supabase/config.toml; set it (supabase init writes one)',
420
+ );
421
+ }