@ultimat3/db 1.2.0 → 3.0.0
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/CLAUDE.md +618 -0
- package/README.md +159 -21
- package/package.json +3 -2
- package/src/attribution.ts +45 -0
- package/src/branch.ts +21 -5
- package/src/client.ts +259 -31
- package/src/destructive.ts +126 -0
- package/src/drift.ts +263 -14
- package/src/errors.ts +256 -13
- package/src/expected-loop.ts +53 -0
- package/src/fake-pglite.ts +32 -0
- package/src/fake-reservable.ts +50 -0
- package/src/fake.ts +16 -2
- package/src/foreign-key.ts +41 -0
- package/src/generate.ts +192 -39
- package/src/index.ts +38 -11
- package/src/introspect.ts +34 -8
- package/src/libpq-options.ts +76 -0
- package/src/migrate.ts +283 -64
- package/src/observe.ts +90 -0
- package/src/pglite-branch.ts +2 -1
- package/src/pglite-turns.ts +13 -10
- package/src/pglite.ts +85 -15
- package/src/readonly-query.ts +20 -8
- package/src/snapshot-json.ts +84 -0
- package/src/snapshot-parse.ts +99 -0
- package/src/sql-noise.ts +40 -0
- package/src/sql-scan.ts +159 -0
- package/src/sqlstate.ts +107 -0
- package/src/statement-shape.ts +58 -0
- package/src/statement-span.ts +40 -0
- package/src/statement-split.ts +51 -0
- package/src/transaction.ts +138 -16
- package/src/type-pins.ts +29 -0
- package/src/readonly.ts +0 -111
package/src/pglite.ts
CHANGED
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
// The module is resolved at first query and never at import: it is an OPTIONAL peer, and an image
|
|
4
4
|
// that only ever talks to a managed Postgres must not carry 26 MB of WASM it will never load.
|
|
5
5
|
|
|
6
|
+
import { statementAttribution } from './attribution';
|
|
6
7
|
import type { DbConnection, ReservableClient } from './client';
|
|
7
8
|
import { DbError, dbUnavailable } from './errors';
|
|
9
|
+
import { expectedQueryLoopReason } from './expected-loop';
|
|
10
|
+
import { statementObserver } from './observe';
|
|
8
11
|
import { createTurnQueue } from './pglite-turns';
|
|
9
12
|
import type { SqlFragment } from './sql';
|
|
10
|
-
import {
|
|
13
|
+
import { withStatementSpan } from './statement-span';
|
|
14
|
+
import { inLiveTx } from './transaction';
|
|
11
15
|
|
|
12
16
|
/** What PGlite answers with. `rows` is empty for a write, which is why the count is separate. */
|
|
13
17
|
export interface PgliteResult {
|
|
@@ -106,6 +110,17 @@ export interface PgliteClient extends ReservableClient {
|
|
|
106
110
|
close(): Promise<void>;
|
|
107
111
|
}
|
|
108
112
|
|
|
113
|
+
// PGlite counts MODIFIED rows, so a SELECT that returned rows still reports `affectedRows: 0` —
|
|
114
|
+
// `??` would answer 0 for every read and disagree with `PostgresClient.execute`. A write that
|
|
115
|
+
// modified nothing returned no rows either, so falling back to the row count stays 0 there. One
|
|
116
|
+
// definition, shared: `execute()` and the observer's event must not disagree about how many rows a
|
|
117
|
+
// statement accounted for.
|
|
118
|
+
function rowsOf(result: PgliteResult): number {
|
|
119
|
+
return result.affectedRows !== undefined && result.affectedRows > 0
|
|
120
|
+
? result.affectedRows
|
|
121
|
+
: result.rows.length;
|
|
122
|
+
}
|
|
123
|
+
|
|
109
124
|
/** Lazily boots: constructing a client opens nothing, exactly like `createPostgresClient`. */
|
|
110
125
|
export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
111
126
|
// One in-flight boot, shared. PGlite takes seconds to start, so two concurrent first queries
|
|
@@ -122,7 +137,8 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
122
137
|
return booting;
|
|
123
138
|
}
|
|
124
139
|
|
|
125
|
-
|
|
140
|
+
/** The send itself: one statement on the session, every driver failure typed on the way out. */
|
|
141
|
+
async function send(driver: PgliteDriver, fragment: SqlFragment): Promise<PgliteResult> {
|
|
126
142
|
try {
|
|
127
143
|
return await driver.query(fragment.text, fragment.values);
|
|
128
144
|
} catch (error) {
|
|
@@ -130,6 +146,56 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
130
146
|
}
|
|
131
147
|
}
|
|
132
148
|
|
|
149
|
+
/**
|
|
150
|
+
* The funnel — queued, in-transaction and pinned statements all arrive here, so the observer
|
|
151
|
+
* hangs off this one function and nowhere else. Uninstalled it costs one property read and one
|
|
152
|
+
* branch: no clock read, no span, no event object, and `send` receives exactly the call
|
|
153
|
+
* `statement` made before the seam existed (axiom 6). Same shape as `runOn` in `client.ts`, one
|
|
154
|
+
* driver up.
|
|
155
|
+
*/
|
|
156
|
+
async function statement(driver: PgliteDriver, fragment: SqlFragment): Promise<PgliteResult> {
|
|
157
|
+
const observer = statementObserver();
|
|
158
|
+
if (observer === undefined) return send(driver, fragment);
|
|
159
|
+
// Read here for the same reason as `runOn`: the scope is gone by the time a per-request
|
|
160
|
+
// detector judges what it collected, so the reason travels with the statement it defends.
|
|
161
|
+
const expected = expectedQueryLoopReason();
|
|
162
|
+
// And the pair `postgresRepo` left above this frame, for the same reason again: it is what
|
|
163
|
+
// reports a repository loop as "50× findById on members" rather than as fifty rows of SQL.
|
|
164
|
+
const attribution = statementAttribution();
|
|
165
|
+
const started = performance.now();
|
|
166
|
+
let result: PgliteResult;
|
|
167
|
+
try {
|
|
168
|
+
// The span wraps the send and nothing else, so its duration is the statement's and the
|
|
169
|
+
// observer's own work is not charged to the database.
|
|
170
|
+
result = await withStatementSpan(fragment.text, () => send(driver, fragment));
|
|
171
|
+
} catch (error) {
|
|
172
|
+
// The failing path is observed too — the error is already `X_DB_UNAVAILABLE`, and a throw
|
|
173
|
+
// from `onStatement` replaces it, which is why `observe.ts` says a reporting-only observer
|
|
174
|
+
// must not throw.
|
|
175
|
+
observer.onStatement({
|
|
176
|
+
text: fragment.text,
|
|
177
|
+
values: fragment.values,
|
|
178
|
+
durationMs: performance.now() - started,
|
|
179
|
+
rows: 0,
|
|
180
|
+
error,
|
|
181
|
+
attribution,
|
|
182
|
+
expected,
|
|
183
|
+
});
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
// Outside the `try` deliberately: a throw from `onStatement` is the observer's, not the
|
|
187
|
+
// database's, and catching it above would report a statement that succeeded as failed.
|
|
188
|
+
observer.onStatement({
|
|
189
|
+
text: fragment.text,
|
|
190
|
+
values: fragment.values,
|
|
191
|
+
durationMs: performance.now() - started,
|
|
192
|
+
rows: rowsOf(result),
|
|
193
|
+
attribution,
|
|
194
|
+
expected,
|
|
195
|
+
});
|
|
196
|
+
return result;
|
|
197
|
+
}
|
|
198
|
+
|
|
133
199
|
async function run(fragment: SqlFragment): Promise<PgliteResult> {
|
|
134
200
|
const driver = await connect();
|
|
135
201
|
// A statement issued inside an open transaction is already inside it — there is one
|
|
@@ -137,18 +203,17 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
137
203
|
// inside of would hang. `handle.enqueue(input, { outbox: false })` within `withTransaction`
|
|
138
204
|
// is the shape that reaches this line; on a pooled server it would get its own connection,
|
|
139
205
|
// and here it joins the caller's transaction because a second connection does not exist.
|
|
140
|
-
|
|
206
|
+
//
|
|
207
|
+
// The fence is the transaction's LIVENESS, never the ALS store's presence: the store rides
|
|
208
|
+
// into every promise chain started inside `withTransaction`, so a statement the app forgot to
|
|
209
|
+
// `await` still found one after COMMIT, skipped the queue, and landed inside whichever unit of
|
|
210
|
+
// work held the session next — a stray statement in someone else's transaction, committed or
|
|
211
|
+
// rolled back with it, with no error anywhere. A closed scope falls through and takes its own
|
|
212
|
+
// turn, exactly as `client.ts`'s released pin sends a late statement back to the pool.
|
|
213
|
+
if (inLiveTx()) return statement(driver, fragment);
|
|
141
214
|
return turns.run(() => statement(driver, fragment));
|
|
142
215
|
}
|
|
143
216
|
|
|
144
|
-
// PGlite counts MODIFIED rows, so a SELECT that returned rows still reports `affectedRows: 0` —
|
|
145
|
-
// `??` would answer 0 for every read and disagree with `PostgresClient.execute`. A write that
|
|
146
|
-
// modified nothing returned no rows either, so falling back to the row count stays 0 there.
|
|
147
|
-
const rowsOf = (result: PgliteResult): number =>
|
|
148
|
-
result.affectedRows !== undefined && result.affectedRows > 0
|
|
149
|
-
? result.affectedRows
|
|
150
|
-
: result.rows.length;
|
|
151
|
-
|
|
152
217
|
return {
|
|
153
218
|
async query<T>(fragment: SqlFragment): Promise<readonly T[]> {
|
|
154
219
|
return (await run(fragment)).rows as readonly T[];
|
|
@@ -172,15 +237,20 @@ export function createPgliteClient(options: PgliteOptions = {}): PgliteClient {
|
|
|
172
237
|
// statement queues like any other caller and waits for its own turn.
|
|
173
238
|
const on = (fragment: SqlFragment): Promise<PgliteResult> =>
|
|
174
239
|
held ? statement(driver, fragment) : turns.run(() => statement(driver, fragment));
|
|
240
|
+
// Idempotent for free: `turn.release()` is a settled promise's `resolve`, not a counter, so
|
|
241
|
+
// a second call cannot hand out a second turn (`pglite-turns.ts`). `[Symbol.dispose]` below
|
|
242
|
+
// is that same call.
|
|
243
|
+
const release = (): void => {
|
|
244
|
+
held = false;
|
|
245
|
+
turn.release();
|
|
246
|
+
};
|
|
175
247
|
return {
|
|
176
248
|
query: async <T>(fragment: SqlFragment) => (await on(fragment)).rows as readonly T[],
|
|
177
249
|
one: async <T>(fragment: SqlFragment) =>
|
|
178
250
|
((await on(fragment)).rows[0] as T | undefined) ?? null,
|
|
179
251
|
execute: async (fragment: SqlFragment) => rowsOf(await on(fragment)),
|
|
180
|
-
release
|
|
181
|
-
|
|
182
|
-
turn();
|
|
183
|
-
},
|
|
252
|
+
release,
|
|
253
|
+
[Symbol.dispose]: release,
|
|
184
254
|
};
|
|
185
255
|
},
|
|
186
256
|
async ping(): Promise<void> {
|
package/src/readonly-query.ts
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// meant to ask for.
|
|
5
5
|
|
|
6
6
|
import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
|
|
7
|
-
import {
|
|
7
|
+
import { multipleStatements } from './errors';
|
|
8
8
|
import { identifier, raw, sql } from './sql';
|
|
9
|
+
import { stripSqlNoise } from './sql-noise';
|
|
10
|
+
import { statementsOf } from './statement-split';
|
|
9
11
|
|
|
10
12
|
/** Default per-statement ceiling for an agent-authored read. */
|
|
11
13
|
export const READONLY_TIMEOUT_MS = 5_000;
|
|
@@ -70,18 +72,29 @@ function cursorable(statement: string): boolean {
|
|
|
70
72
|
* to keep right.
|
|
71
73
|
*
|
|
72
74
|
* Deliberately does not use `withTransaction`: it nests into an ambient transaction with a
|
|
73
|
-
* `SAVEPOINT`, and a savepoint inside a read-write transaction is not read-only.
|
|
74
|
-
*
|
|
75
|
-
*
|
|
75
|
+
* `SAVEPOINT`, and a savepoint inside a read-write transaction is not read-only. It runs no
|
|
76
|
+
* mutating-keyword scan of its own either — `BEGIN READ ONLY` is the server refusing the write,
|
|
77
|
+
* which is stronger than any regex, and this package deliberately ships no second answer to "is
|
|
78
|
+
* this SQL a write?" (`@ultimat3/mcp`'s parse guard is layer 3, and it is the only one).
|
|
76
79
|
*/
|
|
77
80
|
export async function readOnlyQuery<T>(
|
|
78
81
|
statement: string,
|
|
79
82
|
options: ReadOnlyQueryOptions = {},
|
|
80
83
|
): Promise<ReadOnlyQueryResult<T>> {
|
|
84
|
+
// ONE statement, decided before anything is opened. Not a second mutating-keyword scan — a
|
|
85
|
+
// different question, and the one the guards below depend on: only the first command of a text
|
|
86
|
+
// is bounded by them, so `select 1; set statement_timeout = 0` undid the timeout this function
|
|
87
|
+
// had just installed while `guards` went on reporting `timeout:5000ms`. `statementsOf` is the
|
|
88
|
+
// package's one splitter, so a `;` inside a literal, a comment or a dollar-quoted body is data.
|
|
89
|
+
const statements = statementsOf(statement);
|
|
90
|
+
if (statements.length > 1) throw multipleStatements(statement, statements.length);
|
|
91
|
+
|
|
81
92
|
const client = options.client ?? baseClient();
|
|
82
93
|
// A pooled BEGIN that lands on a different physical connection than the query that follows is
|
|
83
94
|
// not a transaction at all, so a reservable client must pin one connection for the sequence.
|
|
84
|
-
|
|
95
|
+
// Held by a `using` declaration, the same shape as `withTransaction` — the pin comes back on
|
|
96
|
+
// every exit path, and no future edit can move a statement above the guard that returns it.
|
|
97
|
+
using reserved: DbConnection | undefined = isReservable(client)
|
|
85
98
|
? await client.reserve()
|
|
86
99
|
: undefined;
|
|
87
100
|
const connection: DbClient = reserved ?? client;
|
|
@@ -122,8 +135,6 @@ export async function readOnlyQuery<T>(
|
|
|
122
135
|
// Best-effort: the caller needs the original error, never the rollback's.
|
|
123
136
|
await connection.execute(raw('ROLLBACK')).catch(() => undefined);
|
|
124
137
|
throw error;
|
|
125
|
-
} finally {
|
|
126
|
-
reserved?.release();
|
|
127
138
|
}
|
|
128
139
|
}
|
|
129
140
|
|
|
@@ -146,7 +157,8 @@ async function readRows<T>(
|
|
|
146
157
|
): Promise<readonly T[]> {
|
|
147
158
|
if (fetch === undefined || !cursorable(statement)) return connection.query<T>(raw(statement));
|
|
148
159
|
|
|
149
|
-
// A trailing `;` would close `DECLARE` before its query and turn one statement into two.
|
|
160
|
+
// A trailing `;` would close `DECLARE` before its query and turn one statement into two. An
|
|
161
|
+
// EMBEDDED one is refused up in `readOnlyQuery`, before the transaction opens.
|
|
150
162
|
const query = statement.trim().replace(/;\s*$/, '');
|
|
151
163
|
await connection.execute(raw(`DECLARE ${CURSOR_NAME} NO SCROLL CURSOR FOR ${query}`));
|
|
152
164
|
const rows = await connection.query<T>(raw(`FETCH FORWARD ${fetch} FROM ${CURSOR_NAME}`));
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Single responsibility: serialise a `SchemaDescription` as the JSON Biome would have printed.
|
|
2
|
+
// `x db gen` writes this file into an app whose `lint` step is `biome check .`, so a serialiser
|
|
3
|
+
// that is not a fixed point of the formatter is a framework that fails its own gate — which
|
|
4
|
+
// `JSON.stringify(…, null, 2)` was, on every snapshot carrying a one-element array.
|
|
5
|
+
|
|
6
|
+
import type { SchemaDescription } from './introspect';
|
|
7
|
+
|
|
8
|
+
/** What `x new` writes into the scaffold's `biome.json`, and what this repo's own config sets. */
|
|
9
|
+
const LINE_WIDTH = 100;
|
|
10
|
+
const INDENT = 2;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Biome's two JSON rules, measured against 2.5.5 and the whole of this module:
|
|
14
|
+
*
|
|
15
|
+
* - an **object** keeps the source's shape — a line break after `{` stays broken, and `{}` stays
|
|
16
|
+
* inline — so emitting every non-empty object broken is stable by construction;
|
|
17
|
+
* - an **array** collapses onto one line when every element is already on one line and the whole
|
|
18
|
+
* line fits, *counting the trailing comma*, at `<= LINE_WIDTH`.
|
|
19
|
+
*
|
|
20
|
+
* So the only arithmetic is the array's, and the only thing that can force an array open is a
|
|
21
|
+
* non-empty object inside it.
|
|
22
|
+
*/
|
|
23
|
+
function expands(value: unknown): boolean {
|
|
24
|
+
if (Array.isArray(value)) return value.some(expands);
|
|
25
|
+
if (typeof value === 'object' && value !== null) return Object.keys(value).length > 0;
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function inline(value: unknown): string {
|
|
30
|
+
if (Array.isArray(value)) return `[${value.map(inline).join(', ')}]`;
|
|
31
|
+
if (typeof value === 'object' && value !== null) {
|
|
32
|
+
const entries = Object.entries(value);
|
|
33
|
+
if (entries.length === 0) return '{}';
|
|
34
|
+
const members = entries.map(([key, each]) => `${JSON.stringify(key)}: ${inline(each)}`);
|
|
35
|
+
return `{ ${members.join(', ')} }`;
|
|
36
|
+
}
|
|
37
|
+
return JSON.stringify(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `column` is what the line already holds before this value; `trailing` is what follows it on the
|
|
42
|
+
* same line — 1 for the comma of a member that is not the last. Both exist only for the array rule.
|
|
43
|
+
*/
|
|
44
|
+
function print(value: unknown, depth: number, column: number, trailing: number): string {
|
|
45
|
+
const pad = ' '.repeat(depth * INDENT);
|
|
46
|
+
const inner = ' '.repeat((depth + 1) * INDENT);
|
|
47
|
+
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
if (value.length === 0) return '[]';
|
|
50
|
+
const one = inline(value);
|
|
51
|
+
if (!expands(value) && column + one.length + trailing <= LINE_WIDTH) return one;
|
|
52
|
+
const items = value.map((each, index) => {
|
|
53
|
+
const comma = index === value.length - 1 ? '' : ',';
|
|
54
|
+
return `${inner}${print(each, depth + 1, inner.length, comma.length)}${comma}`;
|
|
55
|
+
});
|
|
56
|
+
return `[\n${items.join('\n')}\n${pad}]`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (typeof value === 'object' && value !== null) {
|
|
60
|
+
const entries = Object.entries(value);
|
|
61
|
+
if (entries.length === 0) return '{}';
|
|
62
|
+
const members = entries.map(([key, each], index) => {
|
|
63
|
+
const label = `${JSON.stringify(key)}: `;
|
|
64
|
+
const comma = index === entries.length - 1 ? '' : ',';
|
|
65
|
+
const printed = print(each, depth + 1, inner.length + label.length, comma.length);
|
|
66
|
+
return `${inner}${label}${printed}${comma}`;
|
|
67
|
+
});
|
|
68
|
+
return `{\n${members.join('\n')}\n${pad}}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return JSON.stringify(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The sidecar's bytes, trailing newline included.
|
|
76
|
+
*
|
|
77
|
+
* Round-tripped through `JSON.parse(JSON.stringify(…))` first so the printer walks exactly the
|
|
78
|
+
* value the reader will parse back: an `undefined` field disappears here rather than reaching a
|
|
79
|
+
* printer that has no spelling for it.
|
|
80
|
+
*/
|
|
81
|
+
export function snapshotJson(snapshot: SchemaDescription): string {
|
|
82
|
+
const plain: unknown = JSON.parse(JSON.stringify(snapshot));
|
|
83
|
+
return `${print(plain, 0, 0, 0)}\n`;
|
|
84
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Single responsibility: turn the JSON of a `<id>.snapshot.json` sidecar into a `SchemaDescription`
|
|
2
|
+
// or into nothing. It lives beside the type it validates, because "what a valid snapshot is" is
|
|
3
|
+
// this package's answer and the reader on disk is `@ultimat3/cli`'s — two owners of that question
|
|
4
|
+
// is a sidecar one of them accepts and the other cannot use.
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
ColumnDescription,
|
|
8
|
+
ForeignKeyDescription,
|
|
9
|
+
IndexDescription,
|
|
10
|
+
SchemaDescription,
|
|
11
|
+
TableDescription,
|
|
12
|
+
} from './introspect';
|
|
13
|
+
|
|
14
|
+
type Row = Record<string, unknown>;
|
|
15
|
+
|
|
16
|
+
const isRow = (value: unknown): value is Row =>
|
|
17
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
|
|
19
|
+
const str = (value: unknown): value is string => typeof value === 'string';
|
|
20
|
+
const bool = (value: unknown): value is boolean => typeof value === 'boolean';
|
|
21
|
+
const nullableStr = (value: unknown): value is string | null => value === null || str(value);
|
|
22
|
+
const strings = (value: unknown): value is readonly string[] =>
|
|
23
|
+
Array.isArray(value) && value.every(str);
|
|
24
|
+
|
|
25
|
+
/** `null` is Postgres' own default; anything else must be one of the two directions. */
|
|
26
|
+
const order = (value: unknown): value is 'asc' | 'desc' | null =>
|
|
27
|
+
value === null || value === 'asc' || value === 'desc';
|
|
28
|
+
|
|
29
|
+
function column(value: unknown): ColumnDescription | undefined {
|
|
30
|
+
if (!isRow(value)) return undefined;
|
|
31
|
+
const { name, dataType, nullable, default: fallback, position } = value;
|
|
32
|
+
if (!str(name) || !str(dataType) || !bool(nullable) || !nullableStr(fallback)) return undefined;
|
|
33
|
+
if (typeof position !== 'number') return undefined;
|
|
34
|
+
return { name, dataType, nullable, default: fallback, position };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function index(value: unknown): IndexDescription | undefined {
|
|
38
|
+
if (!isRow(value)) return undefined;
|
|
39
|
+
const { name, columns, unique, primary, where, order: direction } = value;
|
|
40
|
+
if (!str(name) || !strings(columns) || !bool(unique) || !bool(primary)) return undefined;
|
|
41
|
+
// Written by 1.2.0 onwards. A sidecar from before it carries neither, and the total, ascending
|
|
42
|
+
// reading is what that generation actually emitted — so an older file stays readable rather
|
|
43
|
+
// than being discarded whole, which would refuse to generate against every existing app.
|
|
44
|
+
if (!(where === undefined || nullableStr(where))) return undefined;
|
|
45
|
+
if (!(direction === undefined || order(direction))) return undefined;
|
|
46
|
+
return {
|
|
47
|
+
name,
|
|
48
|
+
columns,
|
|
49
|
+
unique,
|
|
50
|
+
primary,
|
|
51
|
+
where: where === undefined ? null : where,
|
|
52
|
+
order: direction === undefined ? null : direction,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function foreignKey(value: unknown): ForeignKeyDescription | undefined {
|
|
57
|
+
if (!isRow(value)) return undefined;
|
|
58
|
+
const { name, columns, referencedTable, referencedColumns, onDelete } = value;
|
|
59
|
+
if (!str(name) || !strings(columns) || !str(referencedTable)) return undefined;
|
|
60
|
+
if (!strings(referencedColumns) || !nullableStr(onDelete)) return undefined;
|
|
61
|
+
return { name, columns, referencedTable, referencedColumns, onDelete };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `undefined` from any member rejects the whole list — a partial table is not a smaller one. */
|
|
65
|
+
function all<T>(value: unknown, one: (item: unknown) => T | undefined): readonly T[] | undefined {
|
|
66
|
+
if (!Array.isArray(value)) return undefined;
|
|
67
|
+
const parsed: T[] = [];
|
|
68
|
+
for (const item of value) {
|
|
69
|
+
const each = one(item);
|
|
70
|
+
if (each === undefined) return undefined;
|
|
71
|
+
parsed.push(each);
|
|
72
|
+
}
|
|
73
|
+
return parsed;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function tableOf(value: unknown): TableDescription | undefined {
|
|
77
|
+
if (!isRow(value)) return undefined;
|
|
78
|
+
const { schema, name, primaryKey } = value;
|
|
79
|
+
if (!str(schema) || !str(name) || !strings(primaryKey)) return undefined;
|
|
80
|
+
const columns = all(value['columns'], column);
|
|
81
|
+
const indexes = all(value['indexes'], index);
|
|
82
|
+
const foreignKeys = all(value['foreignKeys'], foreignKey);
|
|
83
|
+
if (columns === undefined || indexes === undefined || foreignKeys === undefined) return undefined;
|
|
84
|
+
return { schema, name, columns, primaryKey, indexes, foreignKeys };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The snapshot `value` describes, or `undefined` if it describes anything else.
|
|
89
|
+
*
|
|
90
|
+
* Every nested field is parsed, not asserted: `{"tables":[null]}` is syntactically valid JSON that
|
|
91
|
+
* an `Array.isArray(tables)` check accepted and a cast then called a `SchemaDescription`, so the
|
|
92
|
+
* first `table.columns` in the diff threw on a truncated or hand-edited sidecar rather than
|
|
93
|
+
* regenerating it. A file that will not parse is *absent* — which the caller already handles.
|
|
94
|
+
*/
|
|
95
|
+
export function parseSnapshot(value: unknown): SchemaDescription | undefined {
|
|
96
|
+
if (!isRow(value)) return undefined;
|
|
97
|
+
const tables = all(value['tables'], tableOf);
|
|
98
|
+
return tables === undefined ? undefined : { tables };
|
|
99
|
+
}
|
package/src/sql-noise.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Single responsibility: blank everything a keyword could legitimately hide inside — comments,
|
|
2
|
+
// literals, quoted identifiers, dollar-quoted bodies — so a reader judging SQL text sees the
|
|
3
|
+
// operation and not the prose around it. Two readers share it (`readOnlyQuery`'s cursorable check,
|
|
4
|
+
// the destructive rail), and one wrong answer here is a wrong answer in both.
|
|
5
|
+
|
|
6
|
+
import { noiseAt } from './sql-scan';
|
|
7
|
+
|
|
8
|
+
/** What each blanked span leaves behind. A quote pair stays a token; a comment becomes a gap. */
|
|
9
|
+
const BLANK: Readonly<Record<string, string>> = {
|
|
10
|
+
'line-comment': ' ',
|
|
11
|
+
'block-comment': ' ',
|
|
12
|
+
'dollar-body': ' ',
|
|
13
|
+
string: " '' ",
|
|
14
|
+
identifier: ' "" ',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Comments, literals, quoted identifiers and dollar-quoted bodies, each replaced by whitespace or
|
|
19
|
+
* an empty quote pair.
|
|
20
|
+
*
|
|
21
|
+
* Scanned in source order rather than by a sequence of replacements, and that ordering is the
|
|
22
|
+
* whole guard: blanking comments first reads the `--` in `select '--'; delete from posts` as a
|
|
23
|
+
* comment and erases the `delete` with it, so anything reading the blanked text sees a SELECT
|
|
24
|
+
* where a mutating statement was. `sql-noise.test.ts` pins that case and the four beside it.
|
|
25
|
+
*/
|
|
26
|
+
export function stripSqlNoise(text: string): string {
|
|
27
|
+
let out = '';
|
|
28
|
+
let index = 0;
|
|
29
|
+
while (index < text.length) {
|
|
30
|
+
const noise = noiseAt(text, index);
|
|
31
|
+
if (noise === null) {
|
|
32
|
+
out += text[index];
|
|
33
|
+
index += 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
out += BLANK[noise.kind] ?? ' ';
|
|
37
|
+
index = noise.end;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
package/src/sql-scan.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Single responsibility: name the span of SQL text starting at one offset — a comment, a literal,
|
|
2
|
+
// a quoted identifier, a dollar-quoted body, or none of them. One scanner, because a splitter that
|
|
3
|
+
// disagreed with a guard about where a literal ends is a `;` sent as data or a `delete` read as
|
|
4
|
+
// prose, and the two answers must be the same answer.
|
|
5
|
+
|
|
6
|
+
const IDENTIFIER_START = /[A-Za-z_]/;
|
|
7
|
+
export const IDENTIFIER_PART = /[A-Za-z0-9_]/;
|
|
8
|
+
/** `$` is legal in an identifier after the first character — `a$b` is one name, not three. */
|
|
9
|
+
const IDENTIFIER_TAIL = /[A-Za-z0-9_$]/;
|
|
10
|
+
|
|
11
|
+
export type NoiseKind = 'line-comment' | 'block-comment' | 'string' | 'identifier' | 'dollar-body';
|
|
12
|
+
|
|
13
|
+
/** A span that is not code: what it is, and the offset just past it. */
|
|
14
|
+
export interface NoiseSpan {
|
|
15
|
+
readonly kind: NoiseKind;
|
|
16
|
+
readonly end: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Past a `--` comment, including the newline that ends it. */
|
|
20
|
+
function skipLineComment(script: string, index: number): number {
|
|
21
|
+
const newline = script.indexOf('\n', index);
|
|
22
|
+
return newline === -1 ? script.length : newline + 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Past a block comment. Postgres **nests** them, so the depth is counted rather than matched to
|
|
27
|
+
* the first terminator — a commented-out block that itself contains a comment closes once, and
|
|
28
|
+
* every `;` after that point would otherwise be read as data.
|
|
29
|
+
*/
|
|
30
|
+
function skipBlockComment(script: string, index: number): number {
|
|
31
|
+
let depth = 0;
|
|
32
|
+
let at = index;
|
|
33
|
+
while (at < script.length) {
|
|
34
|
+
const char = script[at];
|
|
35
|
+
const next = script[at + 1];
|
|
36
|
+
if (char === '/' && next === '*') {
|
|
37
|
+
depth += 1;
|
|
38
|
+
at += 2;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (char === '*' && next === '/') {
|
|
42
|
+
depth -= 1;
|
|
43
|
+
at += 2;
|
|
44
|
+
if (depth === 0) return at;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
at += 1;
|
|
48
|
+
}
|
|
49
|
+
return script.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Past a run closing on `quote`, where a doubled quote is an escaped one — `'it''s'` and
|
|
54
|
+
* `"a""b"` are each one token. `escapes` is the `E''` dialect, the only one where a backslash
|
|
55
|
+
* escapes the character after it; a standard-conforming string carries it as data.
|
|
56
|
+
*/
|
|
57
|
+
function skipQuoted(script: string, index: number, quote: string, escapes: boolean): number {
|
|
58
|
+
let at = index + 1;
|
|
59
|
+
while (at < script.length) {
|
|
60
|
+
const char = script[at];
|
|
61
|
+
if (escapes && char === '\\') {
|
|
62
|
+
at += 2;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (char === quote) {
|
|
66
|
+
if (script[at + 1] === quote) {
|
|
67
|
+
at += 2;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
return at + 1;
|
|
71
|
+
}
|
|
72
|
+
at += 1;
|
|
73
|
+
}
|
|
74
|
+
return script.length;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether the `$` at `index` continues an identifier instead of opening a delimiter.
|
|
79
|
+
*
|
|
80
|
+
* The run before it is walked to its start rather than one character being read, because the
|
|
81
|
+
* answer is what that run *began* as: `foo$tag$` is the single identifier Postgres reads it as
|
|
82
|
+
* (`$` is legal after the first character), while `$1$tag$` is a bound parameter followed by a
|
|
83
|
+
* real delimiter — a run opening with a digit or a `$` cannot be an identifier at all.
|
|
84
|
+
*/
|
|
85
|
+
function insideIdentifier(script: string, index: number): boolean {
|
|
86
|
+
let at = index - 1;
|
|
87
|
+
while (at >= 0 && IDENTIFIER_TAIL.test(script[at] ?? '')) at -= 1;
|
|
88
|
+
const first = script[at + 1];
|
|
89
|
+
return at + 1 < index && first !== undefined && IDENTIFIER_START.test(first);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The `$tag$` opening a dollar-quoted body at `index`, or `null`. A tag is an identifier or
|
|
94
|
+
* empty, which is what keeps a bound parameter out: `$1` cannot open a body, so `where "id" = $1`
|
|
95
|
+
* never swallows the rest of the script.
|
|
96
|
+
*
|
|
97
|
+
* A delimiter also needs separating from the identifier before it, or `select foo$tag$; select
|
|
98
|
+
* 2;` is one statement to us and two to the server — which answers `cannot insert multiple
|
|
99
|
+
* commands into a prepared statement`.
|
|
100
|
+
*/
|
|
101
|
+
export function dollarTagAt(script: string, index: number): string | null {
|
|
102
|
+
if (script[index] !== '$') return null;
|
|
103
|
+
if (insideIdentifier(script, index)) return null;
|
|
104
|
+
let at = index + 1;
|
|
105
|
+
while (at < script.length) {
|
|
106
|
+
const char = script[at] ?? '';
|
|
107
|
+
const valid = at === index + 1 ? IDENTIFIER_START.test(char) : IDENTIFIER_PART.test(char);
|
|
108
|
+
if (!valid) break;
|
|
109
|
+
at += 1;
|
|
110
|
+
}
|
|
111
|
+
return script[at] === '$' ? script.slice(index, at + 1) : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Past the body `tag` opened, up to and including the matching close. */
|
|
115
|
+
function skipDollarBody(script: string, index: number, tag: string): number {
|
|
116
|
+
const close = script.indexOf(tag, index + tag.length);
|
|
117
|
+
return close === -1 ? script.length : close + tag.length;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Whether the `'` at `index` opens an `E''` string. The prefix is a whole token, so a trailing
|
|
122
|
+
* `e` on an identifier does not turn the literal beside it into an escape string.
|
|
123
|
+
*/
|
|
124
|
+
function escapesAt(script: string, index: number): boolean {
|
|
125
|
+
const prefix = script[index - 1];
|
|
126
|
+
if (prefix !== 'E' && prefix !== 'e') return false;
|
|
127
|
+
const before = script[index - 2];
|
|
128
|
+
return before === undefined || !IDENTIFIER_PART.test(before);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The non-code span starting at `index`, or `null` when `index` is code.
|
|
133
|
+
*
|
|
134
|
+
* Source order is the whole point: a caller that asked "is there a comment anywhere" before
|
|
135
|
+
* "where do the literals end" reads the `--` in `select '--'; delete from posts` as a comment and
|
|
136
|
+
* erases a live statement. Walking forward one span at a time cannot make that mistake, because
|
|
137
|
+
* by the time the `--` is reached it is already inside the literal that was scanned first.
|
|
138
|
+
*
|
|
139
|
+
* A span left unterminated ends at the end of the text rather than being refused: Postgres names
|
|
140
|
+
* that syntax error precisely, and a second SQL parser competing with it would only report the
|
|
141
|
+
* same fault in worse words.
|
|
142
|
+
*/
|
|
143
|
+
export function noiseAt(script: string, index: number): NoiseSpan | null {
|
|
144
|
+
const char = script[index];
|
|
145
|
+
if (char === '-' && script[index + 1] === '-') {
|
|
146
|
+
return { kind: 'line-comment', end: skipLineComment(script, index) };
|
|
147
|
+
}
|
|
148
|
+
if (char === '/' && script[index + 1] === '*') {
|
|
149
|
+
return { kind: 'block-comment', end: skipBlockComment(script, index) };
|
|
150
|
+
}
|
|
151
|
+
if (char === "'") {
|
|
152
|
+
return { kind: 'string', end: skipQuoted(script, index, char, escapesAt(script, index)) };
|
|
153
|
+
}
|
|
154
|
+
if (char === '"') {
|
|
155
|
+
return { kind: 'identifier', end: skipQuoted(script, index, char, false) };
|
|
156
|
+
}
|
|
157
|
+
const tag = dollarTagAt(script, index);
|
|
158
|
+
return tag === null ? null : { kind: 'dollar-body', end: skipDollarBody(script, index, tag) };
|
|
159
|
+
}
|