@rigour-labs/core 6.5.1 → 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.
- 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/fact-extractor.js +2 -1
- package/dist/deep/fact-extractor.test.js +2 -1
- package/dist/gates/ast.js +4 -2
- package/dist/gates/coverage.js +4 -2
- package/dist/gates/dedupe-failures.test.d.ts +1 -0
- package/dist/gates/dedupe-failures.test.js +12 -0
- 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/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/index.d.ts +4 -1
- package/dist/index.js +4 -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 +19 -9
- 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 +31 -0
- package/dist/review/check-outcomes.js +81 -0
- package/dist/review/check-outcomes.test.d.ts +1 -0
- package/dist/review/check-outcomes.test.js +56 -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 +2 -0
- package/dist/review/quiet.js +12 -3
- package/dist/review/review.d.ts +4 -0
- package/dist/review/review.js +17 -5
- package/dist/review/review.test.js +36 -1
- 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 +132 -0
- package/dist/types/index.js +15 -0
- package/dist/utils/glob-paths.d.ts +4 -0
- package/dist/utils/glob-paths.js +26 -0
- package/dist/utils/glob-paths.test.d.ts +1 -0
- package/dist/utils/glob-paths.test.js +40 -0
- package/dist/utils/scanner.js +4 -2
- package/dist/utils/scanner.test.js +2 -1
- package/package.json +7 -7
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unindexed read: a supabase-js read whose table, as the repository's own
|
|
3
|
+
* migrations define it, has no index that can serve it (coverage.ts).
|
|
4
|
+
*
|
|
5
|
+
* Advisory and off by default. It speaks only when the claim is provable:
|
|
6
|
+
* the read is fully literal, its table is created in the migrations, and no
|
|
7
|
+
* DDL touching that table is beyond what schema.ts models. A small table
|
|
8
|
+
* read without an index is fine; dismiss the finding for it.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'fs-extra';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { Gate } from '../base.js';
|
|
13
|
+
import { FileScanner } from '../../utils/scanner.js';
|
|
14
|
+
import { Logger } from '../../utils/logger.js';
|
|
15
|
+
import { policyFilters, replayMigrations } from './schema.js';
|
|
16
|
+
import { findReads } from './queries.js';
|
|
17
|
+
import { seekColumns, servesRead } from './coverage.js';
|
|
18
|
+
const DEFAULT_MIGRATIONS = ['**/migrations/**/*.sql'];
|
|
19
|
+
const SOURCE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
|
|
20
|
+
const SKIP = ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*.test.*', '**/*.spec.*', '**/__tests__/**', '**/*.d.ts'];
|
|
21
|
+
export class UnindexedReadsGate extends Gate {
|
|
22
|
+
config;
|
|
23
|
+
constructor(config = {}) {
|
|
24
|
+
super('unindexed-reads', 'Unindexed read');
|
|
25
|
+
this.config = config;
|
|
26
|
+
}
|
|
27
|
+
async run(context) {
|
|
28
|
+
if (!this.config.enabled)
|
|
29
|
+
return [];
|
|
30
|
+
const schemas = await loadSchemas(context.cwd, this.config.migrations?.length ? this.config.migrations : DEFAULT_MIGRATIONS, context.ignore ?? []);
|
|
31
|
+
if (schemas.length === 0)
|
|
32
|
+
return [];
|
|
33
|
+
const files = (await FileScanner.findFiles({ cwd: context.cwd, patterns: context.patterns, ignore: [...(context.ignore ?? []), ...SKIP] }))
|
|
34
|
+
.filter(file => SOURCE.test(file));
|
|
35
|
+
Logger.info(`Unindexed reads: ${files.length} files against ${schemas.length} migration set(s)`);
|
|
36
|
+
const failures = [];
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
const content = await fs.readFile(path.join(context.cwd, file), 'utf-8');
|
|
39
|
+
if (!content.includes('.from('))
|
|
40
|
+
continue;
|
|
41
|
+
for (const read of findReads(file, content)) {
|
|
42
|
+
const columns = unservedColumns(read, schemas);
|
|
43
|
+
if (columns)
|
|
44
|
+
failures.push(this.finding(file, read, columns));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return failures;
|
|
48
|
+
}
|
|
49
|
+
finding(file, read, columns) {
|
|
50
|
+
const list = columns.map(c => `\`${c}\``).join(', ');
|
|
51
|
+
return this.createFailure(`No index on \`${read.table}\` starts with a column this read compares to a value or sorts by first, including any its row-level policy adds (${list}), and none of its partial indexes match the read's filters, so Postgres cannot seek to these rows.`, [file], `Add an index in a new migration that leads with the most selective of ${list}, in the read's sort order. If the table stays small, dismiss this finding.`, undefined, read.line, undefined, 'medium');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The columns the read narrows by (its own and the table's policy filters) when
|
|
56
|
+
* no migration set can serve it; undefined when one can, or when that is not provable.
|
|
57
|
+
*/
|
|
58
|
+
export function unservedColumns(read, schemas) {
|
|
59
|
+
if (read.uncertain || seekColumns(read).size === 0)
|
|
60
|
+
return undefined;
|
|
61
|
+
const tables = schemas.map(schema => schema.get(read.table)).filter(table => table !== undefined);
|
|
62
|
+
if (tables.length === 0 || tables.some(table => table.uncertain || policyFilters(table).unknown))
|
|
63
|
+
return undefined;
|
|
64
|
+
const columns = new Set();
|
|
65
|
+
for (const table of tables) {
|
|
66
|
+
const filtered = withPolicyFilters(read, policyFilters(table).columns);
|
|
67
|
+
if (table.indexes.some(index => servesRead(index, filtered)))
|
|
68
|
+
return undefined;
|
|
69
|
+
for (const column of seekColumns(filtered))
|
|
70
|
+
columns.add(column);
|
|
71
|
+
}
|
|
72
|
+
return [...columns];
|
|
73
|
+
}
|
|
74
|
+
/** The read as Postgres runs it for a signed-in user: the policy's columns are compared too. */
|
|
75
|
+
function withPolicyFilters(read, columns) {
|
|
76
|
+
return { ...read, filters: [...read.filters, ...columns.map(column => ({ column, op: 'eq', value: undefined }))] };
|
|
77
|
+
}
|
|
78
|
+
/** One schema per migrations directory, replayed in filename order. */
|
|
79
|
+
async function loadSchemas(cwd, globs, ignore) {
|
|
80
|
+
const files = await FileScanner.findFiles({ cwd, patterns: globs, ignore });
|
|
81
|
+
const byDirectory = new Map();
|
|
82
|
+
for (const file of files) {
|
|
83
|
+
const directory = migrationsRoot(file);
|
|
84
|
+
byDirectory.set(directory, [...(byDirectory.get(directory) ?? []), file]);
|
|
85
|
+
}
|
|
86
|
+
const schemas = [];
|
|
87
|
+
for (const group of byDirectory.values()) {
|
|
88
|
+
const sql = await Promise.all(group.sort().map(file => fs.readFile(path.join(cwd, file), 'utf-8')));
|
|
89
|
+
schemas.push(replayMigrations(sql));
|
|
90
|
+
}
|
|
91
|
+
return schemas;
|
|
92
|
+
}
|
|
93
|
+
/** The directory a migration belongs to: up to and including its `migrations` segment, else its own folder. */
|
|
94
|
+
function migrationsRoot(file) {
|
|
95
|
+
const parts = file.split('/');
|
|
96
|
+
const at = parts.lastIndexOf('migrations');
|
|
97
|
+
return at >= 0 ? parts.slice(0, at + 1).join('/') : path.posix.dirname(file);
|
|
98
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type Filter = {
|
|
2
|
+
column: string;
|
|
3
|
+
op: 'eq';
|
|
4
|
+
value: string | undefined;
|
|
5
|
+
} | {
|
|
6
|
+
column: string;
|
|
7
|
+
op: 'in' | 'range' | 'isnull' | 'notnull' | 'match';
|
|
8
|
+
};
|
|
9
|
+
export interface Read {
|
|
10
|
+
table: string;
|
|
11
|
+
line: number;
|
|
12
|
+
filters: Filter[];
|
|
13
|
+
/** First `.order()` column: the only one an index can return rows in. */
|
|
14
|
+
orderBy?: string;
|
|
15
|
+
uncertain: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare function findReads(fileName: string, content: string): Read[];
|
|
@@ -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 {};
|