@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,214 @@
1
+ // The `object` form of a registry `sql` entry (the schema-snapshot form).
2
+ //
3
+ // A function guard is `object` + `find` + `replace`: the entry names the
4
+ // function, and the run edits `pg_get_functiondef(<object>)` instead of
5
+ // carrying a whole `create or replace function` copy that goes stale when the
6
+ // function changes. This module checks an entry against the committed schema
7
+ // snapshot (supabase/schema/) and expands it into the SQL `applySql` runs.
8
+ //
9
+ // Library only; no CLI. `check-mutations.mjs` calls `checkSqlEntry` and
10
+ // `checkProtects` per entry, `mutate-check.mjs` calls `checkSqlEntry` and
11
+ // `expandSql`. The object grammar (`parseObject`) and the JS matcher
12
+ // (`locate`) are mutate-check's; the Postgres ARE builder here is
13
+ // `sqlFindPattern`.
14
+ import { locate } from '#engine/mutations/locate.mjs';
15
+ import { parseObject, snapshotPath } from './sql-kind.mjs';
16
+
17
+ export const WHOLE_BODY =
18
+ /\bcreate\s+(?:or\s+replace\s+)?(?:function|procedure)\b|\bpg_get_functiondef\b/i;
19
+
20
+ // One statement of a grant / revoke / default-privileges / cron apply (or the
21
+ // PostgREST schema-reload notify that follows a grant), after
22
+ // an optional `do $x$ begin` or `execute '` wrapper is peeled off.
23
+ const PRIVILEGE_STATEMENT =
24
+ /^(?:(?:grant|revoke|alter\s+default\s+privileges|select\s+cron\.(?:schedule|unschedule))\b|perform\s+pg_notify\(\s*'pgrst'\s*,)/i;
25
+
26
+ function peel(statement) {
27
+ return statement
28
+ .replace(/^\s*do\s+\$[A-Za-z0-9_]*\$/i, '')
29
+ .replace(/^\s*begin\b/i, '')
30
+ .replace(/^\s*execute\s+'/i, '')
31
+ .replace(/'\s*$/, '')
32
+ .replace(/^\s*end\s+\$[A-Za-z0-9_]*\$\s*$/i, '')
33
+ .trim();
34
+ }
35
+
36
+ /** True when every statement of an apply is a grant, revoke, default-privileges or cron call. */
37
+ export const PRIVILEGE_ONLY = {
38
+ test(apply) {
39
+ const parts = String(apply)
40
+ .split(';')
41
+ .map(peel)
42
+ .filter((s) => s !== '');
43
+ return parts.length > 0 && parts.every((s) => PRIVILEGE_STATEMENT.test(s));
44
+ },
45
+ };
46
+
47
+ const WS_RUN = /\s+/g;
48
+
49
+ /** A Postgres ARE for `find`: every whitespace run matches `[[:space:]]+`, every other character is literal. */
50
+ export function sqlFindPattern(find) {
51
+ const escaped = find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
52
+ return escaped.replace(WS_RUN, '[[:space:]]+');
53
+ }
54
+
55
+ /** Text between `AS $tag$` and the closing `$tag$` of a function snapshot, or null. */
56
+ export function functionBody(snapshot) {
57
+ const open = /\bAS\s+(\$[A-Za-z0-9_]*\$)/.exec(snapshot);
58
+ if (!open) return null;
59
+ const from = open.index + open[0].length;
60
+ const end = snapshot.indexOf(open[1], from);
61
+ return end === -1 ? null : snapshot.slice(from, end);
62
+ }
63
+
64
+ /** True when the table snapshot names the trigger, policy or constraint of `o`. */
65
+ function hasNamedObject(text, o) {
66
+ const kinds =
67
+ o.kind === 'constraint' ? 'CONSTRAINT|FK CONSTRAINT' : o.kind.toUpperCase();
68
+ const header = new RegExp(
69
+ `^-- Name: ${o.table} ${o.name}; Type: (?:${kinds});`,
70
+ 'm',
71
+ );
72
+ if (header.test(text)) return true;
73
+ return (
74
+ o.kind === 'constraint' && new RegExp(`\\bCONSTRAINT ${o.name} `).test(text)
75
+ );
76
+ }
77
+
78
+ const edits = (entry) => [
79
+ entry,
80
+ ...(Array.isArray(entry.also) ? entry.also : []),
81
+ ];
82
+ const TABLE_KINDS = new Set(['trigger', 'policy', 'constraint']);
83
+
84
+ /**
85
+ * Why an sql entry is not valid, one string each; [] when valid. Shape rules
86
+ * always; snapshot rules unless `opts.shapeOnly` (a key new on this branch).
87
+ * `readFile(path)` returns the text or throws when the file is missing.
88
+ */
89
+ export function checkSqlEntry(entry, readFile, opts = {}) {
90
+ const errors = [];
91
+ if (WHOLE_BODY.test(entry.apply ?? ''))
92
+ errors.push(
93
+ 'whole-body apply: a function edit uses object + find + replace',
94
+ );
95
+ if (entry.object === undefined && !PRIVILEGE_ONLY.test(entry.apply ?? ''))
96
+ errors.push(
97
+ 'apply without object: only grants, revokes and cron are object-less',
98
+ );
99
+ const own = parseObject(entry.object);
100
+ const hasFind = entry.find !== undefined;
101
+ if (own?.kind === 'function' && hasFind)
102
+ for (const [i, e] of edits(entry).entries()) {
103
+ const at = i === 0 ? entry.object : `also[${i - 1}] ${e.object}`;
104
+ if (parseObject(e.object)?.kind !== 'function')
105
+ errors.push(`${at}: find and replace edit a function object`);
106
+ if (typeof e.find !== 'string' || e.find === '')
107
+ errors.push(`${at}: find must be a non-empty string`);
108
+ if (typeof e.replace !== 'string')
109
+ errors.push(`${at}: replace must be a string`);
110
+ }
111
+ else if (own?.kind === 'function' && !PRIVILEGE_ONLY.test(entry.apply ?? ''))
112
+ errors.push(
113
+ `function object without find: edit it with find + replace; apply alone is only for grants`,
114
+ );
115
+ if (own && own.kind !== 'function' && (hasFind || entry.also !== undefined))
116
+ errors.push(
117
+ `${entry.object}: only a function object takes find, replace or also`,
118
+ );
119
+ if (own && own.kind === 'cron' && !PRIVILEGE_ONLY.test(entry.apply ?? ''))
120
+ errors.push('cron object: apply must be cron.schedule or cron.unschedule');
121
+ if (opts.shapeOnly) return errors;
122
+
123
+ for (const edit of edits(entry)) {
124
+ const o = parseObject(edit.object);
125
+ if (!o) continue;
126
+ const file = snapshotPath(edit.object)[0];
127
+ let text = null;
128
+ try {
129
+ text = readFile(file);
130
+ } catch {
131
+ text = null;
132
+ }
133
+ const exists = text !== null;
134
+ if (!exists) {
135
+ errors.push(`unknown object ${edit.object}: ${file} does not exist`);
136
+ continue;
137
+ }
138
+ if (o.kind === 'function') {
139
+ const header = `-- Name: ${o.name}(${o.args}); Type: FUNCTION;`;
140
+ if (!text.includes(header)) {
141
+ errors.push(`unknown object ${edit.object}: no "${header}" in ${file}`);
142
+ continue;
143
+ }
144
+ if (typeof edit.find !== 'string' || edit.find === '') continue;
145
+ const body = functionBody(text.slice(text.indexOf(header)));
146
+ const n = body === null ? 0 : locate(body, edit.find).count;
147
+ if (n !== 1)
148
+ errors.push(
149
+ `find occurs ${n} times in the body of ${edit.object}, must be exactly 1`,
150
+ );
151
+ } else if (TABLE_KINDS.has(o.kind)) {
152
+ if (!hasNamedObject(text, o))
153
+ errors.push(`unknown object ${entry.object} in ${file}`);
154
+ }
155
+ }
156
+ return errors;
157
+ }
158
+
159
+ export { checkProtects } from '#engine/mutations/kinds.mjs';
160
+
161
+ // A dollar tag whose `$tag$` occurs in none of `texts`.
162
+ function freeTag(texts, taken = []) {
163
+ for (let i = 0; ; i++) {
164
+ const tag = `m${i === 0 ? '' : i}`;
165
+ if (!taken.includes(tag) && !texts.some((t) => t.includes(`$${tag}$`)))
166
+ return tag;
167
+ }
168
+ }
169
+
170
+ /** The SQL `applySql` receives for an entry. */
171
+ export function expandSql(entry) {
172
+ if (entry.find === undefined) return entry.apply;
173
+ const list = edits(entry);
174
+ const patterns = list.map((e) => sqlFindPattern(e.find));
175
+ const texts = [
176
+ entry.apply ?? '',
177
+ ...patterns,
178
+ ...list.map((e) => e.find),
179
+ ...list.map((e) => e.replace),
180
+ ];
181
+ const outer = freeTag(texts);
182
+ const inner = freeTag(texts, [outer]);
183
+ const q = (s) => `$${inner}$${s}$${inner}$`;
184
+ const groups = new Map();
185
+ list.forEach((e, i) => {
186
+ groups.set(e.object, [...(groups.get(e.object) ?? []), i]);
187
+ });
188
+ const body = [];
189
+ for (const [object, idx] of groups) {
190
+ const o = parseObject(object);
191
+ const label = object.replace(/%/g, '%%');
192
+ body.push(
193
+ `d := pg_get_functiondef('${o.schema}.${o.name}(${o.args})'::regprocedure);`,
194
+ // Edit the body only (prosrc, the last text in the definition): a find in the signature is never meant.
195
+ `b := (select prosrc from pg_proc where oid = '${o.schema}.${o.name}(${o.args})'::regprocedure);`,
196
+ 'k := length(d) - strpos(reverse(d), reverse(b)) - length(b) + 2;',
197
+ `if substr(d, k, length(b)) <> b then raise exception 'mutation cannot locate the body of ${label}'; end if;`,
198
+ 'h := substr(d, 1, k - 1); t := substr(d, k + length(b));',
199
+ );
200
+ for (const i of idx) {
201
+ const p = q(patterns[i]);
202
+ const r = q(list[i].replace.replace(/\\/g, '\\\\'));
203
+ body.push(
204
+ `if regexp_count(b, ${p}) <> 1 then raise exception 'mutation find occurs % times in ${label}', regexp_count(b, ${p}); end if;`,
205
+ `n := regexp_replace(b, ${p}, ${r});`,
206
+ `if n = b then raise exception 'mutation replace changed nothing in ${label}'; end if;`,
207
+ 'b := n;',
208
+ );
209
+ }
210
+ body.push('execute h || b || t;');
211
+ }
212
+ const block = `do $${outer}$ declare d text; n text; b text; h text; t text; k int; begin\n${body.join('\n')}\nend $${outer}$;`;
213
+ return entry.apply ? `${entry.apply.trimEnd()}\n${block}` : block;
214
+ }
@@ -0,0 +1,48 @@
1
+ // The Supabase stack as a nightly payload: docker must be up, the
2
+ // stack must already be running (the nightly never starts it), it is reset
3
+ // before the suites and again after them for the main checkout.
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ export const dockerInfo = async () =>
7
+ spawnSync('docker', ['info'], { stdio: 'ignore', timeout: 10_000 }).status ===
8
+ 0;
9
+
10
+ export function supabaseStack({ preflight, reset } = {}) {
11
+ if (
12
+ !Array.isArray(preflight) ||
13
+ preflight.length === 0 ||
14
+ !Array.isArray(reset) ||
15
+ reset.length === 0
16
+ )
17
+ throw new TypeError(
18
+ 'supabase-next: supabaseStack needs preflight and reset argv arrays',
19
+ );
20
+ return {
21
+ name: 'supabase-stack',
22
+ io: { dockerUp: dockerInfo },
23
+ async preflight(main, io) {
24
+ if (!(await io.dockerUp())) return { reason: 'docker-down' };
25
+ return { ok: true };
26
+ },
27
+ async before(wt, io) {
28
+ const pre = await io.run(preflight[0], preflight.slice(1), {
29
+ cwd: wt,
30
+ limitS: 180,
31
+ });
32
+ if (pre.code !== 0) return { reason: 'stack-down', runAfter: false };
33
+ const rst = await io.run(reset[0], reset.slice(1), {
34
+ cwd: wt,
35
+ limitS: 600,
36
+ });
37
+ if (rst.code !== 0) return { reason: 'reset-failed', runAfter: true };
38
+ return { ok: true };
39
+ },
40
+ async after(main, io) {
41
+ const fr = await io.run(reset[0], reset.slice(1), {
42
+ cwd: main,
43
+ limitS: 600,
44
+ });
45
+ return fr.code === 0 ? 'pass' : 'fail';
46
+ },
47
+ };
48
+ }
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "@ticket-gate/supabase-next",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-rc.1",
4
+ "description": "The Supabase and Next.js pack for @ticket-gate/engine.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "engines": { "node": ">=22" },
8
+ "exports": {
9
+ "./*": "./*",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "imports": { "#engine/*": "@ticket-gate/engine/*" },
13
+ "dependencies": { "@ticket-gate/engine": "0.1.0-rc.1" },
14
+ "files": ["*", "!test"],
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/dmitry-prohorov/ticket-gate.git",
18
+ "directory": "packages/supabase-next"
19
+ },
20
+ "publishConfig": { "access": "public", "provenance": true }
21
+ }
@@ -0,0 +1,10 @@
1
+ const NEXT = 'npx --no-install next';
2
+
3
+ /** The profiling build of a Next.js app in the `app` directory. */
4
+ export const nextProfiling = ({ app }) => ({
5
+ command: `cd ${app} && ${NEXT} build --profile --no-mangling`,
6
+ unmangle: '--no-mangling',
7
+ start: `cd ${app} && ${NEXT} start`,
8
+ port: 3000,
9
+ noBuildLabel: 'unknown (existing server on :3000)',
10
+ });
@@ -0,0 +1,72 @@
1
+ // Supabase hooks for the React profiler: reset the local database, read the
2
+ // magic link from Mailpit, and the two service probes.
3
+ import {
4
+ runBounded,
5
+ stackPreflight,
6
+ } from './checks/stack-preflight.mjs';
7
+
8
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+
10
+ /** Reset the local db. Re-checks the stack first (a red stack throws, no reset) and bounds the reset to 300 s. */
11
+ export async function dbReset(io) {
12
+ const problems = await io.checkStack(['db']);
13
+ if (problems.length > 0) throw new Error(`dbReset: ${problems.join('; ')}`);
14
+ const r = await io.run('pnpm db:reset', { limitS: 300 });
15
+ if (r.timedOut === true) throw new Error('dbReset: TIMEOUT after 300 s');
16
+ if (r.code !== 0 && r.code !== undefined)
17
+ throw new Error(`dbReset: pnpm db:reset exited ${r.code}`);
18
+ }
19
+
20
+ export function mailpitMagicLink({ mailUrl, email }) {
21
+ return async (since) => {
22
+ for (let i = 0; i < 40; i++) {
23
+ await sleep(500);
24
+ const query = encodeURIComponent(`to:"${email}"`);
25
+ const list = await (
26
+ await fetch(`${mailUrl}/api/v1/search?query=${query}`, {
27
+ signal: AbortSignal.timeout(10_000),
28
+ })
29
+ ).json();
30
+ const m = (list.messages ?? []).find(
31
+ (x) => Date.parse(x.Created) >= since - 2000,
32
+ );
33
+ if (!m) continue;
34
+ const msg = await (
35
+ await fetch(`${mailUrl}/api/v1/message/${m.ID}`, {
36
+ signal: AbortSignal.timeout(10_000),
37
+ })
38
+ ).json();
39
+ await fetch(`${mailUrl}/api/v1/messages`, {
40
+ method: 'DELETE',
41
+ body: JSON.stringify({ IDs: [m.ID] }),
42
+ signal: AbortSignal.timeout(10_000),
43
+ });
44
+ const url = (msg.Text ?? '').match(/https?:\/\/\S+\/auth\/v1\/verify\S*/);
45
+ if (url) return url[0];
46
+ }
47
+ throw new Error(`no magic link for ${email} in Mailpit (${mailUrl})`);
48
+ };
49
+ }
50
+
51
+ /** `stack` is { ...STACK_PREFLIGHT, services }. */
52
+ export function profileHooks({ root, stack, apiUrl, mailUrl, email }) {
53
+ const checked = stackPreflight({ ...stack, root });
54
+ const io = {
55
+ checkStack: async (steps) =>
56
+ (await checked.checkStack({ steps }, checked.realIo(root))).problems,
57
+ run: (cmd, opts) => runBounded(cmd, { cwd: root, ...opts }),
58
+ };
59
+ const problems = {
60
+ noSupabase: `the Supabase API (${apiUrl}) does not answer: pnpm supabase start`,
61
+ noMailpit: `Mailpit (${mailUrl}) does not answer: pnpm supabase start`,
62
+ };
63
+ return {
64
+ reset: () => dbReset(io),
65
+ magicLink: mailpitMagicLink({ mailUrl, email }),
66
+ services: [
67
+ { url: `${apiUrl}/auth/v1/health`, problem: problems.noSupabase },
68
+ { url: `${mailUrl}/livez`, problem: problems.noMailpit },
69
+ ],
70
+ problems,
71
+ };
72
+ }
package/score.mjs ADDED
@@ -0,0 +1,26 @@
1
+ // The base-worktree install of a pnpm workspace package: the `baseInstall` a score
2
+ // config needs, built from the package name or filter pattern.
3
+
4
+ /**
5
+ * A `score.baseInstall` that installs `filter` and the packages it depends on, frozen
6
+ * to the lockfile. The marker is written only after a successful install.
7
+ */
8
+ export function pnpmFilterInstall(filter) {
9
+ if (typeof filter !== "string" || filter === "" || /[\s'"`]/.test(filter))
10
+ throw new Error(
11
+ "pnpmFilterInstall: filter must be a package name or pattern",
12
+ );
13
+ return {
14
+ cmd: "pnpm",
15
+ args: [
16
+ "install",
17
+ "--frozen-lockfile",
18
+ "--prefer-offline",
19
+ "--filter",
20
+ `${filter}...`,
21
+ ],
22
+ label: "pnpm install",
23
+ hint: `pnpm install --frozen-lockfile --filter '${filter}...'`,
24
+ marker: ".ticket-gate-install-done",
25
+ };
26
+ }
@@ -0,0 +1,40 @@
1
+ import { listFiles } from '#engine/graph/js.mjs';
2
+
3
+ // ---------------------------------------------------------------- graph root and check
4
+
5
+ /** @param {string} relToApp path relative to the app dir; '*' = shell entry, null = orphan */
6
+ export function routeAreaIn(table, relToApp) {
7
+ let best = null;
8
+ for (const [prefix, area] of table) {
9
+ if (!relToApp.startsWith(prefix)) continue;
10
+ if (best === null || prefix.length > best[0].length) best = [prefix, area];
11
+ }
12
+ return best ? best[1] : null;
13
+ }
14
+
15
+ /**
16
+ * The Next app router as a graph Root and a Check: entries() maps each route file to its area,
17
+ * run() reports an unmapped route (orphan) and counts the entries.
18
+ */
19
+ export function nextAppRoutes({ appDir, entryRe, routeAreas }) {
20
+ const routes = (root) =>
21
+ listFiles(root, appDir, (p) =>
22
+ entryRe.test(p.slice(appDir.length + 1)),
23
+ ).map((file) => ({
24
+ file,
25
+ area: routeAreaIn(routeAreas, file.slice(appDir.length + 1)),
26
+ }));
27
+ return {
28
+ name: 'next-routes',
29
+ entries: (root) => routes(root).filter((e) => e.area !== null),
30
+ run(root) {
31
+ const all = routes(root);
32
+ return {
33
+ problems: all
34
+ .filter((e) => e.area === null)
35
+ .map((e) => `orphan-route ${e.file.slice(appDir.length + 1)}`),
36
+ entries: all.length,
37
+ };
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The Supabase Edge Functions as a selection rule. Any change under `dir` is a full run
3
+ * except a function that has its own area (`areas`: sub-path prefix -> area), which adds only
4
+ * that area. Runs after the triggers, so the shared function config files stay triggers.
5
+ */
6
+ export function supabaseFunctions({ dir, areas }) {
7
+ return {
8
+ name: 'supabase-functions',
9
+ stage: 'after-triggers',
10
+ rule(path) {
11
+ if (!path.startsWith(dir)) return null;
12
+ const sub = path.slice(dir.length);
13
+ for (const [prefix, area] of Object.entries(areas))
14
+ if (sub.startsWith(prefix)) return { areas: [area] };
15
+ return { full: 'function' };
16
+ },
17
+ };
18
+ }
@@ -0,0 +1,44 @@
1
+ import { posix } from 'node:path';
2
+
3
+ /**
4
+ * A workspace package as a resolver: `name` -> `${dir}/src/index.ts`, `name/sub` through the
5
+ * package.json exports map (dist/ mirrors src/), else the same path under src/.
6
+ * @param {string} name package name, e.g. '@scope/pkg'
7
+ * @param {string} dir package directory relative to the repo root
8
+ */
9
+ export function workspaceExports(name, dir) {
10
+ const src = `${dir}/src`;
11
+ // One exports map per graph (ctx), not per process: config.mjs builds this resolver once.
12
+ const maps = new WeakMap();
13
+ const target = (sub, ctx) => {
14
+ if (!maps.has(ctx)) {
15
+ let exp = {};
16
+ try {
17
+ exp = JSON.parse(ctx.readText(`${dir}/package.json`)).exports ?? {};
18
+ } catch {
19
+ exp = {};
20
+ }
21
+ maps.set(ctx, exp);
22
+ }
23
+ const e = maps.get(ctx)[`./${sub}`];
24
+ const t = typeof e === 'string' ? e : (e?.types ?? e?.import);
25
+ const m =
26
+ typeof t === 'string' ? /^\.\/dist\/(.+?)(\.d\.ts|\.js)$/.exec(t) : null;
27
+ return m ? m[1] : null;
28
+ };
29
+ return {
30
+ name: `workspace ${name}`,
31
+ resolve(spec, _from, ctx) {
32
+ if (spec === name) {
33
+ const file = posix.join(src, 'index.ts');
34
+ return ctx.exists(file) ? { file } : { unresolved: true };
35
+ }
36
+ if (!spec.startsWith(`${name}/`)) return null;
37
+ const sub = spec.slice(name.length + 1);
38
+ const mapped = target(sub, ctx);
39
+ let out = ctx.viaTs(`./${mapped ?? sub}`, `${src}/_.ts`);
40
+ if (!out && mapped) out = ctx.viaTs(`./${sub}`, `${src}/_.ts`);
41
+ return out ?? { unresolved: true };
42
+ },
43
+ };
44
+ }