@rigour-labs/core 6.5.2 → 6.5.4
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/README.md +1 -1
- package/dist/context/automatic-index.d.ts +5 -0
- package/dist/context/automatic-index.js +24 -3
- package/dist/context/index-status.test.d.ts +1 -0
- package/dist/context/index-status.test.js +26 -0
- package/dist/deep/risk.d.ts +6 -0
- package/dist/deep/risk.js +7 -1
- package/dist/deep/risk.test.js +12 -1
- package/dist/firewall/arbitration-token.d.ts +12 -3
- package/dist/firewall/arbitration-token.js +30 -23
- package/dist/firewall/attestation.d.ts +7 -2
- package/dist/firewall/attestation.js +49 -28
- package/dist/firewall/trust-boundaries.test.d.ts +1 -0
- package/dist/firewall/trust-boundaries.test.js +64 -0
- package/dist/gates/dedupe-failures.test.d.ts +1 -0
- package/dist/gates/dedupe-failures.test.js +12 -0
- package/dist/gates/deep-analysis.d.ts +6 -0
- package/dist/gates/deep-analysis.js +8 -1
- package/dist/gates/deprecated-dependencies.d.ts +27 -0
- package/dist/gates/deprecated-dependencies.js +104 -0
- package/dist/gates/deprecated-dependencies.test.d.ts +1 -0
- package/dist/gates/deprecated-dependencies.test.js +55 -0
- package/dist/gates/runner.d.ts +7 -1
- package/dist/gates/runner.js +25 -11
- package/dist/gates/security-patterns-data.js +2 -1
- package/dist/gates/security-patterns-owasp.test.js +15 -0
- package/dist/gates/semantic-bugs.js +1 -1
- package/dist/gates/trusted-reviews.test.d.ts +1 -0
- package/dist/gates/trusted-reviews.test.js +16 -0
- package/dist/gates/unindexed-reads/coverage.d.ts +15 -0
- package/dist/gates/unindexed-reads/coverage.js +24 -0
- package/dist/gates/unindexed-reads/index.d.ts +20 -0
- package/dist/gates/unindexed-reads/index.js +98 -0
- package/dist/gates/unindexed-reads/queries.d.ts +17 -0
- package/dist/gates/unindexed-reads/queries.js +147 -0
- package/dist/gates/unindexed-reads/queries.test.d.ts +1 -0
- package/dist/gates/unindexed-reads/queries.test.js +54 -0
- package/dist/gates/unindexed-reads/schema.d.ts +38 -0
- package/dist/gates/unindexed-reads/schema.js +256 -0
- package/dist/gates/unindexed-reads/schema.test.d.ts +1 -0
- package/dist/gates/unindexed-reads/schema.test.js +81 -0
- package/dist/gates/unindexed-reads/sql.d.ts +21 -0
- package/dist/gates/unindexed-reads/sql.js +131 -0
- package/dist/gates/unindexed-reads/unindexed-reads.test.d.ts +1 -0
- package/dist/gates/unindexed-reads/unindexed-reads.test.js +90 -0
- package/dist/hooks/checker.js +22 -0
- package/dist/hooks/checker.test.js +8 -0
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/session-state.d.ts +8 -0
- package/dist/hooks/session-state.js +61 -0
- package/dist/hooks/stop-review.d.ts +6 -4
- package/dist/hooks/stop-review.js +5 -3
- package/dist/hooks/templates.js +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/inference/brain-placeholder.test.d.ts +1 -0
- package/dist/inference/brain-placeholder.test.js +28 -0
- package/dist/inference/llama-engine-checksum.test.d.ts +1 -0
- package/dist/inference/llama-engine-checksum.test.js +27 -0
- package/dist/inference/llama-engine.d.ts +4 -1
- package/dist/inference/llama-engine.js +15 -8
- package/dist/memory/recall.d.ts +37 -0
- package/dist/memory/recall.js +93 -0
- package/dist/memory/recall.test.d.ts +1 -0
- package/dist/memory/recall.test.js +36 -0
- package/dist/pattern-index/assess.d.ts +16 -0
- package/dist/pattern-index/assess.js +37 -0
- package/dist/pattern-index/embeddings.d.ts +19 -0
- package/dist/pattern-index/embeddings.js +16 -0
- package/dist/pattern-index/index.d.ts +3 -2
- package/dist/pattern-index/index.js +4 -2
- package/dist/pattern-index/indexer.js +15 -7
- package/dist/pattern-index/matcher.d.ts +6 -0
- package/dist/pattern-index/matcher.js +13 -3
- package/dist/pattern-index/pattern-reuse.test.d.ts +1 -0
- package/dist/pattern-index/pattern-reuse.test.js +76 -0
- package/dist/pattern-index/staleness.d.ts +2 -15
- package/dist/pattern-index/staleness.js +1 -78
- package/dist/pattern-index/types.d.ts +2 -0
- package/dist/review/agent-fixes.d.ts +4 -0
- package/dist/review/agent-fixes.js +10 -7
- package/dist/review/agent-fixes.test.js +4 -4
- package/dist/review/changed-lines.d.ts +2 -1
- package/dist/review/changed-lines.js +12 -2
- package/dist/review/check-outcomes.d.ts +32 -0
- package/dist/review/check-outcomes.js +90 -0
- package/dist/review/check-outcomes.test.d.ts +1 -0
- package/dist/review/check-outcomes.test.js +56 -0
- package/dist/review/git-diff.d.ts +3 -0
- package/dist/review/git-diff.js +5 -0
- package/dist/review/migration-order.d.ts +5 -0
- package/dist/review/migration-order.js +70 -0
- package/dist/review/migration-order.test.d.ts +1 -0
- package/dist/review/migration-order.test.js +62 -0
- package/dist/review/quiet.d.ts +5 -2
- package/dist/review/quiet.js +21 -8
- package/dist/review/review.d.ts +6 -0
- package/dist/review/review.js +20 -3
- package/dist/review/review.test.js +30 -0
- package/dist/review/trusted-state.d.ts +6 -0
- package/dist/review/trusted-state.js +35 -0
- package/dist/semantic/ast.d.ts +1 -0
- package/dist/semantic/ast.js +4 -0
- package/dist/semantic/learn/compile.js +3 -1
- package/dist/semantic/learn/learn.js +40 -5
- package/dist/semantic/types.d.ts +2 -0
- package/dist/services/context-scope-summary.test.d.ts +1 -0
- package/dist/services/context-scope-summary.test.js +17 -0
- package/dist/services/context-telemetry-service.js +2 -1
- package/dist/storage/context-telemetry.js +33 -19
- package/dist/storage/db.js +52 -1
- package/dist/storage/findings.js +1 -1
- package/dist/storage/fix-lessons.d.ts +13 -0
- package/dist/storage/fix-lessons.js +84 -0
- package/dist/storage/fix-lessons.test.d.ts +1 -0
- package/dist/storage/fix-lessons.test.js +61 -0
- package/dist/storage/index.d.ts +2 -1
- package/dist/storage/index.js +2 -1
- package/dist/storage/lessons.d.ts +16 -2
- package/dist/storage/lessons.js +27 -61
- package/dist/storage/lessons.test.js +1 -15
- package/dist/storage/local-memory.js +39 -5
- package/dist/storage/local-memory.test.d.ts +1 -0
- package/dist/storage/local-memory.test.js +55 -0
- package/dist/storage/patterns.d.ts +1 -1
- package/dist/storage/patterns.js +1 -1
- package/dist/storage/share-memory.test.d.ts +1 -0
- package/dist/storage/share-memory.test.js +34 -0
- package/dist/storage/team-vector-store.js +2 -1
- package/dist/storage/telemetry-scope.test.d.ts +1 -0
- package/dist/storage/telemetry-scope.test.js +38 -0
- package/dist/templates/universal-config.js +11 -0
- package/dist/types/index.d.ts +137 -0
- package/dist/types/index.js +15 -0
- package/dist/utils/user-state.d.ts +7 -0
- package/dist/utils/user-state.js +21 -0
- package/package.json +7 -7
|
@@ -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;
|
|
@@ -0,0 +1,131 @@
|
|
|
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 function splitStatements(sql) {
|
|
8
|
+
const statements = [];
|
|
9
|
+
let current = '';
|
|
10
|
+
let i = 0;
|
|
11
|
+
while (i < sql.length) {
|
|
12
|
+
const rest = sql.slice(i);
|
|
13
|
+
const dollar = /^\$[A-Za-z_]*\$/.exec(rest);
|
|
14
|
+
if (rest.startsWith('--')) {
|
|
15
|
+
const end = sql.indexOf('\n', i);
|
|
16
|
+
i = end === -1 ? sql.length : end;
|
|
17
|
+
}
|
|
18
|
+
else if (rest.startsWith('/*')) {
|
|
19
|
+
const end = sql.indexOf('*/', i + 2);
|
|
20
|
+
i = end === -1 ? sql.length : end + 2;
|
|
21
|
+
current += ' ';
|
|
22
|
+
}
|
|
23
|
+
else if (dollar) {
|
|
24
|
+
const end = sql.indexOf(dollar[0], i + dollar[0].length);
|
|
25
|
+
const stop = end === -1 ? sql.length : end + dollar[0].length;
|
|
26
|
+
current += sql.slice(i, stop);
|
|
27
|
+
i = stop;
|
|
28
|
+
}
|
|
29
|
+
else if (sql[i] === "'" || sql[i] === '"') {
|
|
30
|
+
const stop = quotedEnd(sql, i);
|
|
31
|
+
current += sql.slice(i, stop);
|
|
32
|
+
i = stop;
|
|
33
|
+
}
|
|
34
|
+
else if (sql[i] === ';') {
|
|
35
|
+
if (current.trim())
|
|
36
|
+
statements.push(current.trim());
|
|
37
|
+
current = '';
|
|
38
|
+
i++;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
current += sql[i++];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (current.trim())
|
|
45
|
+
statements.push(current.trim());
|
|
46
|
+
return statements.map(s => s.replace(/\s+/g, ' '));
|
|
47
|
+
}
|
|
48
|
+
/** Index just past a quoted string or identifier starting at `start` (a doubled quote is an escape). */
|
|
49
|
+
function quotedEnd(text, start) {
|
|
50
|
+
const quote = text[start];
|
|
51
|
+
let i = start + 1;
|
|
52
|
+
while (i < text.length) {
|
|
53
|
+
if (text[i] === quote && text[i + 1] === quote)
|
|
54
|
+
i += 2;
|
|
55
|
+
else if (text[i] === quote)
|
|
56
|
+
return i + 1;
|
|
57
|
+
else
|
|
58
|
+
i++;
|
|
59
|
+
}
|
|
60
|
+
return text.length;
|
|
61
|
+
}
|
|
62
|
+
/** Split on `separator` outside parentheses and quotes. */
|
|
63
|
+
export function splitTopLevel(text, separator) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
let depth = 0;
|
|
66
|
+
let start = 0;
|
|
67
|
+
let i = 0;
|
|
68
|
+
while (i < text.length) {
|
|
69
|
+
const ch = text[i];
|
|
70
|
+
if (ch === "'" || ch === '"') {
|
|
71
|
+
i = quotedEnd(text, i);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (ch === '(')
|
|
75
|
+
depth++;
|
|
76
|
+
else if (ch === ')')
|
|
77
|
+
depth--;
|
|
78
|
+
else if (depth === 0) {
|
|
79
|
+
const match = separator.exec(text.slice(i));
|
|
80
|
+
if (match && match.index === 0) {
|
|
81
|
+
parts.push(text.slice(start, i).trim());
|
|
82
|
+
i += match[0].length;
|
|
83
|
+
start = i;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
i++;
|
|
88
|
+
}
|
|
89
|
+
parts.push(text.slice(start).trim());
|
|
90
|
+
return parts.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
/** The text inside the parentheses that open at `open`, or undefined when they never close. */
|
|
93
|
+
export function parenthesized(text, open) {
|
|
94
|
+
let depth = 0;
|
|
95
|
+
for (let i = open; i < text.length; i++) {
|
|
96
|
+
const ch = text[i];
|
|
97
|
+
if (ch === "'" || ch === '"') {
|
|
98
|
+
i = quotedEnd(text, i) - 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (ch === '(')
|
|
102
|
+
depth++;
|
|
103
|
+
else if (ch === ')' && --depth === 0)
|
|
104
|
+
return { inner: text.slice(open + 1, i), end: i + 1 };
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
/** Strip parentheses that enclose the whole expression, as often as they do: `((a))` → `a`, but `(a) or (b)` stays. */
|
|
109
|
+
export function unwrap(text) {
|
|
110
|
+
let current = text.trim();
|
|
111
|
+
while (current.startsWith('(')) {
|
|
112
|
+
const group = parenthesized(current, 0);
|
|
113
|
+
if (!group || group.end !== current.length)
|
|
114
|
+
break;
|
|
115
|
+
current = group.inner.trim();
|
|
116
|
+
}
|
|
117
|
+
return current;
|
|
118
|
+
}
|
|
119
|
+
/** A relation name as Rigour keys it: unquoted names fold to lower case, `public.` is implied. */
|
|
120
|
+
export function relationName(raw) {
|
|
121
|
+
const parts = raw.split('.').map(identifier);
|
|
122
|
+
if (parts.length === 2 && parts[0] === 'public')
|
|
123
|
+
return parts[1];
|
|
124
|
+
return parts.join('.');
|
|
125
|
+
}
|
|
126
|
+
export function identifier(raw) {
|
|
127
|
+
const trimmed = raw.trim();
|
|
128
|
+
return trimmed.startsWith('"') ? trimmed.slice(1, -1).replace(/""/g, '"') : trimmed.toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
/** One identifier, optionally schema-qualified and quoted: the `name` in `CREATE TABLE name (`. */
|
|
131
|
+
export const NAME = String.raw `(?:"(?:[^"]|"")+"|[A-Za-z_][\w$]*)(?:\.(?:"(?:[^"]|"")+"|[A-Za-z_][\w$]*))?`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|