@ultimat3/db 10.0.0 → 11.1.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 CHANGED
@@ -137,6 +137,16 @@ that would start attempt two answers `25P01 ROLLBACK TO SAVEPOINT can only be us
137
137
  blocks`. There is nothing to retry into, and an author who believes they hold a budget they do not
138
138
  is worse off than one who is told.
139
139
 
140
+ **`BEGIN` re-derives its isolation level from the closed set, `As of 2026-08-23`.** `BEGIN` takes
141
+ no parameters, so `beginStatement` is one of the two statements here built as TEXT — and the level
142
+ was `options.isolation.toUpperCase()` spliced into it. The TYPE is not the guard: the value reaches
143
+ `withTransaction` from an app's config, a JSON body or a CLI flag, and
144
+ `{ isolation: 'read committed; drop table x; --' }` became exactly that statement while a
145
+ non-string became an uncoded `TypeError` inside a template literal. `isolationMode` is a `switch`
146
+ over `IsolationLevel` whose `default` arm is `never` — a fourth level with no SQL beside it is a
147
+ type error, and anything else at runtime is `X_SQL_UNSAFE` (`isolationLevelInvalid`), the code
148
+ `branchNameInvalid` already uses for a value spliced into a statement.
149
+
140
150
  **The migration lock is polled, never waited on.** `pg_advisory_lock` blocks with no timeout, so a
141
151
  predecessor OOM-killed on a network partition kept its backend — and the lock — for hours while the
142
152
  new `ROLE=migrate` pod sat inside one statement printing nothing: `helm upgrade --wait` blocked on a
@@ -669,7 +679,13 @@ statement_timeout` set moments earlier, so `select 1; set statement_timeout = 0`
669
679
  while `guards` went on reporting `timeout:5000ms`. `BEGIN READ ONLY` still held, so this was a
670
680
  defeated layer reported as an engaged one rather than a write — and a guard list that lies is worse
671
681
  than a guard list that is short. `statementsOf` is the package's one splitter, so a `;` inside a
672
- literal, a comment or a dollar-quoted body stays data.
682
+ literal, a comment or a dollar-quoted body stays data. **And the splice takes the splitter's
683
+ answer, `As of 2026-08-23`** — `statements[0]`, never the caller's text with a trailing `;` chopped
684
+ off it by a regex. That second answer only saw a `;` at the very END: `select 1; -- note` is one
685
+ statement to the splitter and does not end in `;`, so it reached the `DECLARE` whole and Postgres
686
+ answered `cannot insert multiple commands into a prepared statement`, uncoded, out of the path
687
+ whose whole job is bounding the read. The uncursored path still sends the caller's text
688
+ byte-for-byte, because it splices nothing.
673
689
 
674
690
  `readonly-role.ts` and `readonly-query.ts` are layers 1–2 of that tool's defence-in-depth: a
675
691
  `NOLOGIN` Postgres role (`ensureReadOnlyRole`) and a per-statement `BEGIN READ ONLY` + statement
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/db",
3
- "version": "10.0.0",
3
+ "version": "11.1.0",
4
4
  "description": "Postgres access, transactions, migrations and drift detection",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "10.0.0"
34
+ "@ultimat3/core": "11.1.0"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "@electric-sql/pglite": ">=0.5.0"
package/src/errors.ts CHANGED
@@ -2,7 +2,13 @@
2
2
  // fixes the situation — `X_DB_DRIFT` is the flagship and its rendering is byte-for-byte
3
3
  // pinned by the framework contract, so change its strings only with the contract.
4
4
 
5
- import { registerErrorCodes, renderThrowable, stringField, UltimateError } from '@ultimat3/core';
5
+ import {
6
+ describeValue,
7
+ registerErrorCodes,
8
+ renderThrowable,
9
+ stringField,
10
+ UltimateError,
11
+ } from '@ultimat3/core';
6
12
  import { DESTRUCTIVE_CAUSE, DESTRUCTIVE_MARKER, type DestructiveStatement } from './destructive';
7
13
  import { type DbSqlStateCode, sqlState, sqlStateCode } from './sqlstate';
8
14
 
@@ -384,6 +390,23 @@ export const branchNameInvalid = (branch: string): DbError =>
384
390
  meta: { branch },
385
391
  });
386
392
 
393
+ /**
394
+ * An isolation level that is not one of the three. `X_SQL_UNSAFE` for the reason
395
+ * `branchNameInvalid` uses it: `BEGIN` takes no parameters, so the level is SPLICED into the
396
+ * statement text — `withTransaction(fn, { isolation: fromConfig })` with an operand TypeScript
397
+ * never saw is an injection, not a typo.
398
+ *
399
+ * `describeValue`, never the value: this cause is folded into a problem document and a log line,
400
+ * and an operand that arrived from a request body has no key left to redact once it is baked into
401
+ * a message. The three legal spellings are in the `fix:`, which is what the caller needs.
402
+ */
403
+ export const isolationLevelInvalid = (received: unknown): DbError =>
404
+ new DbError({
405
+ code: 'X_SQL_UNSAFE',
406
+ cause: `an isolation level must be one of 'read committed', 'repeatable read' or 'serializable'; got ${describeValue(received)}`,
407
+ fix: "withTransaction(fn, { isolation: 'serializable' }) # or 'repeatable read', or 'read committed'",
408
+ });
409
+
387
410
  export const dbNotImplemented = (feature: string, fix: string): DbError =>
388
411
  new DbError({
389
412
  code: 'X_NOT_IMPLEMENTED',
@@ -128,7 +128,15 @@ export async function readOnlyQuery<T>(
128
128
  guards.push(`role:${options.role}`);
129
129
  }
130
130
 
131
- const rows = await readRows<T>(connection, statement, fetchCount(options.maxRows), guards);
131
+ // Two texts, deliberately: the caller's own, sent verbatim when nothing is spliced, and the
132
+ // one COMMAND `statementsOf` cut out of it, which is the only text a `DECLARE` may carry.
133
+ const rows = await readRows<T>(
134
+ connection,
135
+ statement,
136
+ statements[0] ?? statement,
137
+ fetchCount(options.maxRows),
138
+ guards,
139
+ );
132
140
  await connection.execute(raw('ROLLBACK'));
133
141
  return { rows, guards };
134
142
  } catch (error) {
@@ -151,16 +159,25 @@ export async function readOnlyQuery<T>(
151
159
  */
152
160
  async function readRows<T>(
153
161
  connection: DbClient,
162
+ /** What the caller wrote. Sent byte-for-byte on the uncursored path, which splices nothing. */
154
163
  statement: string,
164
+ /**
165
+ * The one command `statementsOf` cut out of it — what may be spliced.
166
+ *
167
+ * This was `statement.trim().replace(/;\s*$/, '')`, a second answer to "where does the command
168
+ * end" that only saw a `;` at the very END of the text: `select 1; -- note` is ONE statement to
169
+ * the splitter (a chunk of pure noise is not a statement), so it passed the one-statement gate
170
+ * and reached the splice whole, as `DECLARE … CURSOR FOR select 1; -- note` — two commands, and
171
+ * `cannot insert multiple commands into a prepared statement` out of the driver with no code
172
+ * and no `fix:`. The splitter is the package's one answer, and this is now its only reader.
173
+ */
174
+ command: string,
155
175
  fetch: number | undefined,
156
176
  guards: string[],
157
177
  ): Promise<readonly T[]> {
158
- if (fetch === undefined || !cursorable(statement)) return connection.query<T>(raw(statement));
178
+ if (fetch === undefined || !cursorable(command)) return connection.query<T>(raw(statement));
159
179
 
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.
162
- const query = statement.trim().replace(/;\s*$/, '');
163
- await connection.execute(raw(`DECLARE ${CURSOR_NAME} NO SCROLL CURSOR FOR ${query}`));
180
+ await connection.execute(raw(`DECLARE ${CURSOR_NAME} NO SCROLL CURSOR FOR ${command}`));
164
181
  const rows = await connection.query<T>(raw(`FETCH FORWARD ${fetch} FROM ${CURSOR_NAME}`));
165
182
  guards.push(`fetch:${fetch} rows`);
166
183
  return rows;
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { assert, asyncContext, nanoid } from '@ultimat3/core';
7
7
  import { baseClient, type DbClient, type DbConnection, isReservable } from './client';
8
- import { serializationExhausted } from './errors';
8
+ import { isolationLevelInvalid, serializationExhausted } from './errors';
9
9
  import { raw, type SqlFragment } from './sql';
10
10
  import { isRetryableState } from './sqlstate';
11
11
 
@@ -102,11 +102,36 @@ export function inLiveTx(): boolean {
102
102
  return storage.get()?.live.value === true;
103
103
  }
104
104
 
105
+ /**
106
+ * The SQL for one isolation level, RE-DERIVED from the closed set rather than built out of the
107
+ * value — the same rule `pg-sql.ts` follows for `asc|desc`, and for the same reason: `BEGIN` takes
108
+ * no parameters, so this is one of the two statements here built as text, and a level spliced into
109
+ * it is whatever the caller passed. `isolation` is typed, and a type is not a runtime guard: the
110
+ * value reaches `withTransaction` from an app's config, a JSON body or a CLI flag —
111
+ * `{ isolation: 'read committed; drop table x; --' }` became exactly that statement, and a
112
+ * non-string became an uncoded `TypeError` inside a template literal.
113
+ *
114
+ * The `default` arm is `never`, so a fourth member added to `IsolationLevel` with no SQL beside it
115
+ * is a type error here rather than a refusal at runtime.
116
+ */
117
+ const isolationMode = (declared: IsolationLevel): string => {
118
+ switch (declared) {
119
+ case 'read committed':
120
+ return 'ISOLATION LEVEL READ COMMITTED';
121
+ case 'repeatable read':
122
+ return 'ISOLATION LEVEL REPEATABLE READ';
123
+ case 'serializable':
124
+ return 'ISOLATION LEVEL SERIALIZABLE';
125
+ default: {
126
+ const unhandled: never = declared;
127
+ throw isolationLevelInvalid(unhandled);
128
+ }
129
+ }
130
+ };
131
+
105
132
  export function beginStatement(options: TransactionOptions): string {
106
133
  const modes: string[] = [];
107
- if (options.isolation !== undefined) {
108
- modes.push(`ISOLATION LEVEL ${options.isolation.toUpperCase()}`);
109
- }
134
+ if (options.isolation !== undefined) modes.push(isolationMode(options.isolation));
110
135
  if (options.readOnly === true) modes.push('READ ONLY');
111
136
  if (options.deferrable === true) modes.push('DEFERRABLE');
112
137
  return modes.length === 0 ? 'BEGIN' : `BEGIN ${modes.join(' ')}`;