@rigour-labs/core 6.5.2 → 6.5.3

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 (110) hide show
  1. package/README.md +1 -1
  2. package/dist/context/automatic-index.d.ts +5 -0
  3. package/dist/context/automatic-index.js +24 -3
  4. package/dist/context/index-status.test.d.ts +1 -0
  5. package/dist/context/index-status.test.js +26 -0
  6. package/dist/gates/dedupe-failures.test.d.ts +1 -0
  7. package/dist/gates/dedupe-failures.test.js +12 -0
  8. package/dist/gates/deprecated-dependencies.d.ts +27 -0
  9. package/dist/gates/deprecated-dependencies.js +104 -0
  10. package/dist/gates/deprecated-dependencies.test.d.ts +1 -0
  11. package/dist/gates/deprecated-dependencies.test.js +55 -0
  12. package/dist/gates/runner.d.ts +7 -1
  13. package/dist/gates/runner.js +25 -11
  14. package/dist/gates/security-patterns-data.js +2 -1
  15. package/dist/gates/security-patterns-owasp.test.js +15 -0
  16. package/dist/gates/semantic-bugs.js +1 -1
  17. package/dist/gates/unindexed-reads/coverage.d.ts +15 -0
  18. package/dist/gates/unindexed-reads/coverage.js +24 -0
  19. package/dist/gates/unindexed-reads/index.d.ts +20 -0
  20. package/dist/gates/unindexed-reads/index.js +98 -0
  21. package/dist/gates/unindexed-reads/queries.d.ts +17 -0
  22. package/dist/gates/unindexed-reads/queries.js +147 -0
  23. package/dist/gates/unindexed-reads/queries.test.d.ts +1 -0
  24. package/dist/gates/unindexed-reads/queries.test.js +54 -0
  25. package/dist/gates/unindexed-reads/schema.d.ts +38 -0
  26. package/dist/gates/unindexed-reads/schema.js +256 -0
  27. package/dist/gates/unindexed-reads/schema.test.d.ts +1 -0
  28. package/dist/gates/unindexed-reads/schema.test.js +81 -0
  29. package/dist/gates/unindexed-reads/sql.d.ts +21 -0
  30. package/dist/gates/unindexed-reads/sql.js +131 -0
  31. package/dist/gates/unindexed-reads/unindexed-reads.test.d.ts +1 -0
  32. package/dist/gates/unindexed-reads/unindexed-reads.test.js +90 -0
  33. package/dist/index.d.ts +4 -1
  34. package/dist/index.js +4 -1
  35. package/dist/inference/brain-placeholder.test.d.ts +1 -0
  36. package/dist/inference/brain-placeholder.test.js +28 -0
  37. package/dist/inference/llama-engine-checksum.test.d.ts +1 -0
  38. package/dist/inference/llama-engine-checksum.test.js +27 -0
  39. package/dist/inference/llama-engine.d.ts +4 -1
  40. package/dist/inference/llama-engine.js +15 -8
  41. package/dist/memory/recall.d.ts +37 -0
  42. package/dist/memory/recall.js +93 -0
  43. package/dist/memory/recall.test.d.ts +1 -0
  44. package/dist/memory/recall.test.js +36 -0
  45. package/dist/pattern-index/assess.d.ts +16 -0
  46. package/dist/pattern-index/assess.js +37 -0
  47. package/dist/pattern-index/embeddings.d.ts +19 -0
  48. package/dist/pattern-index/embeddings.js +16 -0
  49. package/dist/pattern-index/index.d.ts +3 -2
  50. package/dist/pattern-index/index.js +4 -2
  51. package/dist/pattern-index/indexer.js +15 -7
  52. package/dist/pattern-index/matcher.d.ts +6 -0
  53. package/dist/pattern-index/matcher.js +13 -3
  54. package/dist/pattern-index/pattern-reuse.test.d.ts +1 -0
  55. package/dist/pattern-index/pattern-reuse.test.js +76 -0
  56. package/dist/pattern-index/staleness.d.ts +2 -15
  57. package/dist/pattern-index/staleness.js +1 -78
  58. package/dist/pattern-index/types.d.ts +2 -0
  59. package/dist/review/agent-fixes.d.ts +4 -0
  60. package/dist/review/agent-fixes.js +10 -7
  61. package/dist/review/agent-fixes.test.js +4 -4
  62. package/dist/review/changed-lines.d.ts +2 -1
  63. package/dist/review/changed-lines.js +12 -2
  64. package/dist/review/check-outcomes.d.ts +31 -0
  65. package/dist/review/check-outcomes.js +81 -0
  66. package/dist/review/check-outcomes.test.d.ts +1 -0
  67. package/dist/review/check-outcomes.test.js +56 -0
  68. package/dist/review/migration-order.d.ts +5 -0
  69. package/dist/review/migration-order.js +70 -0
  70. package/dist/review/migration-order.test.d.ts +1 -0
  71. package/dist/review/migration-order.test.js +62 -0
  72. package/dist/review/quiet.d.ts +2 -0
  73. package/dist/review/quiet.js +12 -3
  74. package/dist/review/review.d.ts +2 -0
  75. package/dist/review/review.js +7 -2
  76. package/dist/review/review.test.js +11 -0
  77. package/dist/semantic/ast.d.ts +1 -0
  78. package/dist/semantic/ast.js +4 -0
  79. package/dist/semantic/learn/compile.js +3 -1
  80. package/dist/semantic/learn/learn.js +40 -5
  81. package/dist/semantic/types.d.ts +2 -0
  82. package/dist/services/context-scope-summary.test.d.ts +1 -0
  83. package/dist/services/context-scope-summary.test.js +17 -0
  84. package/dist/services/context-telemetry-service.js +2 -1
  85. package/dist/storage/context-telemetry.js +33 -19
  86. package/dist/storage/db.js +52 -1
  87. package/dist/storage/findings.js +1 -1
  88. package/dist/storage/fix-lessons.d.ts +13 -0
  89. package/dist/storage/fix-lessons.js +84 -0
  90. package/dist/storage/fix-lessons.test.d.ts +1 -0
  91. package/dist/storage/fix-lessons.test.js +61 -0
  92. package/dist/storage/index.d.ts +2 -1
  93. package/dist/storage/index.js +2 -1
  94. package/dist/storage/lessons.d.ts +16 -2
  95. package/dist/storage/lessons.js +27 -61
  96. package/dist/storage/lessons.test.js +1 -15
  97. package/dist/storage/local-memory.js +39 -5
  98. package/dist/storage/local-memory.test.d.ts +1 -0
  99. package/dist/storage/local-memory.test.js +55 -0
  100. package/dist/storage/patterns.d.ts +1 -1
  101. package/dist/storage/patterns.js +1 -1
  102. package/dist/storage/share-memory.test.d.ts +1 -0
  103. package/dist/storage/share-memory.test.js +34 -0
  104. package/dist/storage/team-vector-store.js +2 -1
  105. package/dist/storage/telemetry-scope.test.d.ts +1 -0
  106. package/dist/storage/telemetry-scope.test.js +38 -0
  107. package/dist/templates/universal-config.js +11 -0
  108. package/dist/types/index.d.ts +132 -0
  109. package/dist/types/index.js +15 -0
  110. package/package.json +7 -7
@@ -0,0 +1,147 @@
1
+ /**
2
+ * supabase-js reads in a TypeScript or JavaScript file: `.from('table')`
3
+ * followed by `.select(…)` and filters, as one call chain.
4
+ *
5
+ * A read is only modelled when every part of it is literal: the table, the
6
+ * filter columns, and the methods. Anything else (a computed table name,
7
+ * `.or(…)`, a filter on an embedded resource, a method this does not know)
8
+ * makes the read `uncertain`, and the gate says nothing about it.
9
+ */
10
+ import ts from 'typescript';
11
+ const RANGE = new Set(['gt', 'gte', 'lt', 'lte', 'like', 'ilike', 'likeAllOf', 'likeAnyOf', 'ilikeAllOf', 'ilikeAnyOf']);
12
+ /** Operators a GIN or GiST index could serve: they keep the read silent rather than prove anything. */
13
+ const MATCH = new Set(['contains', 'containedBy', 'overlaps', 'textSearch', 'rangeGt', 'rangeGte', 'rangeLt', 'rangeLte', 'rangeAdjacent']);
14
+ const NEUTRAL = new Set(['select', 'limit', 'range', 'single', 'maybeSingle', 'returns', 'abortSignal', 'csv', 'geojson', 'explain', 'throwOnError', 'setHeader', 'then', 'neq', 'overrideTypes']);
15
+ const WRITES = new Set(['insert', 'update', 'upsert', 'delete']);
16
+ export function findReads(fileName, content) {
17
+ const source = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
18
+ const reads = [];
19
+ const visit = (node) => {
20
+ const read = readAt(source, node);
21
+ if (read)
22
+ reads.push(read);
23
+ ts.forEachChild(node, visit);
24
+ };
25
+ visit(source);
26
+ return reads;
27
+ }
28
+ /** A read rooted at this `.from(…)` call, or undefined when the node is not one. */
29
+ function readAt(source, node) {
30
+ if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== 'from')
31
+ return undefined;
32
+ const tableArg = node.arguments[0];
33
+ const schema = schemaOf(node.expression.expression);
34
+ const chain = callsAfter(node);
35
+ if (!chain.some(call => call.method === 'select') || chain.some(call => WRITES.has(call.method)))
36
+ return undefined;
37
+ const line = source.getLineAndCharacterOfPosition(node.expression.name.getStart(source)).line + 1;
38
+ const literalTable = tableArg && ts.isStringLiteralLike(tableArg) ? tableArg.text : undefined;
39
+ const read = { table: literalTable ? qualified(schema, literalTable) : '', line, filters: [], uncertain: !literalTable || schema === null };
40
+ for (const call of chain)
41
+ applyCall(read, call);
42
+ return read;
43
+ }
44
+ /** Calls chained onto `node`: `.from(…).select(…).eq(…)` gives select, eq. */
45
+ function callsAfter(node) {
46
+ const calls = [];
47
+ let current = node;
48
+ while (ts.isPropertyAccessExpression(current.parent) && ts.isCallExpression(current.parent.parent) && current.parent.parent.expression === current.parent) {
49
+ calls.push({ method: current.parent.name.text, args: current.parent.parent.arguments });
50
+ current = current.parent.parent;
51
+ }
52
+ return calls;
53
+ }
54
+ /** `client.schema('s').from(…)`: the schema name; undefined for the default; null when it is not a literal. */
55
+ function schemaOf(target) {
56
+ if (!ts.isCallExpression(target) || !ts.isPropertyAccessExpression(target.expression) || target.expression.name.text !== 'schema')
57
+ return undefined;
58
+ const arg = target.arguments[0];
59
+ return arg && ts.isStringLiteralLike(arg) ? arg.text : null;
60
+ }
61
+ function qualified(schema, table) {
62
+ const name = table.toLowerCase();
63
+ return schema && schema !== 'public' ? `${schema.toLowerCase()}.${name}` : name;
64
+ }
65
+ function applyCall(read, call) {
66
+ const { method, args } = call;
67
+ if (NEUTRAL.has(method))
68
+ return;
69
+ if (method === 'order') {
70
+ if (read.orderBy === undefined)
71
+ read.orderBy = columnOf(read, args[0]);
72
+ return;
73
+ }
74
+ if (method === 'match')
75
+ return applyMatch(read, args[0]);
76
+ if (method === 'not')
77
+ return applyNot(read, args);
78
+ if (method === 'filter')
79
+ return applyFilter(read, args);
80
+ const column = columnOf(read, args[0]);
81
+ if (column === undefined)
82
+ return;
83
+ if (method === 'eq')
84
+ read.filters.push({ column, op: 'eq', value: literal(args[1]) });
85
+ else if (method === 'in')
86
+ read.filters.push({ column, op: 'in' });
87
+ else if (method === 'is')
88
+ read.filters.push(args[1]?.kind === ts.SyntaxKind.NullKeyword ? { column, op: 'isnull' } : { column, op: 'eq', value: literal(args[1]) });
89
+ else if (RANGE.has(method))
90
+ read.filters.push({ column, op: 'range' });
91
+ else if (MATCH.has(method))
92
+ read.filters.push({ column, op: 'match' });
93
+ else
94
+ read.uncertain = true;
95
+ }
96
+ /** `.not('col', 'is', null)` is IS NOT NULL; any other negation filters nothing an index can seek. */
97
+ function applyNot(read, args) {
98
+ const column = columnOf(read, args[0]);
99
+ const op = args[1] && ts.isStringLiteralLike(args[1]) ? args[1].text : undefined;
100
+ if (column !== undefined && op === 'is' && args[2]?.kind === ts.SyntaxKind.NullKeyword)
101
+ read.filters.push({ column, op: 'notnull' });
102
+ else if (op === undefined)
103
+ read.uncertain = true;
104
+ }
105
+ /** `.filter('col', 'eq', v)`: the same as the named method when the operator is a literal. */
106
+ function applyFilter(read, args) {
107
+ const op = args[1] && ts.isStringLiteralLike(args[1]) ? args[1].text : undefined;
108
+ if (op === undefined || !['eq', 'in', 'is', 'gt', 'gte', 'lt', 'lte', 'like', 'ilike'].includes(op)) {
109
+ read.uncertain = true;
110
+ return;
111
+ }
112
+ applyCall(read, { method: op, args: [args[0], args[2]] });
113
+ }
114
+ /** `.match({ a: 1, b: 'x' })`: equality on each key of an object literal. */
115
+ function applyMatch(read, arg) {
116
+ if (!arg || !ts.isObjectLiteralExpression(arg)) {
117
+ read.uncertain = true;
118
+ return;
119
+ }
120
+ for (const property of arg.properties) {
121
+ const name = ts.isPropertyAssignment(property) && (ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)) ? property.name.text : undefined;
122
+ if (name === undefined || name.includes('.'))
123
+ read.uncertain = true;
124
+ else
125
+ read.filters.push({ column: name.toLowerCase(), op: 'eq', value: literal(property.initializer) });
126
+ }
127
+ }
128
+ /** A literal column on the read's own table; a computed or embedded (`other.col`) column makes the read uncertain. */
129
+ function columnOf(read, arg) {
130
+ if (!arg || !ts.isStringLiteralLike(arg) || arg.text.includes('.') || arg.text.includes('->')) {
131
+ read.uncertain = true;
132
+ return undefined;
133
+ }
134
+ return arg.text.toLowerCase();
135
+ }
136
+ /** A literal value as text, or undefined when it is computed at run time. */
137
+ function literal(arg) {
138
+ if (!arg)
139
+ return undefined;
140
+ if (ts.isStringLiteralLike(arg) || ts.isNumericLiteral(arg))
141
+ return arg.text;
142
+ if (arg.kind === ts.SyntaxKind.TrueKeyword)
143
+ return 'true';
144
+ if (arg.kind === ts.SyntaxKind.FalseKeyword)
145
+ return 'false';
146
+ return undefined;
147
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { findReads } from './queries.js';
3
+ const reads = (code) => findReads('a.ts', code);
4
+ describe('findReads', () => {
5
+ it('models a literal supabase-js read: filters, first order column, line', () => {
6
+ const [read] = reads(`
7
+ const page = await supabase
8
+ .from('orders')
9
+ .select('id, placed_at')
10
+ .eq('status', 'open')
11
+ .not('customer_id', 'is', null)
12
+ .gt('placed_at', since)
13
+ .lte('placed_at', until)
14
+ .order('placed_at')
15
+ .order('id')
16
+ .limit(100);`);
17
+ expect(read).toEqual({
18
+ table: 'orders', line: 3, uncertain: false, orderBy: 'placed_at',
19
+ filters: [
20
+ { column: 'status', op: 'eq', value: 'open' },
21
+ { column: 'customer_id', op: 'notnull' },
22
+ { column: 'placed_at', op: 'range' },
23
+ { column: 'placed_at', op: 'range' },
24
+ ],
25
+ });
26
+ });
27
+ it('reads .in, .is(null), .match and .filter, and qualifies a non-public schema', () => {
28
+ const [read] = reads(`db.schema('billing').from('invoices').select('*').in('id', ids).is('paid_at', null).match({ kind: 'card' }).filter('total', 'gte', 10);`);
29
+ expect(read.table).toBe('billing.invoices');
30
+ expect(read.filters).toEqual([
31
+ { column: 'id', op: 'in' },
32
+ { column: 'paid_at', op: 'isnull' },
33
+ { column: 'kind', op: 'eq', value: 'card' },
34
+ { column: 'total', op: 'range' },
35
+ ]);
36
+ expect(read.uncertain).toBe(false);
37
+ });
38
+ it('is uncertain when anything is computed, OR-ed, embedded or unknown', () => {
39
+ const uncertain = [
40
+ `db.from(table).select('*').eq('a', 1);`,
41
+ `db.from('t').select('*').eq(column, 1);`,
42
+ `db.from('t').select('*').or('a.eq.1,b.eq.2');`,
43
+ `db.from('t').select('*, u!inner(*)').eq('u.id', 1);`,
44
+ `db.from('t').select('*').someNewFilter('a', 1);`,
45
+ `db.schema(name).from('t').select('*').eq('a', 1);`,
46
+ ];
47
+ expect(uncertain.map(code => reads(code)[0].uncertain)).toEqual(uncertain.map(() => true));
48
+ });
49
+ it('ignores writes and calls that are not reads', () => {
50
+ expect(reads(`db.from('t').insert({ a: 1 }).select();`)).toEqual([]);
51
+ expect(reads(`db.from('t').update({ a: 1 }).eq('id', 1);`)).toEqual([]);
52
+ expect(reads(`Array.from(items).filter(Boolean);`)).toEqual([]);
53
+ });
54
+ });
@@ -0,0 +1,38 @@
1
+ /** One term of a partial index's WHERE, or `unknown` when it is not a plain comparison. */
2
+ export type PredicateTerm = {
3
+ kind: 'eq';
4
+ column: string;
5
+ value: string;
6
+ } | {
7
+ kind: 'notnull';
8
+ column: string;
9
+ } | {
10
+ kind: 'null';
11
+ column: string;
12
+ } | {
13
+ kind: 'unknown';
14
+ };
15
+ export interface IndexDef {
16
+ name?: string;
17
+ /** Leading key column, or null when the key starts with an expression. */
18
+ leading: string | null;
19
+ predicate: PredicateTerm[];
20
+ }
21
+ export interface TableSchema {
22
+ indexes: IndexDef[];
23
+ /** Policy name → columns its USING clause compares to the user, or null when the expression is not one this reads. */
24
+ policies: Map<string, string[] | null>;
25
+ uncertain: boolean;
26
+ }
27
+ /** Columns every policy-filtered read of the table also compares, and whether some policy could not be read. */
28
+ export declare function policyFilters(table: TableSchema): {
29
+ columns: string[];
30
+ unknown: boolean;
31
+ };
32
+ export type Schema = Map<string, TableSchema>;
33
+ /** Replay migration files (already in apply order) into a schema. */
34
+ export declare function replayMigrations(sqlFiles: string[]): Schema;
35
+ /** A conjunction of plain comparisons; anything else is `unknown`, which the gate treats as possibly serving. */
36
+ export declare function parsePredicate(text: string): PredicateTerm[];
37
+ /** Columns a policy compares to the current user (`auth.uid() = col`, `col = (select auth.uid())`); `true` gives none; anything else, null. */
38
+ export declare function policyColumns(expression: string): string[] | null;
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Replay Postgres migrations, in order, into the tables they create and the
3
+ * indexes each table ends up with (primary keys and unique constraints count:
4
+ * Postgres backs them with an index).
5
+ *
6
+ * A table touched by DDL this cannot model is marked uncertain, and the gate
7
+ * says nothing about it: a rename, `LIKE … INCLUDING`, inheritance or
8
+ * partitioning, an index statement it cannot parse, or index DDL inside a
9
+ * function or DO body.
10
+ *
11
+ * Row-level security policies are filters a read does not show: a SELECT or
12
+ * ALL policy `auth.uid() = owner_id` makes every user read filter on
13
+ * `owner_id`. Policies made of such comparisons (or `true`) are kept per
14
+ * table; any other policy expression makes the table uncertain.
15
+ */
16
+ import { NAME, identifier, parenthesized, relationName, splitStatements, splitTopLevel, unwrap } from './sql.js';
17
+ /** Columns every policy-filtered read of the table also compares, and whether some policy could not be read. */
18
+ export function policyFilters(table) {
19
+ const all = [...table.policies.values()];
20
+ return { columns: [...new Set(all.flatMap(columns => columns ?? []))], unknown: all.some(columns => columns === null) };
21
+ }
22
+ /** Replay migration files (already in apply order) into a schema. */
23
+ export function replayMigrations(sqlFiles) {
24
+ const schema = new Map();
25
+ for (const sql of sqlFiles) {
26
+ for (const statement of splitStatements(sql))
27
+ applyStatement(schema, statement);
28
+ }
29
+ return schema;
30
+ }
31
+ function applyStatement(schema, statement) {
32
+ const head = statement.slice(0, 40).toLowerCase();
33
+ if (head.startsWith('create table') || head.startsWith('create unlogged table'))
34
+ createTable(schema, statement);
35
+ else if (/^create (unique )?index/.test(head))
36
+ createIndex(schema, statement);
37
+ else if (head.startsWith('drop index'))
38
+ dropIndexes(schema, statement);
39
+ else if (head.startsWith('drop table'))
40
+ dropTables(schema, statement);
41
+ else if (head.startsWith('alter table'))
42
+ alterTable(schema, statement);
43
+ else if (head.startsWith('alter index'))
44
+ renameIndex(schema, statement);
45
+ else if (head.startsWith('create policy'))
46
+ createPolicy(schema, statement);
47
+ else if (head.startsWith('drop policy'))
48
+ dropPolicy(schema, statement);
49
+ else if (head.startsWith('alter policy'))
50
+ markUncertain(schema, policyTable(statement));
51
+ else if (/^(do |create (or replace )?(function|procedure))/.test(head))
52
+ markDynamicDdl(schema, statement);
53
+ }
54
+ function createTable(schema, statement) {
55
+ const match = new RegExp(String.raw `^create (?:unlogged )?table (?:if not exists )?(${NAME})\s*`, 'i').exec(statement);
56
+ if (!match)
57
+ return;
58
+ const name = relationName(match[1]);
59
+ if (/if not exists/i.test(statement.slice(0, match[0].length)) && schema.has(name))
60
+ return;
61
+ const body = parenthesized(statement, match[0].length);
62
+ const table = { indexes: [], policies: new Map(), uncertain: false };
63
+ schema.set(name, table);
64
+ if (!body || statement[match[0].length] !== '(' || /\b(inherits|partition)\b/i.test(statement.slice(body.end))) {
65
+ table.uncertain = true;
66
+ return;
67
+ }
68
+ for (const element of splitTopLevel(body.inner, /,/))
69
+ addTableElement(table, element);
70
+ }
71
+ /** A column definition (`id uuid PRIMARY KEY`) or a table constraint (`UNIQUE (a, b)`). */
72
+ function addTableElement(table, element) {
73
+ if (/^like\s/i.test(element)) {
74
+ table.uncertain = true;
75
+ return;
76
+ }
77
+ const constraint = /^(?:constraint \S+ )?(primary key|unique)(?: nulls (?:not )?distinct)? \(([^)]*)\)/i.exec(element);
78
+ if (constraint) {
79
+ table.indexes.push({ leading: identifier(constraint[2].split(',')[0]), predicate: [] });
80
+ return;
81
+ }
82
+ if (/^(constraint|check|foreign key|exclude)\b/i.test(element))
83
+ return;
84
+ const column = new RegExp(String.raw `^(${NAME})\s`, 'i').exec(element);
85
+ if (column && /\b(primary key|unique)\b/i.test(element))
86
+ table.indexes.push({ leading: identifier(column[1]), predicate: [] });
87
+ }
88
+ function createIndex(schema, statement) {
89
+ const match = new RegExp(String.raw `^create (?:unique )?index (?:concurrently )?(?:if not exists )?(${NAME} )?on (?:only )?(${NAME})(?: using \w+)?\s*\(`, 'i').exec(statement);
90
+ const onTable = new RegExp(String.raw `\bon (?:only )?(${NAME})`, 'i').exec(statement);
91
+ if (!match) {
92
+ markUncertain(schema, onTable ? relationName(onTable[1]) : undefined);
93
+ return;
94
+ }
95
+ const table = schema.get(relationName(match[2]));
96
+ const keys = parenthesized(statement, match[0].length - 1);
97
+ if (!table)
98
+ return;
99
+ if (!keys) {
100
+ table.uncertain = true;
101
+ return;
102
+ }
103
+ const name = match[1] ? identifier(match[1].trim().split('.').pop()) : undefined;
104
+ if (name && /if not exists/i.test(statement) && findIndex(schema, name))
105
+ return;
106
+ const where = /^\s*(?:include \([^)]*\)\s*)?(?:with \([^)]*\)\s*)?(?:tablespace \S+\s*)?where (.*)$/i.exec(statement.slice(keys.end));
107
+ table.indexes.push({ name, leading: leadingColumn(keys.inner), predicate: where ? parsePredicate(where[1]) : [] });
108
+ }
109
+ /** The first key column, or null when the key starts with an expression such as `lower(email)`. */
110
+ function leadingColumn(keys) {
111
+ const first = splitTopLevel(keys, /,/)[0] ?? '';
112
+ const column = new RegExp(String.raw `^(${NAME})(?:\s+(?:asc|desc|nulls (?:first|last)|collate \S+|\w+_ops))*$`, 'i').exec(first);
113
+ return column ? identifier(column[1]) : null;
114
+ }
115
+ /** A conjunction of plain comparisons; anything else is `unknown`, which the gate treats as possibly serving. */
116
+ export function parsePredicate(text) {
117
+ const cleaned = unwrap(text);
118
+ if (/\bor\b/i.test(cleaned))
119
+ return [{ kind: 'unknown' }];
120
+ return splitTopLevel(cleaned, /\s+and\s+/i).map(term => {
121
+ const bare = unwrap(term);
122
+ const notNull = new RegExp(String.raw `^(${NAME}) is not null$`, 'i').exec(bare);
123
+ if (notNull)
124
+ return { kind: 'notnull', column: identifier(notNull[1]) };
125
+ const isNull = new RegExp(String.raw `^(${NAME}) is null$`, 'i').exec(bare);
126
+ if (isNull)
127
+ return { kind: 'null', column: identifier(isNull[1]) };
128
+ const eq = new RegExp(String.raw `^(${NAME}) = '((?:[^']|'')*)'(?:::\w+)?$`, 'i').exec(bare);
129
+ if (eq)
130
+ return { kind: 'eq', column: identifier(eq[1]), value: eq[2].replace(/''/g, "'") };
131
+ return { kind: 'unknown' };
132
+ });
133
+ }
134
+ function dropIndexes(schema, statement) {
135
+ const names = statement.replace(/^drop index (?:concurrently )?(?:if exists )?/i, '').replace(/\s+(cascade|restrict)$/i, '');
136
+ for (const raw of names.split(',')) {
137
+ const name = identifier(raw.trim().split('.').pop());
138
+ for (const table of schema.values())
139
+ table.indexes = table.indexes.filter(index => index.name !== name);
140
+ }
141
+ }
142
+ function dropTables(schema, statement) {
143
+ const names = statement.replace(/^drop table (?:if exists )?/i, '').replace(/\s+(cascade|restrict)$/i, '');
144
+ for (const raw of names.split(','))
145
+ schema.delete(relationName(raw.trim()));
146
+ }
147
+ function alterTable(schema, statement) {
148
+ const match = new RegExp(String.raw `^alter table (?:if exists )?(?:only )?(${NAME}) (.*)$`, 'i').exec(statement);
149
+ if (!match)
150
+ return;
151
+ const name = relationName(match[1]);
152
+ const table = schema.get(name);
153
+ if (!table)
154
+ return;
155
+ const rename = new RegExp(String.raw `^rename to (${NAME})$`, 'i').exec(match[2]);
156
+ if (rename) {
157
+ schema.delete(name);
158
+ schema.set(relationName(rename[1]), table);
159
+ return;
160
+ }
161
+ for (const action of splitTopLevel(match[2], /,/))
162
+ alterAction(table, action);
163
+ }
164
+ function alterAction(table, action) {
165
+ if (/^rename\b/i.test(action)) {
166
+ table.uncertain = true;
167
+ return;
168
+ }
169
+ const constraint = /^add (?:constraint \S+ )?(primary key|unique)(?: nulls (?:not )?distinct)?\s*(?:\(([^)]*)\)|using index (\S+))/i.exec(action);
170
+ if (constraint) {
171
+ if (constraint[2])
172
+ table.indexes.push({ leading: identifier(constraint[2].split(',')[0]), predicate: [] });
173
+ return;
174
+ }
175
+ const column = new RegExp(String.raw `^add (?:column )?(?:if not exists )?(${NAME})\s`, 'i').exec(action);
176
+ if (column && /\b(primary key|unique)\b/i.test(action))
177
+ table.indexes.push({ leading: identifier(column[1]), predicate: [] });
178
+ }
179
+ function renameIndex(schema, statement) {
180
+ const match = new RegExp(String.raw `^alter index (?:if exists )?(${NAME}) rename to (${NAME})$`, 'i').exec(statement);
181
+ if (!match)
182
+ return;
183
+ const index = findIndex(schema, identifier(match[1].split('.').pop()));
184
+ if (index)
185
+ index.name = identifier(match[2].split('.').pop());
186
+ }
187
+ /** Index or constraint DDL inside a function or DO body runs later, maybe dynamically: the tables it names are unknown. */
188
+ function markDynamicDdl(schema, statement) {
189
+ const ddl = new RegExp(String.raw `(?:create (?:unique )?index[^;]*? on (?:only )?|alter table (?:if exists )?(?:only )?)(${NAME})`, 'gi');
190
+ let named = false;
191
+ for (const match of statement.matchAll(ddl)) {
192
+ named = true;
193
+ markUncertain(schema, relationName(match[1]));
194
+ }
195
+ if (!named && /\bexecute\b/i.test(statement) && /\b(index|constraint|primary key|unique)\b/i.test(statement))
196
+ markUncertain(schema, undefined);
197
+ }
198
+ /** One table, or every table when the target cannot be named. */
199
+ function markUncertain(schema, name) {
200
+ if (name === undefined)
201
+ for (const table of schema.values())
202
+ table.uncertain = true;
203
+ else {
204
+ const table = schema.get(name);
205
+ if (table)
206
+ table.uncertain = true;
207
+ }
208
+ }
209
+ function createPolicy(schema, statement) {
210
+ const match = new RegExp(String.raw `^create policy (${NAME}) on (${NAME})(.*)$`, 'i').exec(statement);
211
+ const table = match ? schema.get(relationName(match[2])) : undefined;
212
+ if (!match || !table)
213
+ return;
214
+ const command = /\bfor (all|select|insert|update|delete)\b/i.exec(match[3])?.[1].toLowerCase() ?? 'all';
215
+ if (command !== 'all' && command !== 'select')
216
+ return;
217
+ const using = /\busing\s*\(/i.exec(match[3]);
218
+ const body = using ? parenthesized(match[3], using.index + using[0].length - 1) : undefined;
219
+ table.policies.set(identifier(match[1]), using && body ? policyColumns(body.inner) : null);
220
+ }
221
+ /** Columns a policy compares to the current user (`auth.uid() = col`, `col = (select auth.uid())`); `true` gives none; anything else, null. */
222
+ export function policyColumns(expression) {
223
+ const bare = unwrap(expression);
224
+ if (/^true$/i.test(bare))
225
+ return [];
226
+ // splitStatements leaves single spaces, so ` ?` is enough and nothing can backtrack.
227
+ const uidPattern = String.raw `\(? ?(?:select )?auth\.uid\(\) ?\)?(?:::\w+)?`;
228
+ const columns = [];
229
+ for (const term of splitTopLevel(bare, /\s+(?:and|or)\s+/i)) {
230
+ const inner = unwrap(term);
231
+ const left = new RegExp(String.raw `^${uidPattern} ?= ?(${NAME})(?:::\w+)?$`, 'i').exec(inner);
232
+ const right = new RegExp(String.raw `^(${NAME})(?:::\w+)? ?= ?${uidPattern}$`, 'i').exec(inner);
233
+ const column = left?.[1] ?? right?.[1];
234
+ if (!column)
235
+ return null;
236
+ columns.push(identifier(column.split('.').pop()));
237
+ }
238
+ return columns;
239
+ }
240
+ function dropPolicy(schema, statement) {
241
+ const match = new RegExp(String.raw `^drop policy (?:if exists )?(${NAME}) on (${NAME})`, 'i').exec(statement);
242
+ if (match)
243
+ schema.get(relationName(match[2]))?.policies.delete(identifier(match[1]));
244
+ }
245
+ function policyTable(statement) {
246
+ const match = new RegExp(String.raw `\bon (${NAME})`, 'i').exec(statement);
247
+ return match ? relationName(match[1]) : undefined;
248
+ }
249
+ function findIndex(schema, name) {
250
+ for (const table of schema.values()) {
251
+ const found = table.indexes.find(index => index.name === name);
252
+ if (found)
253
+ return found;
254
+ }
255
+ return undefined;
256
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { policyColumns, policyFilters, replayMigrations } from './schema.js';
3
+ import { splitStatements } from './sql.js';
4
+ describe('splitStatements', () => {
5
+ it('keeps comments, quotes and dollar bodies from splitting a statement', () => {
6
+ const sql = `-- a; comment\ncreate table a (x text default ';');\n/* b; */ do $$ begin perform 1; end $$;\nselect 'it''s; fine';`;
7
+ expect(splitStatements(sql)).toEqual(["create table a (x text default ';')", 'do $$ begin perform 1; end $$', "select 'it''s; fine'"]);
8
+ });
9
+ });
10
+ describe('replayMigrations', () => {
11
+ it('collects primary keys, unique columns and table constraints as indexes', () => {
12
+ const schema = replayMigrations([`
13
+ create table public.orders (
14
+ id uuid primary key default gen_random_uuid(),
15
+ code text not null unique,
16
+ customer_id uuid not null references customers(id),
17
+ placed_at timestamptz,
18
+ constraint orders_customer_code unique (customer_id, code)
19
+ );`]);
20
+ expect(schema.get('orders').indexes.map(i => i.leading)).toEqual(['id', 'code', 'customer_id']);
21
+ expect(schema.get('orders').uncertain).toBe(false);
22
+ });
23
+ it('applies indexes, partial predicates, drops and renames in order', () => {
24
+ const schema = replayMigrations([
25
+ 'create table orders (id uuid primary key, status text, placed_at timestamptz, customer_id uuid);',
26
+ `create index concurrently orders_open_idx on public.orders (placed_at desc, id) where status = 'open' and customer_id is not null;`,
27
+ 'create index orders_customer_idx on orders using btree (customer_id);',
28
+ 'create index orders_lower_idx on orders (lower(status));',
29
+ 'drop index if exists public.orders_customer_idx;',
30
+ 'alter index orders_open_idx rename to orders_open_v2_idx;',
31
+ ]);
32
+ const indexes = schema.get('orders').indexes;
33
+ expect(indexes.map(i => [i.name, i.leading])).toEqual([[undefined, 'id'], ['orders_open_v2_idx', 'placed_at'], ['orders_lower_idx', null]]);
34
+ expect(indexes[1].predicate).toEqual([{ kind: 'eq', column: 'status', value: 'open' }, { kind: 'notnull', column: 'customer_id' }]);
35
+ });
36
+ it('follows ALTER TABLE: added keys, table renames, dropped tables', () => {
37
+ const schema = replayMigrations([
38
+ 'create table events (id bigint, kind text);',
39
+ 'alter table events add constraint events_pkey primary key (id);',
40
+ 'alter table events add column ref text unique;',
41
+ 'alter table events rename to audit_events;',
42
+ 'create table scratch (x int);',
43
+ 'drop table scratch;',
44
+ ]);
45
+ expect(schema.get('audit_events').indexes.map(i => i.leading)).toEqual(['id', 'ref']);
46
+ expect([...schema.keys()]).toEqual(['audit_events']);
47
+ });
48
+ it('marks a table uncertain when DDL is beyond what it models', () => {
49
+ const schema = replayMigrations([
50
+ 'create table a (id int, x int); create table b (id int); create table c (like a including indexes); create table d (id int) partition by range (id);',
51
+ 'alter table a rename column x to y;',
52
+ `create or replace function f() returns void language plpgsql as $$ begin create index on b (id); end $$;`,
53
+ ]);
54
+ expect(['a', 'b', 'c', 'd'].map(t => schema.get(t).uncertain)).toEqual([true, true, true, true]);
55
+ });
56
+ it('treats an OR or an unreadable predicate as unknown', () => {
57
+ const schema = replayMigrations([
58
+ 'create table t (a int, b int);',
59
+ `create index t_or on t (a) where a = 1 or b = 2;`,
60
+ `create index t_fn on t (a) where coalesce(b, 0) > 0;`,
61
+ ]);
62
+ expect(schema.get('t').indexes.map(i => i.predicate)).toEqual([[{ kind: 'unknown' }], [{ kind: 'unknown' }]]);
63
+ });
64
+ it('keeps SELECT and ALL policies as the columns they compare to the user', () => {
65
+ const schema = replayMigrations([
66
+ 'create table subs (id text primary key, user_id uuid, status text);',
67
+ `create policy "own" on subs for select using (auth.uid() = user_id);`,
68
+ `create policy "writes" on subs for insert with check (auth.uid() = user_id);`,
69
+ `create policy "all" on subs to authenticated using (true);`,
70
+ ]);
71
+ expect(policyFilters(schema.get('subs'))).toEqual({ columns: ['user_id'], unknown: false });
72
+ });
73
+ });
74
+ describe('policyColumns', () => {
75
+ it('reads comparisons to the current user and gives up on anything else', () => {
76
+ expect(policyColumns('(select auth.uid()) = owner_id')).toEqual(['owner_id']);
77
+ expect(policyColumns('(owner_id = auth.uid()) or (editor_id = auth.uid())')).toEqual(['owner_id', 'editor_id']);
78
+ expect(policyColumns('true')).toEqual([]);
79
+ expect(policyColumns('exists (select 1 from members m where m.org_id = org_id)')).toBeNull();
80
+ });
81
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Just enough SQL lexing to replay Postgres migrations: statements, top-level
3
+ * lists, and identifiers. Anything this cannot read is reported as unknown so
4
+ * the gate stays silent rather than guess.
5
+ */
6
+ /** Statements in order, comments removed; a `$tag$ … $tag$` body stays inside its statement. */
7
+ export declare function splitStatements(sql: string): string[];
8
+ /** Split on `separator` outside parentheses and quotes. */
9
+ export declare function splitTopLevel(text: string, separator: RegExp): string[];
10
+ /** The text inside the parentheses that open at `open`, or undefined when they never close. */
11
+ export declare function parenthesized(text: string, open: number): {
12
+ inner: string;
13
+ end: number;
14
+ } | undefined;
15
+ /** Strip parentheses that enclose the whole expression, as often as they do: `((a))` → `a`, but `(a) or (b)` stays. */
16
+ export declare function unwrap(text: string): string;
17
+ /** A relation name as Rigour keys it: unquoted names fold to lower case, `public.` is implied. */
18
+ export declare function relationName(raw: string): string;
19
+ export declare function identifier(raw: string): string;
20
+ /** One identifier, optionally schema-qualified and quoted: the `name` in `CREATE TABLE name (`. */
21
+ export declare const NAME: string;