@ultimat3/db 25.0.0 → 25.2.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 +2 -2
- package/README.md +3 -1
- package/package.json +2 -2
- package/src/db-executor.ts +24 -0
- package/src/dependent-view.ts +5 -5
- package/src/index.ts +2 -0
- package/src/retype-dependents.ts +3 -3
- package/src/sql-scan.ts +37 -12
package/CLAUDE.md
CHANGED
|
@@ -100,8 +100,8 @@ consults `currentTx()`; `withTransaction` uses `baseClient()`, never `db()`. Kee
|
|
|
100
100
|
- **`expected-loop.ts` is the ONLY suppression**: `expectedQueryLoop(reason, fn)`, innermost reason,
|
|
101
101
|
blank is `X_INVARIANT`; the funnel stamps `expected`; it suppresses a verdict, never a statement.
|
|
102
102
|
The framework's own loops declare themselves (`migrate()`, `rollback()`, admin's `search.ts`).
|
|
103
|
-
- `@ultimat3/jobs` never imports this package; its statements pass the observer
|
|
104
|
-
`
|
|
103
|
+
- `@ultimat3/jobs` never imports this package; its statements pass the observer through
|
|
104
|
+
`dbExecutor` (`db-executor.ts`, the ONE `PgExecutor` builder; the CLI binds it), unattributed.
|
|
105
105
|
|
|
106
106
|
## Migrations
|
|
107
107
|
|
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ await withTransaction(async (tx) => {
|
|
|
28
28
|
| `sql` / `raw` / `identifier` / `literal` / `join` | fragment builders |
|
|
29
29
|
| `shellInertIdentifier()` | `As of 2026-08-26`: a quoted identifier that is also inert wherever a human PASTES it — or `null`. The one screen a catalog name goes through before it reaches a `fix:`. `identifier()` answers about SQL and **accepts** a backtick and a `$`, which are exactly what a shell substitutes inside double quotes, so a column called `$(id)` inside `x db gen "add $(id)"` runs `id` on paste |
|
|
30
30
|
| `db()` / `baseClient()` / `setDbClient()` | the ambient client; `db()` returns the open tx if any |
|
|
31
|
+
| `dbExecutor(client = db)` | the client as `@ultimat3/core`'s `PgExecutor` (`query(text, values)`) — what a jobs, idempotency, notify or MCP confirmation store takes. The client is resolved per statement: the default reaches the boot's client even when declared at module scope, and joins an open `withTransaction`; `() => pool` pins one pool whatever transaction is open |
|
|
31
32
|
| `DbTx.origin` | `As of 2026-08`: the client the transaction was **opened on** — `options.client` or `baseClient()`, never the reservation it runs statements through. `@ultimat3/entity` compares a pinned repository's client against it, so a pinned repo joins its own shard's transaction instead of being refused |
|
|
32
33
|
| `withTransaction()` / `currentTx()` | transaction scope; `currentTx()` is the outbox seam. `{ retry: n }` (`As of 2026-08`) re-runs `fn` from the top on a `40001`/`40P01` and on nothing else — default 0, so `fn` must be idempotent before you ask for it. Each re-run **waits first**, `As of 2026-08-23`: exponential from 10ms, capped at 500ms, full jitter (`@ultimat3/core`'s `backoffDelay`). A budget of 0 waits not at all. **A transaction the server aborted is reported as one**, `As of 2026-10-02` → [Transactions that end badly](#transactions-that-end-badly) |
|
|
33
34
|
| `liveTxConnection()` | the connection of the transaction still **open** on this async context, or `undefined`. `currentTx()` keeps answering a finished scope's handle to a promise chain the body forgot to await; this is the one that says whether a statement sent now is really inside a transaction. `@ultimat3/action` reads it before binding an idempotency settlement to a commit |
|
|
@@ -112,7 +113,8 @@ the transaction untouched.
|
|
|
112
113
|
`As of 2026-07`: a bug in one defence must not become a write, so `db.query` on the MCP dev
|
|
113
114
|
server stacks independent layers rather than trusting a single gate. This package owns the two
|
|
114
115
|
layers that are Postgres facts rather than MCP facts — the tool-boundary layers (pre-parse scan,
|
|
115
|
-
policy) live above it, and `@ultimat3/mcp`
|
|
116
|
+
policy) live above it, and `@ultimat3/mcp` imports nothing of this package but one lexer rule,
|
|
117
|
+
`endOfBlockComment` (a nested `/* */` ends where Postgres ends it, `null` when it never closes).
|
|
116
118
|
|
|
117
119
|
```ts
|
|
118
120
|
import { ensureReadOnlyRole, readOnlyQuery } from '@ultimat3/db';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/db",
|
|
3
|
-
"version": "25.
|
|
3
|
+
"version": "25.2.0",
|
|
4
4
|
"description": "Postgres access, transactions, migrations and drift detection",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"test": "bun test"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@ultimat3/core": "25.
|
|
36
|
+
"@ultimat3/core": "25.2.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@electric-sql/pglite": ">=0.5.0"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Single responsibility: a `DbClient` as `@ultimat3/core`'s structural `PgExecutor` — the
|
|
2
|
+
// `query(text, values)` seam the jobs queue, the outbox, the idempotency, notify and MCP
|
|
3
|
+
// confirmation stores take. The ONE builder: the boot and an app both build theirs here.
|
|
4
|
+
|
|
5
|
+
import type { PgExecutor } from '@ultimat3/core';
|
|
6
|
+
import { type DbClient, db } from './client';
|
|
7
|
+
import type { SqlFragment } from './sql';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The client is resolved per STATEMENT, never at the call. Default `db`: the installed client, so
|
|
11
|
+
* an executor declared at module scope — before boot installs one — still reaches the boot's, and
|
|
12
|
+
* inside `withTransaction` the transaction's own connection, so a store's row commits or rolls
|
|
13
|
+
* back with the business rows. Pass `() => client` for a store that must stay on one pool whatever
|
|
14
|
+
* transaction is open (the boot's queue and idempotency reservations).
|
|
15
|
+
*
|
|
16
|
+
* The fragment is assembled by hand rather than through `sql`` `: a store hands over `$1..$n`
|
|
17
|
+
* text it wrote itself plus already-bound values, so there is nothing to interpolate or guard.
|
|
18
|
+
*/
|
|
19
|
+
export function dbExecutor(client: () => DbClient = db): PgExecutor {
|
|
20
|
+
return {
|
|
21
|
+
query: <R>(text: string, values: readonly unknown[]): Promise<readonly R[]> =>
|
|
22
|
+
client().query<R>({ text, values } satisfies SqlFragment),
|
|
23
|
+
};
|
|
24
|
+
}
|
package/src/dependent-view.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import type { DbClient } from './client';
|
|
22
22
|
import { migrationViewDepends } from './migration-errors';
|
|
23
23
|
import { identifier, join, sql } from './sql';
|
|
24
|
-
import {
|
|
24
|
+
import { foldIdentifier, IDENTIFIER_CHAR, noiseAt } from './sql-scan';
|
|
25
25
|
import { statementsOf } from './statement-split';
|
|
26
26
|
|
|
27
27
|
/** One `alter table <table> alter column <column> type …`, as the catalog spells both names. */
|
|
@@ -44,7 +44,7 @@ interface SqlWord {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* The names in one statement, in order, folded the way Postgres folds them: an unquoted identifier
|
|
47
|
-
* to lower case, a quoted one verbatim. Comments, string literals and dollar-quoted bodies
|
|
47
|
+
* to lower case in ASCII only (`foldIdentifier`), a quoted one verbatim. Comments, string literals and dollar-quoted bodies
|
|
48
48
|
* contribute nothing, through this package's one lexer — `-- alter column` is prose and
|
|
49
49
|
* `'alter column'` is data.
|
|
50
50
|
*/
|
|
@@ -60,13 +60,13 @@ function wordsOf(statement: string): readonly SqlWord[] {
|
|
|
60
60
|
at = noise.end;
|
|
61
61
|
continue;
|
|
62
62
|
}
|
|
63
|
-
if (!
|
|
63
|
+
if (!IDENTIFIER_CHAR.test(statement[at] ?? '')) {
|
|
64
64
|
at += 1;
|
|
65
65
|
continue;
|
|
66
66
|
}
|
|
67
67
|
let end = at;
|
|
68
|
-
while (end < statement.length &&
|
|
69
|
-
words.push({ text: statement.slice(at, end)
|
|
68
|
+
while (end < statement.length && IDENTIFIER_CHAR.test(statement[end] ?? '')) end += 1;
|
|
69
|
+
words.push({ text: foldIdentifier(statement.slice(at, end)), quoted: false });
|
|
70
70
|
at = end;
|
|
71
71
|
}
|
|
72
72
|
return words;
|
package/src/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ export type {
|
|
|
30
30
|
export { baseClient, db, isReservable, postgresClient, setDbClient } from './client';
|
|
31
31
|
export type { ColumnDefaultLike } from './column-default';
|
|
32
32
|
export { defaultExpression } from './column-default';
|
|
33
|
+
export { dbExecutor } from './db-executor';
|
|
33
34
|
export type { DbHealthReport } from './db-health';
|
|
34
35
|
export { checkDb } from './db-health';
|
|
35
36
|
export {
|
|
@@ -198,6 +199,7 @@ export {
|
|
|
198
199
|
sql,
|
|
199
200
|
} from './sql';
|
|
200
201
|
export { stripSqlNoise } from './sql-noise';
|
|
202
|
+
export { dollarTagAt, endOfBlockComment } from './sql-scan';
|
|
201
203
|
export type { DbSqlStateCode } from './sqlstate';
|
|
202
204
|
export { DB_SQLSTATE_CODES, isRetryableState, SQLSTATE, sqlState, sqlStateCode } from './sqlstate';
|
|
203
205
|
export { statementFingerprint, statementKind, statementVerb } from './statement-shape';
|
package/src/retype-dependents.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { addCheck, dropCheck } from './check-ddl';
|
|
|
22
22
|
import type { Plan } from './foreign-key-plan';
|
|
23
23
|
import { asDeclared, createIndex, dropIndex } from './index-ddl';
|
|
24
24
|
import type { CheckDescription, IndexDescription, TableDescription } from './introspect';
|
|
25
|
-
import {
|
|
25
|
+
import { IDENTIFIER_CHAR, noiseAt } from './sql-scan';
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Whether `expression` reads `column`, over-approximating on purpose.
|
|
@@ -52,12 +52,12 @@ export function referencesColumn(expression: string, column: string): boolean {
|
|
|
52
52
|
at = noise.end;
|
|
53
53
|
continue;
|
|
54
54
|
}
|
|
55
|
-
if (!
|
|
55
|
+
if (!IDENTIFIER_CHAR.test(expression[at] ?? '')) {
|
|
56
56
|
at += 1;
|
|
57
57
|
continue;
|
|
58
58
|
}
|
|
59
59
|
let end = at;
|
|
60
|
-
while (end < expression.length &&
|
|
60
|
+
while (end < expression.length && IDENTIFIER_CHAR.test(expression[end] ?? '')) end += 1;
|
|
61
61
|
if (expression.slice(at, end).toLowerCase() === wanted) return true;
|
|
62
62
|
at = end;
|
|
63
63
|
}
|
package/src/sql-scan.ts
CHANGED
|
@@ -3,10 +3,27 @@
|
|
|
3
3
|
// disagreed with a guard about where a literal ends is a `;` sent as data or a `delete` read as
|
|
4
4
|
// prose, and the two answers must be the same answer.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Postgres's `ident_start`/`dolq_start`: any non-ASCII character too (`\200-\377` per UTF-8 byte),
|
|
8
|
+
* so `$é$` opens a body and `é$x$` is one name — read against a server, `sql-scan.test.ts`.
|
|
9
|
+
*/
|
|
10
|
+
const IDENTIFIER_START = /[A-Za-z_\u0080-\uffff]/;
|
|
11
|
+
/** A tag's `dolq_cont`: an identifier character without the `$`, non-ASCII included. */
|
|
12
|
+
const TAG_PART = /[A-Za-z0-9_\u0080-\uffff]/;
|
|
13
|
+
/**
|
|
14
|
+
* `ident_cont`: the one class every word scan in this package reads a name with. `$` is legal
|
|
15
|
+
* after the first character (`a$b` is one name, not three) and so is any non-ASCII one — an
|
|
16
|
+
* ASCII-only scan found `col` inside `écol` and never found `prénom` at all.
|
|
17
|
+
*/
|
|
18
|
+
export const IDENTIFIER_CHAR = /[A-Za-z0-9_$\u0080-\uffff]/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* An unquoted name as a UTF8 server stores it: ASCII folded to lower case and nothing else —
|
|
22
|
+
* `Évent` stays `Évent` (read against a server, `dependent-view.test.ts`). `toLowerCase()` folded
|
|
23
|
+
* the `É` too and named a column the catalog does not hold.
|
|
24
|
+
*/
|
|
25
|
+
export const foldIdentifier = (name: string): string =>
|
|
26
|
+
name.replace(/[A-Z]+/g, (run) => run.toLowerCase());
|
|
10
27
|
|
|
11
28
|
export type NoiseKind = 'line-comment' | 'block-comment' | 'string' | 'identifier' | 'dollar-body';
|
|
12
29
|
|
|
@@ -23,11 +40,13 @@ function skipLineComment(script: string, index: number): number {
|
|
|
23
40
|
}
|
|
24
41
|
|
|
25
42
|
/**
|
|
26
|
-
* Past a block comment
|
|
27
|
-
* the
|
|
28
|
-
* every `;` after that point would otherwise
|
|
43
|
+
* Past a block comment opening at `index`, or `null` when it never closes. Postgres **nests**
|
|
44
|
+
* them, so the depth is counted rather than matched to the first terminator — a commented-out
|
|
45
|
+
* block that itself contains a comment closes once, and every `;` after that point would otherwise
|
|
46
|
+
* be read as data. Exported for `@ultimat3/mcp`'s read-only check, which REFUSES an unclosed
|
|
47
|
+
* comment where this lexer runs it to the end: one rule for where a comment ends, two readers.
|
|
29
48
|
*/
|
|
30
|
-
function
|
|
49
|
+
export function endOfBlockComment(script: string, index: number): number | null {
|
|
31
50
|
let depth = 0;
|
|
32
51
|
let at = index;
|
|
33
52
|
while (at < script.length) {
|
|
@@ -46,9 +65,13 @@ function skipBlockComment(script: string, index: number): number {
|
|
|
46
65
|
}
|
|
47
66
|
at += 1;
|
|
48
67
|
}
|
|
49
|
-
return
|
|
68
|
+
return null;
|
|
50
69
|
}
|
|
51
70
|
|
|
71
|
+
/** An unclosed comment runs to the end: Postgres reports that syntax error, not this lexer. */
|
|
72
|
+
const skipBlockComment = (script: string, index: number): number =>
|
|
73
|
+
endOfBlockComment(script, index) ?? script.length;
|
|
74
|
+
|
|
52
75
|
/**
|
|
53
76
|
* Past a run closing on `quote`, where a doubled quote is an escaped one — `'it''s'` and
|
|
54
77
|
* `"a""b"` are each one token. `escapes` is the `E''` dialect, the only one where a backslash
|
|
@@ -84,7 +107,7 @@ function skipQuoted(script: string, index: number, quote: string, escapes: boole
|
|
|
84
107
|
*/
|
|
85
108
|
function insideIdentifier(script: string, index: number): boolean {
|
|
86
109
|
let at = index - 1;
|
|
87
|
-
while (at >= 0 &&
|
|
110
|
+
while (at >= 0 && IDENTIFIER_CHAR.test(script[at] ?? '')) at -= 1;
|
|
88
111
|
const first = script[at + 1];
|
|
89
112
|
return at + 1 < index && first !== undefined && IDENTIFIER_START.test(first);
|
|
90
113
|
}
|
|
@@ -104,7 +127,7 @@ export function dollarTagAt(script: string, index: number): string | null {
|
|
|
104
127
|
let at = index + 1;
|
|
105
128
|
while (at < script.length) {
|
|
106
129
|
const char = script[at] ?? '';
|
|
107
|
-
const valid = at === index + 1 ? IDENTIFIER_START.test(char) :
|
|
130
|
+
const valid = at === index + 1 ? IDENTIFIER_START.test(char) : TAG_PART.test(char);
|
|
108
131
|
if (!valid) break;
|
|
109
132
|
at += 1;
|
|
110
133
|
}
|
|
@@ -125,7 +148,9 @@ function escapesAt(script: string, index: number): boolean {
|
|
|
125
148
|
const prefix = script[index - 1];
|
|
126
149
|
if (prefix !== 'E' && prefix !== 'e') return false;
|
|
127
150
|
const before = script[index - 2];
|
|
128
|
-
|
|
151
|
+
// `IDENTIFIER_CHAR`, never an ASCII-only class: `éE` and `a$E` are names to Postgres,
|
|
152
|
+
// and reading their `E` as a prefix let a `\'` swallow the `;` before a `drop table`.
|
|
153
|
+
return before === undefined || !IDENTIFIER_CHAR.test(before);
|
|
129
154
|
}
|
|
130
155
|
|
|
131
156
|
/**
|