@starci/eslint-canon-be 3.4.39 → 3.4.41

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +2 -2
  3. package/boundary-schema.mjs +32 -4
  4. package/idempotency.mjs +3 -0
  5. package/index.mjs +2 -0
  6. package/infra-owner.mjs +4 -3
  7. package/lib/client-idempotency.mjs +403 -0
  8. package/lib/persistence.mjs +1 -1
  9. package/lib/schema-values.mjs +90 -0
  10. package/lib/unit-spec.mjs +6 -0
  11. package/operations.mjs +197 -32
  12. package/package.json +1 -1
  13. package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
  14. package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
  15. package/runtime/knowledge/hfs/rules.yaml +168 -47
  16. package/runtime/knowledge/hfs/slots.yaml +110 -52
  17. package/runtime/knowledge/sonar-gate.yaml +1 -1
  18. package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
  19. package/runtime/modules/models/registry.yaml +3 -1
  20. package/runtime/modules/models/runtimes.yaml +2 -0
  21. package/runtime/scripts/api/git/ls-tree.mjs +5 -0
  22. package/runtime/scripts/api/git/show.mjs +6 -0
  23. package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
  24. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
  25. package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
  26. package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
  27. package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
  28. package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
  29. package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
  30. package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
  31. package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
  32. package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
  33. package/runtime/scripts/hfs/declaration-slots.mjs +12 -3
  34. package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
  35. package/runtime/scripts/hfs/rule-params-shape.mjs +16 -6
  36. package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
  37. package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
  38. package/runtime/scripts/hfs/slot-side-problems.mjs +2 -2
  39. package/runtime/scripts/hfs/slots.mjs +1 -0
  40. package/runtime/scripts/hfs/view.mjs +2 -1
  41. package/runtime/scripts/lib/in-order.mjs +71 -0
  42. package/runtime/scripts/lib/language.mjs +94 -1
  43. package/runtime/scripts/lib/package-at.mjs +62 -0
  44. package/runtime/scripts/lib/same-text.mjs +5 -0
  45. package/runtime/scripts/lib/ts-analysis.mjs +10 -0
  46. package/suppression.mjs +1 -1
  47. package/unit-spec.mjs +66 -6
  48. package/uploads.mjs +112 -0
@@ -51,7 +51,7 @@ export function valueObjectSubjects(ts, sourceFile) {
51
51
  }
52
52
 
53
53
  /** The value of one literal object property. */
54
- const propertyValue = (ts, object, key) => {
54
+ export const propertyValue = (ts, object, key) => {
55
55
  const property = object.properties.find(item => keyOf(ts, item) === key);
56
56
  return property && ts.isPropertyAssignment(property) ? property.initializer : null;
57
57
  };
@@ -0,0 +1,53 @@
1
+ /** The app emitter consumes the API app's declared operation table without importing it from app.module.ts. */
2
+ import path from 'node:path';
3
+ import { hasAnyFlag } from '../../lib/ts-ast.mjs';
4
+
5
+ const actualSymbol = (ts, checker, symbol) => symbol && hasAnyFlag(symbol.flags, ts.SymbolFlags.Alias) ? checker.getAliasedSymbol(symbol) : symbol;
6
+
7
+ /** Follow declaration aliases while retaining the actual expression rather than an asserted type. */
8
+ const initializerOf = (ts, checker, node, seen = new Set()) => {
9
+ if (!node || seen.has(node)) return null;
10
+ seen.add(node);
11
+ if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)
12
+ || ts.isSatisfiesExpression(node) || ts.isNonNullExpression(node)) return initializerOf(ts, checker, node.expression, seen);
13
+ if (!ts.isIdentifier(node) && !ts.isPropertyAccessExpression(node)) return node;
14
+ const declaration = actualSymbol(ts, checker, checker.getSymbolAtLocation(node))?.valueDeclaration;
15
+ return declaration?.initializer ? initializerOf(ts, checker, declaration.initializer, seen) : node;
16
+ };
17
+
18
+ /** A same-named local function cannot declare the platform operation contract. */
19
+ const isOperationDefiner = (ts, checker, graph, config, expression, seen = new Set()) => {
20
+ const symbol = actualSymbol(ts, checker, checker.getSymbolAtLocation(expression));
21
+ if (!symbol || seen.has(symbol)) return false;
22
+ seen.add(symbol);
23
+ return (symbol?.declarations ?? []).some(declaration => {
24
+ const relative = path.relative(config.root, declaration.getSourceFile().fileName).split(path.sep).join('/');
25
+ const owner = graph.resolver.ownerOf(relative);
26
+ if (declaration.name?.text === 'defineOperations' && owner?.slot === 'be.platform' && path.posix.basename(owner.root) === 'operations') return true;
27
+ const alias = declaration.initializer;
28
+ return Boolean(alias && (ts.isIdentifier(alias) || ts.isPropertyAccessExpression(alias)) && isOperationDefiner(ts, checker, graph, config, alias, seen));
29
+ });
30
+ };
31
+
32
+ /** True only for an exported, nonempty registration by the canon platform capability. */
33
+ const declaresOperations = (context, graph, config, sourceFile) => {
34
+ const { ts } = context;
35
+ const checker = context.checkerFor?.(sourceFile.fileName);
36
+ const moduleSymbol = checker?.getSymbolAtLocation(sourceFile);
37
+ if (!moduleSymbol) return false;
38
+ const exported = checker.getExportsOfModule(moduleSymbol).find(symbol => symbol.getName() === 'OPERATIONS');
39
+ const declaration = actualSymbol(ts, checker, exported)?.valueDeclaration;
40
+ const registration = initializerOf(ts, checker, declaration?.initializer);
41
+ if (!registration || !ts.isCallExpression(registration) || !isOperationDefiner(ts, checker, graph, config, registration.expression)) return false;
42
+ const entries = initializerOf(ts, checker, registration.arguments[0]);
43
+ return entries !== null && ts.isObjectLiteralExpression(entries) && checker.getPropertiesOfType(checker.getTypeAtLocation(entries)).length > 0;
44
+ };
45
+
46
+ /** Only the emitter's exact entry of a declared API app is a contract root; workers and arbitrary app files remain judged. */
47
+ export function operationContractRoots(context, graph, config) {
48
+ return (config.apps ?? []).filter(app => app.kind === 'api').flatMap(app => {
49
+ const relative = `apps/${app.name}/src/operations.ts`;
50
+ const node = graph.files.get(relative);
51
+ return node?.tier === 'app' && node.slot === 'be.app.api' && declaresOperations(context, graph, config, node.sourceFile) ? [relative] : [];
52
+ });
53
+ }
@@ -13,7 +13,7 @@ import { byCodeUnit } from '../../lib/list.mjs';
13
13
 
14
14
  export const REACHABILITY_RULE_IDS = ['BE_FEATURE_NOT_COMPOSED', 'FE_OWNER_REACHABLE', 'FE_HREF_RESOLVES'];
15
15
 
16
- const BE_CAPABILITY_SLOTS = new Set(['be.domain', 'be.integrations', 'be.platform']);
16
+ const BE_CAPABILITY_SLOTS = new Set(['be.domain', 'be.integrations', 'be.platform', 'be.platform.logging']);
17
17
  const TEST_FILE = /(?:\.(?:spec|test|stories|e2e-spec)\.[cm]?[jt]sx?$)|(?:(?:^|\/)__tests__\/)/;
18
18
  const ROUTE_FILE = /^(?:page|route)\.(?:tsx|ts|jsx|js)$/;
19
19
  const PLACEHOLDER = '\u0000';
@@ -3,6 +3,7 @@ import { analyzeSql, HOLE } from './sql-tokens.mjs';
3
3
  import { machineKit } from './machine-ast.mjs';
4
4
  import { contextModelOf } from './context-map.mjs';
5
5
  import { supabaseTablesOf } from './supabase-tables.mjs';
6
+ import { supabaseUniqueSetsOf } from './supabase-keys.mjs';
6
7
 
7
8
  /**
8
9
  * R86 `sql-owner` (BE_SQL_TABLE_OWNER). Every `sql` tagged template (the tag declared in platform/database) of a
@@ -14,10 +15,10 @@ import { supabaseTablesOf } from './supabase-tables.mjs';
14
15
  * to the table owner's public entry); a table of another context is refused (cross-context data comes only by events);
15
16
  * - every table it names is declared by some entity under TypeORM authority, or by the generated
16
17
  * `Database['public']['Tables']` contract under Supabase authority;
17
- * - a SELECT carries LIMIT, or constrains the primary key or a unique key of its first table with `=`, or selects only
18
- * aggregates without GROUP BY.
18
+ * - a SELECT carries LIMIT, or reads a single relation under a complete primary/unique-key equality to scalar values,
19
+ * or selects only proven scalar aggregates without GROUP BY. Inferred bounds reject multiplying projections and set operations.
19
20
  * Table positions that hold a `${...}` substitution (SqlIdent) are dynamic; they are counted in the coverage and judged by
20
- * nobody. Joined tables are not part of the bound: a to-many join under a key-constrained first table is a review point.
21
+ * nobody. A join needs an explicit result bound because key constraints on its first relation do not bound joined rows.
21
22
  */
22
23
  export const SQL_OWNER_RULE_IDS = ['BE_SQL_TABLE_OWNER'];
23
24
 
@@ -132,7 +133,8 @@ const capabilityTable = root => path.posix.basename(root).replaceAll('-', '_').t
132
133
  /** Supabase declarations, with an owner when the generated table name matches a capability's persistence folder. */
133
134
  function supabaseDeclarations(graph, appRoot, ts) {
134
135
  const generated = supabaseTablesOf(appRoot, ts);
135
- const tables = new Map([...generated].map(table => [table, { table, owner: null, rel: null, uniqueSets: [] }]));
136
+ const keys = supabaseUniqueSetsOf(appRoot);
137
+ const tables = new Map([...generated].map(table => [table, { table, owner: null, rel: null, uniqueSets: keys.get(table) ?? [] }]));
136
138
  for (const file of graph.files.values()) {
137
139
  if (file.slot !== 'be.persistence' || !file.owner) continue;
138
140
  const key = capabilityTable(file.owner.root);
@@ -142,6 +144,12 @@ function supabaseDeclarations(graph, appRoot, ts) {
142
144
  return tables;
143
145
  }
144
146
 
147
+ /** A capability owns the table named for it and the tables that start with its name and an underscore (`realtime`, `realtime_events`). */
148
+ const isOwnTable = (table, ownTable) => {
149
+ const name = table.toLowerCase();
150
+ return name === ownTable || name.startsWith(`${ownTable}_`);
151
+ };
152
+
145
153
  function reportSqlWrite({ tables, supabase, file, ownTable, report }, write) {
146
154
  const declaration = tables.get(write.table.toLowerCase());
147
155
  if (!declaration) {
@@ -149,7 +157,7 @@ function reportSqlWrite({ tables, supabase, file, ownTable, report }, write) {
149
157
  ? `SQL writes table ${write.table}, which Database['public']['Tables'] does not declare; regenerate supabase/types/database.types.ts from the migrations.`
150
158
  : `SQL writes table ${write.table}, which no @Entity declares; declare the entity in the owning capability's persistence/entities.`, { table: write.table });
151
159
  } else if (supabase) {
152
- if (write.table.toLowerCase() === ownTable) return;
160
+ if (isOwnTable(write.table, ownTable) && (declaration.owner === null || declaration.owner === file.owner.root)) return;
153
161
  const ownerNote = declaration.owner ? `, owned by ${declaration.owner}` : '';
154
162
  report(`SQL in ${file.owner.root} writes table ${write.table}${ownerNote}; under Supabase schema authority a capability writes only its own table (${ownTable}), named for that capability.`, { table: write.table, ...(declaration.owner ? { tableOwner: declaration.owner } : {}) });
155
163
  } else if (declaration.owner !== file.owner.root) {
@@ -208,7 +216,10 @@ function inspectSqlTag(input, state, file, checker, node) {
208
216
  seen.add(key);
209
217
  state.violations.push({ ruleId: RULE, path: file.rel, ...kit.at(file.rel, file.sourceFile, node), message, ...extra });
210
218
  };
211
- const result = analyzeSql(text, { uniqueSetsOf: table => tables.get(table.toLowerCase())?.uniqueSets ?? null });
219
+ const result = analyzeSql(text, { uniqueSetsOf: (table, schema) => {
220
+ if (supabase && ((schema && schema !== 'public') || table !== table.toLowerCase())) return null;
221
+ return tables.get(table.toLowerCase())?.uniqueSets ?? null;
222
+ } });
212
223
  state.dynamic += result.dynamic;
213
224
  const ownTable = capabilityTable(file.owner.root);
214
225
  reportSqlWrites({ result, tables, supabase, file, ownTable, report });
@@ -0,0 +1,105 @@
1
+ /** Token forms shared by SQL relation scanning and conservative singleton-value proofs. */
2
+
3
+ /** Whether a token can spell an SQL identifier. */
4
+ export const isName = token => token && (token.t === 'word' || token.t === 'ident');
5
+ /** The identifier spelling after SQL word folding. */
6
+ export const nameOf = token => (token.t === 'word' ? token.v.toLowerCase() : token.v);
7
+ /** Whether a token is the named SQL keyword. */
8
+ export const isWord = (token, up) => token?.t === 'word' && token.up === up;
9
+
10
+ // The scalar-cast grammar proves only these builtin targets, unqualified or explicitly under pg_catalog.
11
+ const SCALAR_CAST_TYPES = new Set(['uuid', 'text']);
12
+
13
+ /** Depth of each token (the depth before it when it is `(`, after it when it is `)`). */
14
+ export function depths(tokens) {
15
+ let depth = 0;
16
+ return tokens.map(token => {
17
+ if (token.v === ')' && token.t === 'punct') depth -= 1;
18
+ const here = depth;
19
+ if (token.v === '(' && token.t === 'punct') depth += 1;
20
+ return here;
21
+ });
22
+ }
23
+
24
+ /** The closing parenthesis of a token group, or the final token when unmatched. */
25
+ export function matching(tokens, open) {
26
+ let depth = 0;
27
+ for (let i = open; i < tokens.length; i += 1) {
28
+ if (tokens[i].t !== 'punct') continue;
29
+ if (tokens[i].v === '(') depth += 1;
30
+ else if (tokens[i].v === ')') { depth -= 1; if (depth === 0) return i; }
31
+ }
32
+ return tokens.length - 1;
33
+ }
34
+
35
+ function unparenthesized(tokens) {
36
+ let part = tokens;
37
+ while (part[0]?.v === '(' && part.at(-1)?.v === ')' && matching(part, 0) === part.length - 1) part = part.slice(1, -1);
38
+ return part;
39
+ }
40
+
41
+ function keyColumn(tokens, qualifiers) {
42
+ const part = unparenthesized(tokens);
43
+ if (singletonValue(part)) return null;
44
+ if (part.length === 1 && isName(part[0])) return nameOf(part[0]);
45
+ if (part.length === 3 && isName(part[0]) && part[1].v === '.' && isName(part[2]) && qualifiers.has(nameOf(part[0]))) return nameOf(part[2]);
46
+ return null;
47
+ }
48
+
49
+ function scalarCastType(tokens) {
50
+ if (tokens.length === 1 && isName(tokens[0])) return SCALAR_CAST_TYPES.has(nameOf(tokens[0]));
51
+ return tokens.length === 3 && isName(tokens[0]) && nameOf(tokens[0]) === 'pg_catalog' && tokens[1].v === '.'
52
+ && isName(tokens[2]) && SCALAR_CAST_TYPES.has(nameOf(tokens[2]));
53
+ }
54
+
55
+ /** SQL CAST is a scalar wrapper; its operand is still inspected separately for row dependence or set-returning calls. */
56
+ function castParts(tokens, start = 0) {
57
+ if (!isWord(tokens[start], 'CAST') || tokens[start + 1]?.v !== '(') return null;
58
+ const end = matching(tokens, start + 1);
59
+ if (tokens[end]?.v !== ')') return null;
60
+ const inside = tokens.slice(start + 2, end);
61
+ const level = depths(inside);
62
+ const separator = inside.findIndex((token, index) => level[index] === 0 && isWord(token, 'AS'));
63
+ if (separator < 1 || separator === inside.length - 1) return null;
64
+ return { value: inside.slice(0, separator), type: inside.slice(separator + 1), next: end + 1 };
65
+ }
66
+
67
+ /** A scalar atom, optionally cast through the supported builtin grammar, proves a row-independent equality value. */
68
+ function singletonValue(tokens) {
69
+ const part = unparenthesized(tokens);
70
+ if (part.length === 2 && ['+', '-'].includes(part[0].v) && part[1].t === 'number') return true;
71
+ if (part.length === 1) {
72
+ const [token] = part;
73
+ return ['param', 'string', 'number'].includes(token.t) || isWord(token, 'NULL') || isWord(token, 'TRUE') || isWord(token, 'FALSE');
74
+ }
75
+ const cast = castParts(part);
76
+ if (cast?.next === part.length && scalarCastType(cast.type)) return singletonValue(cast.value);
77
+ const level = depths(part);
78
+ for (let index = part.length - 1; index > 0; index -= 1) {
79
+ if (level[index] === 0 && part[index].v === '::') return scalarCastType(part.slice(index + 1)) && singletonValue(part.slice(0, index));
80
+ }
81
+ return false;
82
+ }
83
+
84
+ /** Key columns equated to proven row-independent singleton values in either operand order. */
85
+ export function equalityColumns(conjunct, qualifiers) {
86
+ const found = new Set();
87
+ const part = unparenthesized(conjunct);
88
+ const level = depths(part);
89
+ const equals = part.findIndex((token, index) => level[index] === 0 && token.t === 'punct' && token.v === '=');
90
+ if (equals < 0) return found;
91
+ const left = part.slice(0, equals);
92
+ const right = part.slice(equals + 1);
93
+ const leftColumn = keyColumn(left, qualifiers);
94
+ const rightColumn = keyColumn(right, qualifiers);
95
+ if (leftColumn && singletonValue(right)) found.add(leftColumn);
96
+ if (rightColumn && singletonValue(left)) found.add(rightColumn);
97
+ return found;
98
+ }
99
+
100
+ /** Whether a call position is SQL CAST through the supported scalar type grammar. */
101
+ export function scalarCastAt(tokens, index) {
102
+ if (tokens[index - 1]?.v === '.') return false;
103
+ const cast = castParts(tokens, index);
104
+ return Boolean(cast && scalarCastType(cast.type));
105
+ }
@@ -5,6 +5,8 @@
5
5
  * A substitution of the `sql` tag (a `${...}` hole, the SqlIdent form) is an opaque token: a table position that holds
6
6
  * one is counted as dynamic and judged by nobody.
7
7
  */
8
+ import { depths, equalityColumns, isName, isWord, matching, nameOf, scalarCastAt } from './sql-token-forms.mjs';
9
+
8
10
  export const HOLE = '';
9
11
 
10
12
  const isSpace = char => /\s/u.test(char);
@@ -129,10 +131,6 @@ const NOT_A_WRITE_BEFORE_UPDATE = new Set(['FOR', 'DO', 'ON', 'KEY', 'SHARE', 'O
129
131
  const WHERE_END = new Set(['GROUP', 'ORDER', 'HAVING', 'LIMIT', 'OFFSET', 'UNION', 'INTERSECT', 'EXCEPT', 'FOR', 'WINDOW', 'RETURNING', 'FETCH']);
130
132
  const SYSTEM_SCHEMAS = new Set(['pg_catalog', 'information_schema']);
131
133
 
132
- const isName = token => token && (token.t === 'word' || token.t === 'ident');
133
- const nameOf = token => (token.t === 'word' ? token.v.toLowerCase() : token.v);
134
- const isWord = (token, up) => token?.t === 'word' && token.up === up;
135
-
136
134
  /**
137
135
  * The table reference at `start`: {parts, table, alias, next}, {dynamic: true}, or null (a subquery, a function, nothing).
138
136
  * `next` is the index after the reference and its alias.
@@ -223,27 +221,6 @@ function cteNames(tokens) {
223
221
  return names;
224
222
  }
225
223
 
226
- /** Depth of each token (the depth before it when it is `(`, after it when it is `)`). */
227
- function depths(tokens) {
228
- let depth = 0;
229
- return tokens.map(token => {
230
- if (token.v === ')' && token.t === 'punct') depth -= 1;
231
- const here = depth;
232
- if (token.v === '(' && token.t === 'punct') depth += 1;
233
- return here;
234
- });
235
- }
236
-
237
- function matching(tokens, open) {
238
- let depth = 0;
239
- for (let i = open; i < tokens.length; i += 1) {
240
- if (tokens[i].t !== 'punct') continue;
241
- if (tokens[i].v === '(') depth += 1;
242
- else if (tokens[i].v === ')') { depth -= 1; if (depth === 0) return i; }
243
- }
244
- return tokens.length - 1;
245
- }
246
-
247
224
  function isAggregateItem(item) {
248
225
  for (let i = 0; i < item.length; i += 1) {
249
226
  if (item[i].t === 'word' && AGGREGATES.has(item[i].up) && item[i + 1]?.v === '(') {
@@ -257,28 +234,11 @@ function isAggregateItem(item) {
257
234
  /** The unique-key columns `columns` (lowercase Set of `=`-constrained column names) satisfy for one of `uniqueSets`. */
258
235
  const covers = (uniqueSets, columns) => uniqueSets.some(set => set.length > 0 && set.every(column => columns.has(column)));
259
236
 
260
- function equalityColumns(conjunct, qualifiers) {
261
- const found = new Set();
262
- const name = (from, to) => {
263
- const part = conjunct.slice(from, to);
264
- if (part.length === 1 && isName(part[0])) return nameOf(part[0]);
265
- if (part.length === 3 && isName(part[0]) && part[1].v === '.' && isName(part[2]) && qualifiers.has(nameOf(part[0]))) return nameOf(part[2]);
266
- return null;
267
- };
268
- const equals = conjunct.findIndex(token => token.t === 'punct' && token.v === '=');
269
- if (equals < 0) return found;
270
- const left = name(0, equals);
271
- const rightStart = conjunct[equals + 1];
272
- if (left && !(rightStart?.t === 'word' && ['ANY', 'ALL', 'SOME'].includes(rightStart.up))) found.add(left);
273
- const right = name(equals + 1, conjunct.length);
274
- if (right) found.add(right);
275
- return found;
276
- }
277
-
278
237
  /**
279
238
  * Judge one statement that reads rows (no INSERT/UPDATE/DELETE/MERGE at its own depth): `{bounded, basis}`. Bounded is
280
239
  * a LIMIT/FETCH at the statement depth, no FROM at all (one row), only aggregates without GROUP BY, or an equality on
281
- * every column of the primary key or of one unique key of the first table of FROM.
240
+ * every column of the primary key or of one unique key of a single FROM relation. Unknown projection calls can
241
+ * multiply rows even when that relation contributes at most one, so they need an explicit statement bound.
282
242
  */
283
243
  function aggregateSelectItems(tokens, level, select, from) {
284
244
  const items = [];
@@ -338,16 +298,42 @@ function isUniqueBounded(tokens, level, from, primary, uniqueSets) {
338
298
  return Boolean(columns) && covers(uniqueSets, columns);
339
299
  }
340
300
 
301
+ function singleFromRelation(tokens, level, from) {
302
+ for (let index = from + 1; index < tokens.length; index += 1) {
303
+ if (level[index] !== 0) continue;
304
+ if (isWord(tokens[index], 'WHERE') || (tokens[index].t === 'word' && WHERE_END.has(tokens[index].up))) break;
305
+ if (isWord(tokens[index], 'JOIN') || (tokens[index].t === 'punct' && tokens[index].v === ',')) return false;
306
+ }
307
+ return true;
308
+ }
309
+
310
+ /** Plain known aggregate calls are scalar; other calls, including wrappers around aggregates, may return sets. */
311
+ function unprovenProjectionCall(tokens, level, select, from) {
312
+ const end = from < 0 ? tokens.length : from;
313
+ for (let index = select + 1; index < end; index += 1) {
314
+ const token = tokens[index];
315
+ if (level[index] === 0 && (isWord(token, 'WHERE') || (token.t === 'word' && WHERE_END.has(token.up)))) break;
316
+ if (!isName(token) || tokens[index + 1]?.v !== '(') continue;
317
+ if (scalarCastAt(tokens, index)) continue;
318
+ if (token.t !== 'word' || !AGGREGATES.has(token.up) || tokens[index - 1]?.v === '.') return true;
319
+ }
320
+ return false;
321
+ }
322
+
341
323
  function selectBound(tokens, level, uniqueSetsOf, cte) {
342
324
  if (findTopWord(tokens, level, 'LIMIT') >= 0 || findTopWord(tokens, level, 'FETCH') >= 0) return { bounded: true, basis: 'limit' };
343
325
  const select = findTopWord(tokens, level, 'SELECT');
344
326
  const from = findTopWord(tokens, level, 'FROM', select);
345
- if (from < 0) return { bounded: true, basis: 'no from' };
346
- if (!hasTopGroupBy(tokens, level) && aggregateSelectItems(tokens, level, select, from).every(isAggregateItem)) return { bounded: true, basis: 'aggregate' };
347
- const primary = tableRef(tokens, from + 1);
348
- if (!primary || primary.dynamic || cte.has(primary.table)) return { bounded: false, basis: 'unbounded', table: primary?.table ?? null };
349
- if (isUniqueBounded(tokens, level, from, primary, uniqueSetsOf(primary.table))) return { bounded: true, basis: 'unique' };
350
- return { bounded: false, basis: 'unbounded', table: primary.table };
327
+ const primary = from < 0 ? null : tableRef(tokens, from + 1);
328
+ const unbounded = { bounded: false, basis: 'unbounded', table: primary?.table ?? null };
329
+ const grouped = hasTopGroupBy(tokens, level);
330
+ if (tokens.some((token, index) => level[index] === 0 && (isWord(token, 'UNION') || isWord(token, 'INTERSECT') || isWord(token, 'EXCEPT')))) return unbounded;
331
+ if (unprovenProjectionCall(tokens, level, select, from)) return unbounded;
332
+ if (from < 0) return grouped ? unbounded : { bounded: true, basis: 'no from' };
333
+ if (!grouped && aggregateSelectItems(tokens, level, select, from).every(isAggregateItem)) return { bounded: true, basis: 'aggregate' };
334
+ if (grouped || !primary || primary.dynamic || cte.has(primary.table) || !singleFromRelation(tokens, level, from)) return unbounded;
335
+ if (isUniqueBounded(tokens, level, from, primary, uniqueSetsOf(primary.table, primary.schema))) return { bounded: true, basis: 'unique' };
336
+ return unbounded;
351
337
  }
352
338
 
353
339
  function recordSqlReference(list, ref, reads, cte, state) {
@@ -437,8 +423,8 @@ function statementAnalysis(tokens, uniqueSetsOf) {
437
423
  }
438
424
 
439
425
  /**
440
- * Read one SQL text. `uniqueSetsOf(table)` returns the unique-key column sets (each a lowercase column list) of the entity
441
- * of `table`, or null when no entity declares it.
426
+ * Read one SQL text. `uniqueSetsOf(table, schema)` returns the unique-key column sets (each a lowercase column list)
427
+ * proven for that relation by its declared schema authority, or null when no key is known.
442
428
  * Returns {writes: [{table, schema}], reads: [{table, schema}], selects: [{bounded, basis, table}], dynamic}.
443
429
  */
444
430
  export function analyzeSql(text, { uniqueSetsOf = () => null } = {}) {
@@ -0,0 +1,261 @@
1
+ import path from 'node:path';
2
+ import { lsFiles } from '../../api/git/ls-files.mjs';
3
+ import { byCodeUnit } from '../../lib/list.mjs';
4
+ import { MIGRATIONS_DIR } from '../rules/database-constants.mjs';
5
+ import { databaseSqlStatements, databaseSqlTokens, isMigrationName, migrationStamp } from '../rules/database-migrations.mjs';
6
+ import { readText } from '../rules/read.mjs';
7
+
8
+ const word = (token, value) => token?.t === 'word' && token.v === value;
9
+ const identifier = (token) => ['word', 'ident'].includes(token?.t) && token.v === token.v.toLowerCase() ? token.v : null;
10
+ const OPAQUE_STATEMENTS = new Set(['do', 'call', 'select', 'with', 'insert', 'update', 'delete', 'merge', 'truncate', 'execute', 'rollback', 'abort', 'prepare', 'discard', 'refresh', 'copy']);
11
+ const settingName = (token, value) => ['word', 'ident'].includes(token?.t) && token.v.toLowerCase() === value;
12
+
13
+ /** A migration relation is public only when its qualification or the current search path proves it. */
14
+ function relationAt(tokens, start, schema) {
15
+ const first = identifier(tokens[start]);
16
+ if (first === null) return null;
17
+ if (tokens[start + 1]?.v !== '.') return { schema, table: first, next: start + 1 };
18
+ const table = identifier(tokens[start + 2]);
19
+ if (table === null || tokens[start + 3]?.v === '.') return null;
20
+ return { schema: first, table, next: start + 3 };
21
+ }
22
+
23
+ /** Split declarations at their own commas, keeping type arguments and constraint column lists together. */
24
+ function clausesOf(tokens) {
25
+ const clauses = [[]];
26
+ let depth = 0;
27
+ for (const token of tokens) {
28
+ if (token.v === '(') depth += 1;
29
+ if (token.v === ')') depth -= 1;
30
+ if (depth < 0) return null;
31
+ if (token.v === ',' && depth === 0) clauses.push([]);
32
+ else clauses.at(-1).push(token);
33
+ }
34
+ return depth === 0 ? clauses : null;
35
+ }
36
+
37
+ function parenthesized(tokens, start) {
38
+ if (tokens[start]?.v !== '(') return null;
39
+ let depth = 1;
40
+ for (let index = start + 1; index < tokens.length; index += 1) {
41
+ if (tokens[index].v === '(') depth += 1;
42
+ if (tokens[index].v === ')') depth -= 1;
43
+ if (depth === 0) return { body: tokens.slice(start + 1, index), next: index + 1 };
44
+ }
45
+ return null;
46
+ }
47
+
48
+ const isDeferred = (tokens) => tokens.some((token, index) => word(token, 'deferrable') && !word(tokens[index - 1], 'not'));
49
+
50
+ /** A declared key proves only its complete, plain column list; index expressions are never column names. */
51
+ function tableKey(tokens) {
52
+ if (isDeferred(tokens)) return null;
53
+ let start = word(tokens[0], 'constraint') ? 2 : 0;
54
+ if (word(tokens[start], 'primary') && word(tokens[start + 1], 'key')) start += 2;
55
+ else if (word(tokens[start], 'unique')) start += 1;
56
+ else return null;
57
+ const enclosed = parenthesized(tokens, start);
58
+ const clauses = enclosed && clausesOf(enclosed.body);
59
+ if (!clauses?.length || clauses.some((clause) => clause.length !== 1 || identifier(clause[0]) === null)) return null;
60
+ return clauses.map((clause) => identifier(clause[0]));
61
+ }
62
+
63
+ /** Inline key clauses are read at column depth, outside defaults, generated expressions and type parameters. */
64
+ function columnKey(tokens) {
65
+ const column = identifier(tokens[0]);
66
+ if (column === null || isDeferred(tokens)) return null;
67
+ let depth = 0;
68
+ for (let index = 1; index < tokens.length; index += 1) {
69
+ if (tokens[index].v === '(') depth += 1;
70
+ if (tokens[index].v === ')') depth -= 1;
71
+ if (depth === 0 && (word(tokens[index], 'unique') || (word(tokens[index], 'primary') && word(tokens[index + 1], 'key')))) return [column];
72
+ }
73
+ return null;
74
+ }
75
+
76
+ function declarationKey(tokens) {
77
+ const tableConstraint = ['constraint', 'primary', 'unique', 'check', 'foreign', 'exclude'].some((value) => word(tokens[0], value));
78
+ return tableConstraint ? tableKey(tokens) : columnKey(tokens);
79
+ }
80
+
81
+ function addKey(keys, columns) {
82
+ if (columns && !keys.some((key) => key.length === columns.length && key.every((column, index) => column === columns[index]))) keys.push(columns);
83
+ }
84
+
85
+ function clearKeys(state, uncertain = false) {
86
+ for (const table of state.tables.keys()) state.tables.set(table, []);
87
+ if (uncertain) state.safe.clear();
88
+ }
89
+
90
+ function opaqueExecution(state) {
91
+ clearKeys(state, true);
92
+ state.schema = null;
93
+ }
94
+
95
+ function forgetRelation(state, relation) {
96
+ if (!relation || relation.schema === null) clearKeys(state, true);
97
+ else if (relation.schema === 'public') {
98
+ state.tables.set(relation.table, []);
99
+ state.safe.delete(relation.table);
100
+ }
101
+ }
102
+
103
+ /** Conditional table creation cannot establish what an unknown pre-existing table actually constrains. */
104
+ function createTable(state, tokens, start) {
105
+ const conditional = word(tokens[start], 'if') && word(tokens[start + 1], 'not') && word(tokens[start + 2], 'exists');
106
+ if (conditional) start += 3;
107
+ const relation = relationAt(tokens, start, state.schema);
108
+ if (!relation || relation.schema !== 'public') return;
109
+ if (conditional) {
110
+ if (!state.tables.has(relation.table)) state.tables.set(relation.table, []);
111
+ return;
112
+ }
113
+ const enclosed = parenthesized(tokens, relation.next);
114
+ const clauses = enclosed && clausesOf(enclosed.body);
115
+ const keys = [];
116
+ state.safe.delete(relation.table);
117
+ state.tables.set(relation.table, keys);
118
+ if (!clauses) return;
119
+ state.safe.add(relation.table);
120
+ for (const clause of clauses) addKey(keys, declarationKey(clause));
121
+ }
122
+
123
+ function safeAlter(tokens) {
124
+ if (word(tokens[0], 'owner') || word(tokens[0], 'cluster')) return true;
125
+ const security = tokens.some((token) => word(token, 'security'));
126
+ if (security && ['enable', 'disable', 'force', 'no'].some((value) => word(tokens[0], value))) return true;
127
+ return (word(tokens[0], 'set') || word(tokens[0], 'reset')) && tokens[1]?.v === '(';
128
+ }
129
+
130
+ /** ADD can establish a new key; drops, renames and unreadable alterations discard the affected proof. */
131
+ function alterClause(state, relation, tokens) {
132
+ if (tokens.some((token) => word(token, 'inherit') || word(token, 'partition'))) { clearKeys(state, true); return; }
133
+ if (word(tokens[0], 'validate') || tokens.some((token) => word(token, 'default') || word(token, 'check') || word(token, 'generated') || word(token, 'using'))) {
134
+ opaqueExecution(state);
135
+ return;
136
+ }
137
+ if (safeAlter(tokens)) return;
138
+ if (word(tokens[0], 'drop') && (word(tokens[1], 'column') || word(tokens[1], 'constraint'))) {
139
+ if (relation.schema === 'public') state.tables.set(relation.table, []);
140
+ return;
141
+ }
142
+ if (!word(tokens[0], 'add')) { forgetRelation(state, relation); return; }
143
+ let start = word(tokens[1], 'column') ? 2 : 1;
144
+ if (word(tokens[start], 'if')) return;
145
+ if (relation.schema !== 'public' || !state.safe.has(relation.table)) return;
146
+ const keys = state.tables.get(relation.table) ?? [];
147
+ state.tables.set(relation.table, keys);
148
+ addKey(keys, declarationKey(tokens.slice(start)));
149
+ }
150
+
151
+ function alterTable(state, tokens, start) {
152
+ if (word(tokens[start], 'if') && word(tokens[start + 1], 'exists')) start += 2;
153
+ if (word(tokens[start], 'only')) start += 1;
154
+ const relation = relationAt(tokens, start, state.schema);
155
+ if (!relation || relation.schema === null) { clearKeys(state, true); return; }
156
+ const clauses = clausesOf(tokens.slice(relation.next));
157
+ if (!clauses) { forgetRelation(state, relation); return; }
158
+ for (const clause of clauses) alterClause(state, relation, clause);
159
+ }
160
+
161
+ function dropTable(state, tokens, start) {
162
+ if (word(tokens[start], 'if') && word(tokens[start + 1], 'exists')) start += 2;
163
+ for (const clause of clausesOf(tokens.slice(start)) ?? []) forgetRelation(state, relationAt(clause, 0, state.schema));
164
+ }
165
+
166
+ function searchPath(state, tokens) {
167
+ const local = word(tokens[1], 'local');
168
+ const start = local || word(tokens[1], 'session') ? 2 : 1;
169
+ if (word(tokens[start], 'schema')) {
170
+ state.schema = !local && tokens[start + 1]?.t === 'string' ? tokens[start + 1].v : null;
171
+ return;
172
+ }
173
+ if (!settingName(tokens[start], 'search_path')) {
174
+ if (tokens.some((token) => settingName(token, 'search_path')) || word(tokens[start], 'all')) state.schema = null;
175
+ return;
176
+ }
177
+ if (word(tokens[0], 'reset')) { state.schema = 'public'; return; }
178
+ if (!word(tokens[start + 1], 'to') && tokens[start + 1]?.v !== '=') { state.schema = null; return; }
179
+ const value = tokens[start + 2];
180
+ const declared = identifier(value) ?? (value?.t === 'string' ? value.v : null);
181
+ state.schema = local ? null : declared;
182
+ }
183
+
184
+ function createObject(tokens) {
185
+ let start = 1;
186
+ if (word(tokens[start], 'or') && word(tokens[start + 1], 'replace')) start += 2;
187
+ if (word(tokens[start], 'global') || word(tokens[start], 'local')) start += 1;
188
+ const temporary = word(tokens[start], 'temp') || word(tokens[start], 'temporary');
189
+ if (temporary || word(tokens[start], 'unlogged')) start += 1;
190
+ if (word(tokens[start], 'unique')) start += 1;
191
+ return { start, temporary };
192
+ }
193
+
194
+ /** CTAS, materialization and index building can call user code; event triggers also affect later DDL. */
195
+ function executingCreate(state, tokens, start) {
196
+ if (word(tokens[start], 'event') && word(tokens[start + 1], 'trigger')) {
197
+ state.tainted = true;
198
+ opaqueExecution(state);
199
+ return true;
200
+ }
201
+ if (word(tokens[start], 'index') || (word(tokens[start], 'materialized') && word(tokens[start + 1], 'view'))) {
202
+ opaqueExecution(state);
203
+ return true;
204
+ }
205
+ if (!word(tokens[start], 'table')) return false;
206
+ let depth = 0;
207
+ for (const token of tokens.slice(start + 1)) {
208
+ if (token.v === '(') depth += 1;
209
+ if (token.v === ')') depth -= 1;
210
+ if (depth === 0 && word(token, 'as')) { opaqueExecution(state); return true; }
211
+ }
212
+ return false;
213
+ }
214
+
215
+ function applyCreate(state, tokens) {
216
+ // A private or temporary child also contributes rows to a public parent's ordinary SELECT.
217
+ if (tokens.some((token) => word(token, 'inherits'))) { clearKeys(state, true); return; }
218
+ const { start, temporary } = createObject(tokens);
219
+ if (executingCreate(state, tokens, start)) return;
220
+ if (temporary) { opaqueExecution(state); return; }
221
+ if (word(tokens[start], 'table')) createTable(state, tokens, start + 1);
222
+ }
223
+
224
+ function applyTableCommand(state, tokens, command) {
225
+ if (word(tokens[1], 'table')) command(state, tokens, 2);
226
+ else clearKeys(state, true);
227
+ }
228
+
229
+ /** Reduce only unconditional schema declarations; executable bodies may remove facts but never manufacture them. */
230
+ function applyStatement(state, tokens) {
231
+ if (state.tainted) return;
232
+ if (word(tokens[0], 'set') || word(tokens[0], 'reset')) { searchPath(state, tokens); return; }
233
+ if (tokens[0]?.t === 'word' && OPAQUE_STATEMENTS.has(tokens[0].v)) { opaqueExecution(state); return; }
234
+ if (word(tokens[0], 'commit') && word(tokens[1], 'prepared')) { opaqueExecution(state); return; }
235
+ if (word(tokens[0], 'create')) { applyCreate(state, tokens); return; }
236
+ if (word(tokens[0], 'alter')) { applyTableCommand(state, tokens, alterTable); return; }
237
+ if (word(tokens[0], 'drop')) applyTableCommand(state, tokens, dropTable);
238
+ }
239
+
240
+ function activeMigrations(root) {
241
+ const listed = lsFiles(['-z', '--cached', '--exclude-standard', '--', MIGRATIONS_DIR], { dir: root, maxBuffer: 256 * 1024 * 1024 });
242
+ if (listed.error || listed.status !== 0) return null;
243
+ return listed.stdout.split('\0').filter((file) => path.posix.dirname(file) === MIGRATIONS_DIR
244
+ && isMigrationName(path.posix.basename(file)) && migrationStamp(path.posix.basename(file).slice(0, 14)) !== null).sort(byCodeUnit);
245
+ }
246
+
247
+ /**
248
+ * Public-table key sets proven by ordered, tracked active migrations. Direct event triggers taint later DDL proof.
249
+ * Opaque bodies discard current facts; persistent schema machinery hidden inside them is outside static authority.
250
+ */
251
+ export function supabaseUniqueSetsOf(appRoot) {
252
+ const root = path.resolve(appRoot);
253
+ const files = activeMigrations(root);
254
+ const state = { tables: new Map(), safe: new Set(), schema: 'public', tainted: false };
255
+ for (const file of files ?? []) {
256
+ const text = readText(root, file);
257
+ if (text === null) { clearKeys(state, true); state.schema = null; continue; }
258
+ for (const statement of databaseSqlStatements(databaseSqlTokens(text))) applyStatement(state, statement);
259
+ }
260
+ return state.tainted ? new Map() : state.tables;
261
+ }