@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/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
+ }
@@ -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
+ }