@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,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
|
+
}
|