@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.
- package/CHANGELOG.md +10 -0
- package/README.md +2 -2
- package/boundary-schema.mjs +32 -4
- package/idempotency.mjs +3 -0
- package/index.mjs +2 -0
- package/infra-owner.mjs +4 -3
- package/lib/client-idempotency.mjs +403 -0
- package/lib/persistence.mjs +1 -1
- package/lib/schema-values.mjs +90 -0
- package/lib/unit-spec.mjs +6 -0
- package/operations.mjs +197 -32
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
- package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
- package/runtime/knowledge/hfs/rules.yaml +168 -47
- package/runtime/knowledge/hfs/slots.yaml +110 -52
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
- package/runtime/modules/models/registry.yaml +3 -1
- package/runtime/modules/models/runtimes.yaml +2 -0
- package/runtime/scripts/api/git/ls-tree.mjs +5 -0
- package/runtime/scripts/api/git/show.mjs +6 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
- package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
- package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
- package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
- package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
- package/runtime/scripts/hfs/declaration-slots.mjs +12 -3
- package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +16 -6
- package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +2 -2
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/view.mjs +2 -1
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +94 -1
- package/runtime/scripts/lib/package-at.mjs +62 -0
- package/runtime/scripts/lib/same-text.mjs +5 -0
- package/runtime/scripts/lib/ts-analysis.mjs +10 -0
- package/suppression.mjs +1 -1
- package/unit-spec.mjs +66 -6
- 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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
if (
|
|
349
|
-
if (
|
|
350
|
-
return { bounded:
|
|
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)
|
|
441
|
-
*
|
|
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
|
+
}
|