@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 +17 -1
- package/package.json +2 -2
- package/src/errors.ts +24 -1
- package/src/readonly-query.ts +23 -6
- package/src/transaction.ts +29 -4
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": "
|
|
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": "
|
|
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 {
|
|
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',
|
package/src/readonly-query.ts
CHANGED
|
@@ -128,7 +128,15 @@ export async function readOnlyQuery<T>(
|
|
|
128
128
|
guards.push(`role:${options.role}`);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
|
|
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(
|
|
178
|
+
if (fetch === undefined || !cursorable(command)) return connection.query<T>(raw(statement));
|
|
159
179
|
|
|
160
|
-
|
|
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;
|
package/src/transaction.ts
CHANGED
|
@@ -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(' ')}`;
|