@ultimat3/db 25.0.0 → 25.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
@@ -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 only as
104
- `packages/cli/src/runtime-queue.ts` wraps a real client for its `PgExecutor`, unattributed.
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` never imports this package directly.
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.0.0",
3
+ "version": "25.1.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.0.0"
36
+ "@ultimat3/core": "25.1.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
+ }
@@ -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 { IDENTIFIER_PART, noiseAt } from './sql-scan';
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 (!IDENTIFIER_PART.test(statement[at] ?? '')) {
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 && IDENTIFIER_PART.test(statement[end] ?? '')) end += 1;
69
- words.push({ text: statement.slice(at, end).toLowerCase(), quoted: false });
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';
@@ -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 { IDENTIFIER_PART, noiseAt } from './sql-scan';
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 (!IDENTIFIER_PART.test(expression[at] ?? '')) {
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 && IDENTIFIER_PART.test(expression[end] ?? '')) end += 1;
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
- 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_$]/;
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. 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.
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 skipBlockComment(script: string, index: number): number {
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 script.length;
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 && IDENTIFIER_TAIL.test(script[at] ?? '')) at -= 1;
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) : IDENTIFIER_PART.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
- return before === undefined || !IDENTIFIER_PART.test(before);
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
  /**