@ultimat3/db 1.2.0 → 2.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/src/observe.ts ADDED
@@ -0,0 +1,90 @@
1
+ // Single responsibility: the seam a diagnostic hangs statements off. One installed observer,
2
+ // process-wide, read through one accessor — so the funnels every statement already passes through
3
+ // pay a single `undefined` check when nothing is installed (axiom 6). Nothing here knows what an
4
+ // entity, a request or a span is: `db` is tier 1 and the detector that consumes this is tier 5.
5
+
6
+ /**
7
+ * What the layer that compiled the statement knows and the driver cannot: which entity and which
8
+ * repository operation. It is the difference between "50× `select … where id = $1`" and "50×
9
+ * `findById` on `members`" in a diagnostic's report.
10
+ *
11
+ * Produced by `@ultimat3/entity`'s `postgresRepo` (tier 2, so importing this is downward): it is
12
+ * the last caller that still knows the entity and the operation by the time the SQL exists, and it
13
+ * declares both through `withStatementAttribution` (`attribution.ts`) around each repository call.
14
+ * Hand-written SQL, a migration, a health probe and the job queue's own statements are unattributed
15
+ * — nothing above them knows an entity to name — which is why the field is optional rather than
16
+ * required, and why a diagnostic must fall back to the statement text.
17
+ */
18
+ export interface StatementAttribution {
19
+ /** Entity name as declared, e.g. `members` — never a table name. */
20
+ readonly entity: string;
21
+ /** Repository operation that compiled the statement, e.g. `findById`. */
22
+ readonly op: string;
23
+ }
24
+
25
+ /** One settled statement. Emitted after it resolved or rejected, never before it was sent. */
26
+ export interface StatementEvent {
27
+ /** The statement as sent, parameters still as `$1..$n`. Safe to log; values are separate. */
28
+ readonly text: string;
29
+ /** Bound parameters, in order. May carry user data — a consumer that logs must redact. */
30
+ readonly values: readonly unknown[];
31
+ /** Wall time from send to settle, from `performance.now()`. */
32
+ readonly durationMs: number;
33
+ /** Rows returned by a read, rows affected by a write, `0` when the statement threw. */
34
+ readonly rows: number;
35
+ /** The rejection, already wrapped as `X_DB_UNAVAILABLE` by the funnel. */
36
+ readonly error?: unknown;
37
+ /**
38
+ * Who compiled this statement, absent when nothing above the SQL knew — see
39
+ * `StatementAttribution`. Stamped by the funnel from the scope open at send time, for the same
40
+ * reason `expected` is: a diagnostic that judges a whole request runs long after every scope in
41
+ * it closed.
42
+ */
43
+ readonly attribution?: StatementAttribution | undefined;
44
+ /**
45
+ * The reason of the innermost `expectedQueryLoop()` this statement was issued inside, absent
46
+ * outside every such scope. Stamped by the funnel at settle time rather than read later, because
47
+ * a diagnostic that judges a whole request at the end of it runs long after the scope closed. A
48
+ * detector counting repeats must not warn about these; everything that only measures — the span,
49
+ * the timeline, a metric — treats them like any other statement.
50
+ */
51
+ readonly expected?: string | undefined;
52
+ }
53
+
54
+ /**
55
+ * A diagnostic. `onStatement` runs synchronously on the caller's stack once the statement has
56
+ * settled, so it must not await anything and must not issue SQL — a statement issued from here
57
+ * re-enters the funnel and observes itself.
58
+ *
59
+ * A throw propagates to whoever ran the statement, deliberately: strict test mode is an observer
60
+ * that fails the test the N+1 happened in, and swallowing here would make that impossible. An
61
+ * observer that only reports must therefore not throw.
62
+ */
63
+ export interface StatementObserver {
64
+ onStatement(event: StatementEvent): void;
65
+ }
66
+
67
+ let installed: StatementObserver | undefined;
68
+
69
+ /**
70
+ * Install the process-wide observer. `setStatementObserver(undefined)` uninstalls, which is the
71
+ * production state and the state every test must leave behind. The `setDbClient` shape, for the
72
+ * same reason: the thing being replaced is ambient, so the seam is a setter and not a parameter
73
+ * threaded through `db()`, `withTransaction()` and both drivers.
74
+ *
75
+ * One observer, not a list — a second registration replaces the first. A fan-out array would make
76
+ * "which diagnostic saw this statement" order-dependent, and the one consumer that needs several
77
+ * (the dev server) composes them itself, in its own order, where that order is reviewable.
78
+ */
79
+ export function setStatementObserver(observer: StatementObserver | undefined): void {
80
+ installed = observer;
81
+ }
82
+
83
+ /**
84
+ * The installed observer, or `undefined` when there is none. Read once per statement and guarded
85
+ * at the call site rather than notified through a wrapper, so an uninstalled seam costs one
86
+ * property read and one branch — no event object is allocated for nobody to receive.
87
+ */
88
+ export function statementObserver(): StatementObserver | undefined {
89
+ return installed;
90
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { cp, mkdir, rm, stat } from 'node:fs/promises';
7
7
  import { basename, dirname, join } from 'node:path';
8
+ import { systemClock } from '@ultimat3/core';
8
9
  import type { BranchInfo } from './branch';
9
10
  import { assertBranchName } from './branch';
10
11
  import { branchExists, dbNotImplemented, dbUnavailable } from './errors';
@@ -77,7 +78,7 @@ export async function branchPglite(
77
78
 
78
79
  return {
79
80
  name: branch,
80
- createdAt: (options.now ?? new Date()).toISOString(),
81
+ createdAt: (options.now ?? systemClock.now()).toISOString(),
81
82
  dataDir: to,
82
83
  sizeBytes: await directorySize(to),
83
84
  };
@@ -5,10 +5,15 @@
5
5
  // gives `withTransaction` and `readOnlyQuery` on a real server.
6
6
 
7
7
  /**
8
- * Gives the connection back. Idempotent for free: it is a settled promise's `resolve`, not a
9
- * counter, so a second call cannot hand out a second turn — the next caller is already awake.
8
+ * Gives the connection back. `release()` is idempotent for free: it is a settled promise's
9
+ * `resolve`, not a counter, so a second call cannot hand out a second turn — the next caller is
10
+ * already awake. `Disposable`, so `using turn = await queue.take()` gives it back on every exit
11
+ * path — the same shape as `DbConnection` in `client.ts`, and `[Symbol.dispose]` is `release()`
12
+ * itself, never a second code path.
10
13
  */
11
- export type Turn = () => void;
14
+ export interface Turn extends Disposable {
15
+ release(): void;
16
+ }
12
17
 
13
18
  export interface TurnQueue {
14
19
  /** Wait for the connection, then keep it until the returned `Turn` is called. */
@@ -35,16 +40,14 @@ export function createTurnQueue(): TurnQueue {
35
40
  // other, not both read the same tail and run at once.
36
41
  tail = mine.then(() => held);
37
42
  await mine;
38
- return release;
43
+ return { release, [Symbol.dispose]: release };
39
44
  }
40
45
 
41
46
  async function run<T>(work: () => Promise<T>): Promise<T> {
42
- const turn = await take();
43
- try {
44
- return await work();
45
- } finally {
46
- turn();
47
- }
47
+ // `using`, not `try`/`finally`: the turn must go back on every exit path, including one a
48
+ // future edit adds above a hand-rolled `finally` that forgot it — see `client.ts`.
49
+ using _turn = await take();
50
+ return await work();
48
51
  }
49
52
 
50
53
  return { take, run };
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 { currentTx } from './transaction';
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
- async function statement(driver: PgliteDriver, fragment: SqlFragment): Promise<PgliteResult> {
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
- if (currentTx() !== undefined) return statement(driver, fragment);
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
- held = false;
182
- turn();
183
- },
252
+ release,
253
+ [Symbol.dispose]: release,
184
254
  };
185
255
  },
186
256
  async ping(): Promise<void> {
@@ -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 { stripSqlNoise } from './readonly';
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. Deliberately
74
- * does not wrap the connection in `readOnly()` either: that guard's regex would refuse our own
75
- * `SET LOCAL` statements, and `BEGIN READ ONLY` is a stronger, Postgres-enforced backstop.
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
- const reserved: DbConnection | undefined = isReservable(client)
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
+ }
@@ -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
+ }