@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/sql-ranges.mjs ADDED
@@ -0,0 +1,185 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ // A dollar-quote opener: `$$` or `$tag$`, the tag an identifier not starting with a digit.
5
+ const DOLLAR_TAG = /\$([A-Za-z_][A-Za-z_0-9]*)?\$/y;
6
+ const FUNCTION_START = /create\s+(?:or\s+replace\s+)?function\b/iy;
7
+ const IDENT_CHAR = /[A-Za-z0-9_$]/;
8
+ const IDENT = '(?:"(?:[^"]|"")+"|[A-Za-z_][A-Za-z_0-9$]*)';
9
+ const FUNCTION_NAME = new RegExp(
10
+ `\\s*(${IDENT})(?:\\s*\\.\\s*(${IDENT}))?\\s*\\(`,
11
+ 'y',
12
+ );
13
+
14
+ const unquote = (id) =>
15
+ id.startsWith('"') ? id.slice(1, -1).replace(/""/g, '"') : id.toLowerCase();
16
+
17
+ /** [schema, name] of the function a statement creates, unqualified = public; null when unreadable. */
18
+ function functionName(text, from) {
19
+ FUNCTION_NAME.lastIndex = from;
20
+ const m = FUNCTION_NAME.exec(text);
21
+ if (!m) return null;
22
+ return m[2] === undefined
23
+ ? ['public', unquote(m[1])]
24
+ : [unquote(m[1]), unquote(m[2])];
25
+ }
26
+
27
+ /** The snapshot (the project's schema dump) dumps only public and private. */
28
+ const defaultInSnapshot = (schema) =>
29
+ schema === 'public' || schema === 'private';
30
+
31
+ /** End index of the opaque construct (comment, string, quoted identifier, dollar-quote)
32
+ * opening at text[i]; -1 when none opens there; null when it never closes. */
33
+ function skipOpaque(text, i) {
34
+ const c = text[i];
35
+ if (c === '-' && text[i + 1] === '-') {
36
+ const nl = text.indexOf('\n', i);
37
+ return nl === -1 ? text.length : nl;
38
+ }
39
+ if (c === '/' && text[i + 1] === '*') {
40
+ let depth = 1;
41
+ let j = i + 2;
42
+ while (j < text.length && depth > 0) {
43
+ if (text[j] === '/' && text[j + 1] === '*') {
44
+ depth += 1;
45
+ j += 2;
46
+ } else if (text[j] === '*' && text[j + 1] === '/') {
47
+ depth -= 1;
48
+ j += 2;
49
+ } else j += 1;
50
+ }
51
+ return depth === 0 ? j : null;
52
+ }
53
+ if (c === "'") {
54
+ const escapes =
55
+ i > 0 && /[eE]/.test(text[i - 1]) && !IDENT_CHAR.test(text[i - 2] ?? ' ');
56
+ let j = i + 1;
57
+ while (j < text.length) {
58
+ if (escapes && text[j] === '\\') j += 2;
59
+ else if (text[j] === "'") {
60
+ if (text[j + 1] === "'") j += 2;
61
+ else return j + 1;
62
+ } else j += 1;
63
+ }
64
+ return null;
65
+ }
66
+ if (c === '"') {
67
+ let j = i + 1;
68
+ while (j < text.length) {
69
+ if (text[j] === '"') {
70
+ if (text[j + 1] === '"') j += 2;
71
+ else return j + 1;
72
+ } else j += 1;
73
+ }
74
+ return null;
75
+ }
76
+ if (c === '$' && !IDENT_CHAR.test(text[i - 1] ?? ' ')) {
77
+ DOLLAR_TAG.lastIndex = i;
78
+ const m = DOLLAR_TAG.exec(text);
79
+ if (!m) return -1;
80
+ const close = text.indexOf(m[0], i + m[0].length);
81
+ return close === -1 ? null : close + m[0].length;
82
+ }
83
+ return -1;
84
+ }
85
+
86
+ /** [start, end) character spans of every function statement; null when the file cannot be parsed. */
87
+ function functionSpans(text, inSnapshot) {
88
+ const spans = [];
89
+ let i = 0;
90
+ while (i < text.length) {
91
+ const opaque = skipOpaque(text, i);
92
+ if (opaque === null) return null;
93
+ if (opaque !== -1) {
94
+ i = opaque;
95
+ continue;
96
+ }
97
+ FUNCTION_START.lastIndex = i;
98
+ const m = IDENT_CHAR.test(text[i - 1] ?? ' ')
99
+ ? null
100
+ : FUNCTION_START.exec(text);
101
+ if (!m) {
102
+ i += 1;
103
+ continue;
104
+ }
105
+ let j = i + m[0].length;
106
+ let inBody = false;
107
+ for (;;) {
108
+ if (j >= text.length) return null;
109
+ const o = skipOpaque(text, j);
110
+ if (o === null) return null;
111
+ if (o !== -1) {
112
+ if (text[j] === '$') inBody = true;
113
+ j = o;
114
+ } else if (text[j] === ';') {
115
+ if (!inBody) return null;
116
+ break;
117
+ } else j += 1;
118
+ }
119
+ const qn = functionName(text, i + m[0].length);
120
+ if (qn && inSnapshot(qn[0], qn[1])) spans.push([i, j + 1]);
121
+ i = j + 1;
122
+ }
123
+ return spans;
124
+ }
125
+
126
+ /** 1-based inclusive line ranges outside every `create [or replace] function` statement,
127
+ * adjacent ranges merged, ascending. A function is cut only when inSnapshot(schema, name)
128
+ * says supabase/schema/functions/ shows it (default: schema public or private); a
129
+ * pg_temp or create-and-drop helper keeps its body in the ranges. null = read the whole file: no function statement,
130
+ * an unparseable file (unclosed quote or comment, a function without a dollar-quoted
131
+ * body), or an empty result. */
132
+ export function migrationReadRanges(text, inSnapshot = defaultInSnapshot) {
133
+ const spans = functionSpans(text, inSnapshot);
134
+ if (spans === null) return null;
135
+ if (spans.length === 0) return null;
136
+ const covered = new Uint8Array(text.length);
137
+ for (const [a, b] of spans) covered.fill(1, a, b);
138
+ const lines = text.split('\n');
139
+ if (lines[lines.length - 1] === '') lines.pop();
140
+ const ranges = [];
141
+ let offset = 0;
142
+ lines.forEach((line, k) => {
143
+ const end = offset + line.length;
144
+ let cut = true;
145
+ let blank = true;
146
+ for (let p = offset; p < end; p += 1) {
147
+ if (/\s/.test(text[p])) continue;
148
+ blank = false;
149
+ if (!covered[p]) cut = false;
150
+ }
151
+ // A blank line is cut only when it sits inside a span (not between statements).
152
+ if (blank) cut = spans.some(([a, b]) => a < offset && end < b);
153
+ if (!cut) {
154
+ const n = k + 1;
155
+ const last = ranges[ranges.length - 1];
156
+ if (last && last[1] === n - 1) last[1] = n;
157
+ else ranges.push([n, n]);
158
+ }
159
+ offset = end + 1;
160
+ });
161
+ if (ranges.length === 0) return null;
162
+ return ranges;
163
+ }
164
+
165
+ /** The H1 reason tail after the read ranges (`fmt` = the formatted ranges). */
166
+ export const H1_REASON = (fmt) =>
167
+ `read lines ${fmt.join(', ')}; function bodies in supabase/schema/functions/`;
168
+
169
+ /** What `collect` records for a changed path: a migration's text, and whether a function
170
+ * has its snapshot file (a function is cut from the read ranges only when it does). */
171
+ export function migrationFacts(path, root) {
172
+ let text = null;
173
+ if (path.startsWith('supabase/migrations/')) {
174
+ try {
175
+ text = readFileSync(join(root, path), 'utf8');
176
+ } catch {
177
+ /* unreadable: read whole */
178
+ }
179
+ }
180
+ const inSnapshot = (schema, name) =>
181
+ existsSync(
182
+ join(root, 'supabase/schema/functions', `${schema}.${name}.sql`),
183
+ );
184
+ return { text, inSnapshot };
185
+ }
package/stack.mjs ADDED
@@ -0,0 +1,4 @@
1
+ // e2e and the registry step share one service list.
2
+ const E2E_SERVICES = ['db', 'kong', 'auth', 'rest', 'realtime', 'inbucket'];
3
+
4
+ export { E2E_SERVICES };
@@ -0,0 +1,41 @@
1
+ // The supabase-next pack's step defaults: the db:reset command, the e2e trigger test and
2
+ // the summaries the project's gate.config.mjs shares between its db and seed steps.
3
+ import { pick } from '#engine/config.mjs';
4
+ import { tools } from './tools.mjs';
5
+
6
+ // db:reset regenerates db.types.ts and supabase/schema/, so
7
+ // RESET only checks both generated outputs. The untracked guard comes first
8
+ // because `git diff` ignores a new snapshot file.
9
+ const DRIFT =
10
+ '! git ls-files --others --exclude-standard supabase/schema/ | grep . && git diff --exit-code packages/core/src/db.types.ts supabase/schema/';
11
+ export const RESET = `pnpm db:reset && ${DRIFT}`;
12
+
13
+ export const E2E_FULL_CMD = 'pnpm test:e2e';
14
+
15
+ export const resetSummary = (out) =>
16
+ pick(out, [/Finished .*db reset/i, /Applying migration/]) ??
17
+ 'db:reset applied; db.types.ts clean';
18
+
19
+ const isTrigger = (p) =>
20
+ tools.e2eAreas.FULL_TRIGGERS.some((t) => p === t || p.startsWith(t));
21
+
22
+ /**
23
+ * G1 and R4 (a)-(d): does this diff need an e2e run? (e), a tools/ file the
24
+ * harness imports, needs the selector and is main's.
25
+ */
26
+ export function e2eWhen(paths) {
27
+ if (paths.some((p) => p.startsWith('apps/web'))) return true;
28
+ // prettier-ignore
29
+ if (paths.some((p) => p.startsWith('packages/core/src/') && !tools.e2eAreas.TEST_FILE_RE.test(p))) return true;
30
+ if (
31
+ paths.some(
32
+ (p) =>
33
+ p.startsWith('supabase/migrations/') ||
34
+ p === 'supabase/seed.sql' ||
35
+ p.startsWith('supabase/functions/'),
36
+ )
37
+ )
38
+ return true;
39
+ if (paths.some(isTrigger)) return true;
40
+ return false;
41
+ }
package/tools.mjs ADDED
@@ -0,0 +1,22 @@
1
+ // The project modules this pack calls, injected by supabaseNext({ tools }) in index.mjs
2
+ // (gate.config.mjs passes them). The pack imports nothing outside tools/gate/ and node:.
3
+ // Keys: e2eAreas (a module
4
+ // namespace, or any object with the same named exports).
5
+ let bound = null;
6
+
7
+ export function bindTools(tools) {
8
+ bound = tools;
9
+ }
10
+
11
+ export const tools = new Proxy(
12
+ {},
13
+ {
14
+ get(_, key) {
15
+ if (!bound)
16
+ throw new Error(
17
+ 'supabase-next: no project tools bound. Construct the pack first: supabaseNext({ tools }) in gate.config.mjs.',
18
+ );
19
+ return bound[key];
20
+ },
21
+ },
22
+ );