@ultimat3/db 16.0.0 → 18.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/CLAUDE.md +170 -12
- package/README.md +4 -3
- package/package.json +3 -3
- package/src/array-parameter.ts +91 -0
- package/src/bun-sql.ts +32 -0
- package/src/client.ts +17 -321
- package/src/connection-url.ts +102 -0
- package/src/db-health.ts +31 -0
- package/src/default-client.ts +2 -1
- package/src/drift-errors.ts +36 -0
- package/src/drift-findings.ts +63 -6
- package/src/drift-fixtures.ts +23 -0
- package/src/errors.ts +0 -9
- package/src/generate.ts +40 -2
- package/src/index.ts +15 -15
- package/src/introspect.ts +16 -0
- package/src/migrate.ts +19 -11
- package/src/pool-profile.ts +125 -0
- package/src/pool-reserve.ts +50 -0
- package/src/readonly-query.ts +21 -8
- package/src/replica-client.ts +16 -3
- package/src/replica-identity.ts +84 -0
- package/src/snapshot-parse.ts +11 -2
- package/src/sql.ts +36 -0
- package/src/statement-funnel.ts +101 -0
- package/src/ungeneratable.ts +11 -0
package/CLAUDE.md
CHANGED
|
@@ -9,6 +9,7 @@ reaches down to this package for it. **Never** import `entity`, `jobs`, `http` o
|
|
|
9
9
|
|---|---|
|
|
10
10
|
| Deps | none. `@electric-sql/pglite` is an **optional peer**, imported by variable specifier inside `loadPgliteDriver()` so no consumer's `tsc` or bundler resolves it. **No ORM** — `entity`'s hand-written `postgresDriver()` is the production backing |
|
|
11
11
|
| SQL | `sql` binds `$n`; anything non-scalar and non-fragment throws `X_SQL_UNSAFE` |
|
|
12
|
+
| A name reaching a `fix:` | `shellInertIdentifier()` (`sql.ts`), the tree's ONE screen for it, `As of 2026-08-26`. `identifier()` alone does not close it: it refuses `"`, `\` and whitespace and **accepts** a backtick and a `$` — `SAFE_IDENTIFIER` allows `$` on its fast path — which are the two characters a shell substitutes inside DOUBLE quotes. A refused name is left OUT of the command, never escaped into it |
|
|
12
13
|
| Escape hatches | `raw()`, `identifier()`, `literal()` — each call is an audit point. `literal()` is the tree's ONE SQL-string-literal escape (`scripts/sql-literal-copies.ts`, pinned at zero) and it emits `E'…'` when the value carries a backslash |
|
|
13
14
|
| SQLSTATE | one reader, `sqlState()` (`sqlstate.ts`). Never read `error.code` for a SQLSTATE |
|
|
14
15
|
| Reading a caught value | `renderThrowable()` from core; never `error instanceof Error ? error.message : String(error)` — both halves RUN app code (a `Proxy` trap, `Symbol.toPrimitive`) and `checkDb` backs `/readyz`, where a render that throws is an exception in place of the report the kubelet asked for |
|
|
@@ -239,7 +240,7 @@ the same place rather than clearing it. The rejection still reaches the caller o
|
|
|
239
240
|
(`pglite.ts` swallows a failed *boot*, which is a different thing: there is nothing to close).
|
|
240
241
|
|
|
241
242
|
`execute()` trusts the command tag only when it is `> 0`, in **both** drivers — `rowsOf`
|
|
242
|
-
(`pglite.ts`) and `affectedBy` (`
|
|
243
|
+
(`pglite.ts`) and `affectedBy` (`statement-funnel.ts`) are one rule written twice, not two rules. PGlite
|
|
243
244
|
counts MODIFIED rows, so a SELECT that returned rows is tagged `0` and `??` would report 0 for
|
|
244
245
|
every read; a driver that tags a read `0` on the pooled side would have diverged from PGlite the
|
|
245
246
|
same way, and the same guard closes both. A write that modified nothing returned no rows either, so
|
|
@@ -256,7 +257,7 @@ the seam swallows nothing** — a throw from `onStatement` is how strict test mo
|
|
|
256
257
|
N+1 happened in, so a guarding facade here would silently delete that mode. `onStatement` is
|
|
257
258
|
synchronous, runs on the caller's stack after the statement settled, and must not issue SQL: a
|
|
258
259
|
statement from inside it re-enters the funnel and observes itself. Only two places may invoke it —
|
|
259
|
-
`runOn` (`
|
|
260
|
+
`runOn` (`statement-funnel.ts`) and `statement()` (`pglite.ts`), the funnels every statement already passes
|
|
260
261
|
through. Reserving a connection, booting PGlite and closing a pool are not statements and stay out.
|
|
261
262
|
|
|
262
263
|
Both funnels are now split in two, and the split is the whole design: `sendOn`/`send` is the raw
|
|
@@ -270,7 +271,7 @@ timeouts are still fifty statements; **notify outside the statement's own `try`*
|
|
|
270
271
|
that succeeded as `X_DB_UNAVAILABLE` and delete strict test mode's failure. On the failing path
|
|
271
272
|
the observer's throw replaces the DB error instead, which is the price of never swallowing — an
|
|
272
273
|
observer that only reports must not throw. `rows` comes from the same helper `execute()` uses
|
|
273
|
-
(`affectedBy` in `
|
|
274
|
+
(`affectedBy` in `statement-funnel.ts`, `rowsOf` in `pglite.ts`, hoisted to module scope for it), so the
|
|
274
275
|
report and the return value cannot disagree about one statement.
|
|
275
276
|
|
|
276
277
|
`attribution.ts` is `StatementEvent.attribution`'s producer: `withStatementAttribution(entity, op,
|
|
@@ -288,8 +289,8 @@ is the same fact written five times, with every path an author forgot it emittin
|
|
|
288
289
|
**Nesting keeps the innermost pair**, exactly as `expectedQueryLoop` keeps the innermost reason: a
|
|
289
290
|
relation preloaded during `findMany` reads through the *related* repository, so its statement is
|
|
290
291
|
attributed to that entity and its own operation, not to the read that triggered the preload.
|
|
291
|
-
**The funnels stamp, on both settle paths** — `runOn` (`
|
|
292
|
-
read `statementAttribution()` inside the branch that already found an observer, next to
|
|
292
|
+
**The funnels stamp, on both settle paths** — `runOn` (`statement-funnel.ts`) and
|
|
293
|
+
`statement()` (`pglite.ts`) read `statementAttribution()` inside the branch that already found an observer, next to
|
|
293
294
|
`expectedQueryLoopReason()`, and put it on the event whether the statement succeeded or failed, the
|
|
294
295
|
same argument as `expected`: a diagnostic that judges a whole request runs long after every scope
|
|
295
296
|
in it closed. `@ultimat3/entity`'s `postgresRepo` is the one producer — the last caller that still
|
|
@@ -1006,13 +1007,36 @@ generate repairs nothing. The empty-diff exclusion is `@ultimat3/cli`'s
|
|
|
1006
1007
|
makes every `x db gen` write a file holding no statement — a ledger row, a checksum and a place in
|
|
1007
1008
|
the apply order for nothing.
|
|
1008
1009
|
|
|
1009
|
-
**`REPLICA IDENTITY FULL` is
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1010
|
+
**`REPLICA IDENTITY FULL` is emitted, `As of 2026-08-26` — by a PARAMETER, never by an entity
|
|
1011
|
+
field.** `@ultimat3/realtime` refuses a live query on a table without it and nothing in the
|
|
1012
|
+
framework wrote it, so a scaffolded app generated a schema its own preflight rejected (issue #357).
|
|
1013
|
+
Which tables need it is **declared** by each `live: true` query's `subscribes:` and read out of the
|
|
1014
|
+
manifest — **not derived**, and this file said "derived" until 2026-08-26. It cannot be derived: the
|
|
1015
|
+
relation name is a string inside the query's `sql:` callback, which no generator can invoke without
|
|
1016
|
+
valid input, so a live-query-to-table set does not exist anywhere to be read. `liveFeed` in the
|
|
1017
|
+
reference app requires `{ orgId: t.uuid, limit }` and its table is the `'posts'` literal inside
|
|
1018
|
+
`from<PostSummary>('posts', …)`; `packages/query/src/sql.ts` says the same thing about itself —
|
|
1019
|
+
"`null` when no sample input was supplied". `X_QUERY_SUBSCRIBES_DRIFT` is what keeps the declaration
|
|
1020
|
+
honest, checked against the resolved shape at first subscribe. It is still a PARAMETER and never an
|
|
1021
|
+
`EntityDescriptionLike` field — this package is tier 1 and can see neither the manifest nor
|
|
1022
|
+
`@ultimat3/query` — so such a field would have been a declared-and-never-wired key, the defect class
|
|
1023
|
+
this release exists to eliminate. `GenerateOptions.replicaIdentityFull: readonly string[] |
|
|
1024
|
+
undefined` is the shape, passed by `@ultimat3/cli`'s `db-generate.ts` from `describeQueries()` —
|
|
1025
|
+
the descriptor, one hop BEFORE the manifest. `x.manifest.json` projects the same declaration
|
|
1026
|
+
(`QueryFact.subscribes`) and is what any other reader should use, but `appManifest(root)` re-loads
|
|
1027
|
+
the app and calls `appIdentity(root)`, which throws `X_APP_PACKAGE_INVALID` where there is no
|
|
1028
|
+
`package.json` — and `x db gen` has never needed one. `replica-identity.ts` owns every rule that
|
|
1029
|
+
rides with it.
|
|
1030
|
+
|
|
1031
|
+
| Rule | Why |
|
|
1032
|
+
|---|---|
|
|
1033
|
+
| recorded on the snapshot as `TableDescription.replicaIdentityFull` | `true` or **absent**, never `false` — the literal type is the enforcement. Absent is "nothing recorded", the reading `checks` and `using` already have, so a sidecar written before the field emits the ALTER once more and Postgres accepts it on a table that has it. Without the record the statement lands in **every** migration forever, which is a generator an author learns to ignore |
|
|
1034
|
+
| the snapshot records the **union** with what was already recorded | a caller passing no set must not erase the fact. `snapshotOf(entities)` alone answers `NONE`, so the one place the union is computed is `generateMigration` |
|
|
1035
|
+
| dead **last** in `up` | the table has to exist and a `create table` in this same migration is why it might not. It is ordered against nothing else — replica identity constrains no column, index or constraint — so the end is the only placement that cannot read as depending on a statement above it |
|
|
1036
|
+
| never `-- destructive: true` | it drops no row, rewrites no column and matches none of `destructive.ts`'s four rules. `generate-replica-identity.test.ts` asserts both the verdict and `destructiveStatements()` |
|
|
1037
|
+
| a name **no entity declares** is skipped, silently | the list comes from the manifest, and a live query whose entity was deleted is an app fault this generator cannot repair. Emitting it anyway is `42P01` at `ROLE=migrate`, which is the one place this package refuses to put a fault |
|
|
1038
|
+
| nothing is ever **reverted** | the option is optional, so "absent" and "no live query subscribes any more" are the same value. Reading them alike would let a caller that never passes it turn off replication for every subscribed table in the app |
|
|
1039
|
+
| `down` is `replica identity default`, except on a table this migration **creates** | that table's whole `down` is already `drop table`; a second statement ahead of it is a line an author reads and nothing performs |
|
|
1016
1040
|
|
|
1017
1041
|
**A column the DATABASE computes is a different thing at every step, and `generated-column.ts` is
|
|
1018
1042
|
all of them** — `As of 2026-08-24`. `ColumnDescriptionLike.generated` carries the
|
|
@@ -1276,12 +1300,146 @@ takes, and left unsaid an `alter database … set statement_timeout` on the serv
|
|
|
1276
1300
|
that must outlive it. The splitter honours libpq's backslash escape, so a `search_path=two\ words`
|
|
1277
1301
|
survives the round trip whole.
|
|
1278
1302
|
|
|
1303
|
+
- **Every numeric option this package bounds anything with is screened, `As of 2026-08-26`** —
|
|
1304
|
+
through core's `finiteCount`, which borrows `X_INVARIANT` as this package already does.
|
|
1305
|
+
`replicaClient`'s `breakerFailures` and `breakerCooldownMs` (a breaker is two comparisons and
|
|
1306
|
+
nothing else: `failures >= NaN` never opens it, `monotonic() < NaN` never parks it, so every read
|
|
1307
|
+
keeps going to the replica that is failing), `migrate`'s `lockWaitMs` (`NaN - elapsed <= 0` is
|
|
1308
|
+
false and `Bun.sleep(NaN)` does not sleep — a tight spin re-taking `pg_try_advisory_lock`, not an
|
|
1309
|
+
unbounded wait) and `readonlyQuery`'s `timeoutMs`, plus `client.ts`'s pool profile. The last one
|
|
1310
|
+
is a **behaviour change**: it used to normalise `NaN` to the default silently, so an agent read
|
|
1311
|
+
ran under a ceiling nobody wrote. Only an explicit `0` disables that layer, which is why its floor
|
|
1312
|
+
is 0 and not 1.
|
|
1313
|
+
|
|
1314
|
+
- **`client.ts` reached the 500-line ceiling on 2026-08-26, and shed the five jobs that were not
|
|
1315
|
+
"open a connection and send a statement".** `pool-profile.ts` owns the five numbers a pool runs
|
|
1316
|
+
on — the per-role table, `DATABASE_POOL_MAX` and the screen every merged profile passes;
|
|
1317
|
+
`connection-url.ts` builds the connection string (the libpq `options` merge and the
|
|
1318
|
+
`application_name` label); `bun-sql.ts` declares the slice of `Bun.SQL` this package uses and
|
|
1319
|
+
looks the global up lazily;
|
|
1320
|
+
`pool-reserve.ts` is `reserve()` under the acquire deadline; `db-health.ts` is `checkDb`, the
|
|
1321
|
+
`/readyz` report. `client.ts` keeps connecting, the client object and the ambient `db()` —
|
|
1322
|
+
and it still opens no socket at import, because `bunSqlFactory()` is reached from inside
|
|
1323
|
+
`connect()`. The **statement funnel** left with it on the same day, once the 500-line ceiling
|
|
1324
|
+
turned out not to be the bound this package is held to: `packages/db/src/**/*.ts` carries a
|
|
1325
|
+
path instruction of 200, and 263 lines is over it. `statement-funnel.ts` is `sendOn`/`runOn`
|
|
1326
|
+
plus the two shape helpers (`rowsOf`, `affectedBy`) — the seam this file already documents, and
|
|
1327
|
+
the one piece of `createPostgresClient` that closed over none of its state, so the move is a
|
|
1328
|
+
cut and a paste with no signature invented for it. Nothing outside this package imported any of
|
|
1329
|
+
the four, so no test's imports moved and no assertion changed. **The public surface did not move**: `src/index.ts` exports every one of those
|
|
1330
|
+
names from its new module, so `@ultimat3/db` is byte-identical to what it was. The same day,
|
|
1331
|
+
`drift.test.ts` split three ways along the three questions it was asking — `drift.test.ts`
|
|
1332
|
+
(tables and columns), `drift-index.test.ts` and `drift-ledger.test.ts` (what the migrations
|
|
1333
|
+
declare, and the post-migrate check) — over one shared `drift-fixtures.ts`, which
|
|
1334
|
+
`drift-foreign-key.test.ts` now imports instead of carrying its own byte-identical copy.
|
|
1335
|
+
|
|
1336
|
+
- **`DATABASE_URL`'s SCHEME is screened at boot, `As of 2026-08-26`** (issue #367). `new URL()`
|
|
1337
|
+
accepts a scheme-less connection string — `db.internal:5432/app` parses with `db.internal:` as
|
|
1338
|
+
the SCHEME and `5432/app` as the path — so `connectionUrl` saw a well-formed url and handed it
|
|
1339
|
+
on. Measured on bun 1.4.0, `Bun.SQL` then reads it as host `db.internal`, port 5432, database
|
|
1340
|
+
`app` and opens a Postgres pool on it, so the first symptom is a connect failure at the first
|
|
1341
|
+
QUERY, in another process phase, worded by the driver and naming neither the variable nor the
|
|
1342
|
+
missing `postgres://`. `POSTGRES_SCHEMES` is closed at **`postgres:` and `postgresql:`** —
|
|
1343
|
+
measured, not assumed: those two answer `adapter: 'postgres'`, while `pg:`, `tcp:` and
|
|
1344
|
+
`postgresql+ssl:` are refused by the driver itself (`Unsupported protocol: … Supported adapters:
|
|
1345
|
+
"postgres", "sqlite", "mysql", "mariadb"`), so excluding them costs a capability nobody has. The
|
|
1346
|
+
direction that matters is the one the driver ACCEPTS: `mysql:`, `mariadb:`, `sqlite:` and
|
|
1347
|
+
`file:` open a **different engine** and every statement generated here is Postgres. A
|
|
1348
|
+
**behaviour change**, not a defect repair — it narrows what the framework accepts, which is why
|
|
1349
|
+
it was deferred out of #364.
|
|
1350
|
+
**The received scheme is deliberately never echoed**, and this is the one refusal in the package
|
|
1351
|
+
that withholds the actionable token. `URL` reads the first token as the scheme, and for the value
|
|
1352
|
+
this exists for that token is the HOST (`db.internal:`); one dashboard field over
|
|
1353
|
+
(`app:hunter2@db.internal/app`) it is the USERNAME. Naming "the scheme" therefore puts a host or
|
|
1354
|
+
a credential in the boot log and the `--json` payload, where the logger has no key left to redact
|
|
1355
|
+
it by. The REQUIRED scheme is a constant and carries the whole instruction, and `describeValue`
|
|
1356
|
+
still keeps the shape, so an empty variable is told apart from a truncated one.
|
|
1357
|
+
`connection-url.test.ts` asserts the absence, so echoing it back is a failing test.
|
|
1358
|
+
|
|
1359
|
+
- **`unexpectedTable`'s `fix:` no longer names `x db gen`, `As of 2026-08-26`** (issue #345). That
|
|
1360
|
+
command diffs the ENTITY REGISTRY against the newest snapshot, and a table nothing declares is on
|
|
1361
|
+
neither side of it — so the diff came back empty, the generator's empty-diff branch writes NO
|
|
1362
|
+
file, and the reader had nothing to run and the same finding on the next deploy. The two edits
|
|
1363
|
+
that do resolve it are named instead: a `create table if not exists` in a migration (which
|
|
1364
|
+
`x db migrate` then accepts, through `@ultimat3/cli`'s `acceptCreatedTables`), or `psql … drop
|
|
1365
|
+
table` for a table nothing owns. No migration PATH is named — where an app keeps its migrations is
|
|
1366
|
+
the CLI's fact. `X_DB_DRIFT` is a shipped code and is unchanged; only this `fix:` text moved.
|
|
1367
|
+
|
|
1368
|
+
- **A name a `fix:` puts in a command is screened ONCE, by `shellInertIdentifier()` (`sql.ts`),
|
|
1369
|
+
`As of 2026-08-26`.** `identifier()` answers about SQL and cannot close this: it refuses `"`,
|
|
1370
|
+
`\` and whitespace and **accepts** a backtick and a `$` — `SAFE_IDENTIFIER` allows `$` on its
|
|
1371
|
+
fast path — which are exactly the two characters a shell substitutes inside DOUBLE quotes. A
|
|
1372
|
+
`fix:` is pasted into a shell at least as often as into a psql session, so a column named
|
|
1373
|
+
`$(id)` inside `x db gen "add $(id)"` RUNS `id` the moment its reader pastes the line, and a
|
|
1374
|
+
screen reusing `identifier()` unchanged ships a green suite over a live command-execution hole.
|
|
1375
|
+
It began as a private `writableName` in `drift-findings.ts` and was promoted rather than copied:
|
|
1376
|
+
three copies of a string-literal escape shipped here once and two were wrong the same way
|
|
1377
|
+
(`scripts/sql-literal-copies.ts`). Callers **degrade to prose** — the argument to `x db gen` is a
|
|
1378
|
+
migration DESCRIPTION, not an identifier, so no quoted form makes a hostile name safe to pass,
|
|
1379
|
+
and the name is read off `cause`/`meta` instead. Every benign rendering is byte-identical: the
|
|
1380
|
+
screen sits on the refusal branch alone, because roughly ten pages across `packages/cli`,
|
|
1381
|
+
`packages/core`, `wiki/` and `docs/` quote `x db gen "add <name>"` verbatim.
|
|
1382
|
+
|
|
1383
|
+
- **`dbDrift()` lives in `drift-errors.ts` and not in `errors.ts`, for exactly the reason
|
|
1384
|
+
`dependent-view.ts` states.** Its `fix:` needs `shellInertIdentifier` and `sql.ts` imports
|
|
1385
|
+
`errors.ts`, so keeping the constructor there is an import cycle around the module whose
|
|
1386
|
+
evaluation REGISTERS every code. `dependent-view.ts` avoided the same cycle by handing
|
|
1387
|
+
`errors.ts` a finished string; that is not available here, because `dbDrift(table, column)` is
|
|
1388
|
+
public API shipped since 1.0 and its signature cannot change. So the constructor moved instead,
|
|
1389
|
+
the way `migration-errors.ts` and `invariant-errors.ts` did — `X_DB_DRIFT` is still declared,
|
|
1390
|
+
titled and registered in `errors.ts`, and `src/index.ts` still exports the same name, so the
|
|
1391
|
+
public surface is byte-identical. `@ultimat3/entity`'s mirror screens through the **same**
|
|
1392
|
+
export across the tier seam (tier 2 → tier 1), which is what keeps the "keep in sync" comment on
|
|
1393
|
+
both declarations true; `packages/entity/src/errors.test.ts` asserts the two texts are equal,
|
|
1394
|
+
so a one-sided edit is a failing test rather than a comment nobody read.
|
|
1395
|
+
|
|
1396
|
+
- **A JS array bound as a parameter is rendered here, because `Bun.SQL` does not render it,
|
|
1397
|
+
`As of 2026-08-26`** (issue #384). `Bun.SQL`'s positional form serialises an array by JOINING ITS
|
|
1398
|
+
ELEMENTS WITH COMMAS, so `unsafe('select $1::text[]', [['x', 'y']])` sends the string `x,y` and
|
|
1399
|
+
Postgres answers `22P02 malformed array literal: "x,y"` — measured on bun 1.4.0 against Postgres
|
|
1400
|
+
17. **Three shipped statements bind an array and all three failed**: `@ultimat3/jobs`' `SQL_CLAIM`
|
|
1401
|
+
(the whole loop of every `ROLE=worker` container the framework produces, so a real deployment
|
|
1402
|
+
claimed nothing and every job sat in its queue), `SQL_OUTBOX_RELEASE` (the relay giving an
|
|
1403
|
+
unpublished batch back) and `@ultimat3/notify`'s `SQL_NOTIFY_INBOX_MARK_READ`.
|
|
1404
|
+
`array-parameter.ts` is the encoder and `sendOn` (`statement-funnel.ts`) is the one caller — this
|
|
1405
|
+
driver's only `unsafe` call, so one encoder is every caller fixed and a helper each site imports
|
|
1406
|
+
is three chances to forget and a fourth site tomorrow that does (axiom 1).
|
|
1407
|
+
|
|
1408
|
+
**Why nothing caught it, and why the repair test is in `@ultimat3/cli`.** `pglite.ts` is a
|
|
1409
|
+
separate driver that encodes an array correctly, and `x dev` runs the embedded default — so the
|
|
1410
|
+
framework's own dev loop is blind by construction and only a container with `DATABASE_URL` ever
|
|
1411
|
+
meets the failure. Every other test of those three statements runs against a recording executor
|
|
1412
|
+
and asserts their SQL as TEXT, which cannot see whether a parameter PARSES;
|
|
1413
|
+
`grep -rln '\.claim(' --include=*.live.test.ts packages/` answered ONE file before this landed.
|
|
1414
|
+
`packages/db/src/array-parameter.live.test.ts` pins the grammar against a real server — and
|
|
1415
|
+
asserts the RAW array is still refused, so deleting the encoder fails rather than passing on any
|
|
1416
|
+
driver that happens to encode. `packages/cli/src/pg-array.live.test.ts` is the composition test:
|
|
1417
|
+
it is in `cli` because nothing else can see all three — this package is tier 1 and may not import
|
|
1418
|
+
`jobs` (3) or `notify` (4), and neither of those can build a db-backed `PgExecutor` — so
|
|
1419
|
+
`pgExecutorFor(createPostgresClient(...))`, the executor every booted role actually gets, is the
|
|
1420
|
+
only place the three real statements meet the real driver.
|
|
1421
|
+
|
|
1422
|
+
Three grammar rules earn their line. **`NULL` bare is the array null and `"NULL"` is the
|
|
1423
|
+
four-character string**, so a JS `null` renders bare and a queue really spelled `NULL` must not
|
|
1424
|
+
become one. **Quoting is by content, not by type** — a comma, a brace, a quote, a backslash,
|
|
1425
|
+
surrounding whitespace or the empty string, which unquoted is not an element at all. **A
|
|
1426
|
+
`Uint8Array` is BYTEA and is deliberately not an array**: `Array.isArray` answers `false` for a
|
|
1427
|
+
typed array, which is behaviour this relies on rather than a case it writes. **A RAGGED nest is
|
|
1428
|
+
REFUSED**, never rendered — Postgres has no jagged array and `{{a,b},{c}}` is the same `22P02`,
|
|
1429
|
+
measured on 17 beside the rectangular `{{a,b},{c,d}}` that parses, so a literal this module is
|
|
1430
|
+
willing to emit is one the server is willing to read. `X_INVARIANT` through core's `assert`, the
|
|
1431
|
+
code this package already borrows for a value this build cannot honour; mixed depth (`{a,{b,c}}`)
|
|
1432
|
+
is caught by the same guard, which a rule comparing row LENGTHS alone would let through. And the
|
|
1433
|
+
common path allocates nothing — one `some` over a short list, then the caller's own array by identity, because
|
|
1434
|
+
every statement the framework runs passes through here and almost none binds an array (axiom 6).
|
|
1435
|
+
|
|
1279
1436
|
```bash
|
|
1280
1437
|
bun test # from packages/db
|
|
1281
1438
|
bun run typecheck
|
|
1282
1439
|
```
|
|
1283
1440
|
|
|
1284
1441
|
Gotchas:
|
|
1442
|
+
|
|
1285
1443
|
- `exactOptionalPropertyTypes` — declare optional fields as `x?: T | undefined`.
|
|
1286
1444
|
- `noUncheckedIndexedAccess` — array reads are `T | undefined`; `chunks[i] ?? ''` everywhere.
|
|
1287
1445
|
- Tests use `createRecordingClient()` + `setDbClient()`; no test may need a live database.
|
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ await withTransaction(async (tx) => {
|
|
|
26
26
|
| Export | |
|
|
27
27
|
|---|---|
|
|
28
28
|
| `sql` / `raw` / `identifier` / `literal` / `join` | fragment builders |
|
|
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 |
|
|
29
30
|
| `db()` / `baseClient()` / `setDbClient()` | the ambient client; `db()` returns the open tx if any |
|
|
30
31
|
| `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 |
|
|
31
32
|
| `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 |
|
|
@@ -42,7 +43,7 @@ await withTransaction(async (tx) => {
|
|
|
42
43
|
| `snapshotJson()` | `As of 2026-08`: the sidecar's **bytes** — the JSON Biome would have printed, trailing newline included. The one writer of a `<id>.snapshot.json`, because `JSON.stringify(…, null, 2)` is not formatter-clean and an app's `lint` step rejected the file `x db gen` had just written |
|
|
43
44
|
| `isLedgerMissing()` | `As of 2026-08`: whether an error is Postgres' `undefined_table` for `x_migrations` — the one condition a caller may read as "nothing applied" |
|
|
44
45
|
| `appTables()` / `FRAMEWORK_TABLE_PREFIX` | `As of 2026-08`: the live schema minus the `x_` namespace — no migration declares the ledger, the queue, the outbox or an auth table, so none of them is drift |
|
|
45
|
-
| `generateMigration()` | `x db gen "<name>"` — reversible up/down SQL, and `destructive` for the marker the file must carry. `As of 2026-08` a foreign key is its own `alter table … add constraint`, emitted after every table statement: inline, a `references()` had to point at a table entity registration order happened to create first, and `down` had to drop them in an order it did not control. `As of 2026-08-19` a **removed** `references()` emits its `drop constraint` (it emitted nothing, and the snapshot then denied a constraint the database still held), a changed `onDelete` is a drop-and-add rebuild, and a declared `on delete` rule reaches the clause at all. `As of 2026-08-25` a **retype** drops the partial indexes and CHECK constraints written against that column first and restores them in `down`: Postgres compiles both predicates against the old type and cannot recompile either, so `alter column … type text using …::text` was `42883 operator does not exist: text = post_status` and the migration aborted mid-run. A plain btree over the column is left alone — measured, Postgres rebuilds that one itself |
|
|
46
|
+
| `generateMigration()` | `x db gen "<name>"` — reversible up/down SQL, and `destructive` for the marker the file must carry. `As of 2026-08` a foreign key is its own `alter table … add constraint`, emitted after every table statement: inline, a `references()` had to point at a table entity registration order happened to create first, and `down` had to drop them in an order it did not control. `As of 2026-08-19` a **removed** `references()` emits its `drop constraint` (it emitted nothing, and the snapshot then denied a constraint the database still held), a changed `onDelete` is a drop-and-add rebuild, and a declared `on delete` rule reaches the clause at all. `As of 2026-08-25` a **retype** drops the partial indexes and CHECK constraints written against that column first and restores them in `down`: Postgres compiles both predicates against the old type and cannot recompile either, so `alter column … type text using …::text` was `42883 operator does not exist: text = post_status` and the migration aborted mid-run. A plain btree over the column is left alone — measured, Postgres rebuilds that one itself. `As of 2026-08-26` `replicaIdentityFull` names the tables a live query subscribes to and emits one `alter table … replica identity full` each, last in `up`, recorded on the snapshot so the next generation emits none — a parameter and never an entity field, because the live-query set is a tier-3 fact (`replica-identity.ts`) |
|
|
46
47
|
| `declaredIndexes()` / `invariantChecks()` / `constraintNameFor()` | `As of 2026-08-25`: the DDL an entity **invariant** becomes — a `check` as a named `CONSTRAINT`, a `unique` as a partial-capable unique INDEX, an `assert` as nothing. `EntityDescriptionLike` had no `invariants` field for three majors, so a regenerated migration silently held **none** of them, including the composite UNIQUE `upsertAll`'s `on conflict` is inferred against |
|
|
47
48
|
| `declaredChecks()` / `checkClauses()` / `checkPlan()` / `columnChecks()` / `columnCheckName()` / `columnNamesConstraint()` | `As of 2026-08-25`: **every** CHECK a table declares — a column's own (`enumerated()`'s value set, `tz()`'s IANA whitelist, `locale()`'s tags, money's currency pattern and scale bound) and an invariant's — on ONE list, so `createTable`, `diffTable` and `snapshotOf` agree about what exists. A column's check reached `create table` **inline and anonymous** and nothing else: the snapshot recorded none and the diff had no arm, so a value added to `enumerated()` generated no migration and a regenerated ENUM column came back as bare `text`. The name is `<table>_<column>_check` because that is the name **Postgres itself mints** for the old anonymous form — measured — so the repair lands on the constraint an already-generated database is holding; `checkPlan` emits `drop constraint if exists` before the `add` for exactly that column, because a bare add is `42710` there and a no-op everywhere else |
|
|
48
49
|
| `defaultExpression()` / `ColumnDefaultLike` | `As of 2026-08-25`: a column's `default` as SQL. A DECLARED default (`{ kind: 'value', value }`) wins; `gen_random_uuid()` and `now()` stay as the inference for a description that carries only `hasDefault` |
|
|
@@ -216,9 +217,9 @@ X_DB_DRIFT: schema differs from migrations
|
|
|
216
217
|
|
|
217
218
|
| Difference | cause | fix |
|
|
218
219
|
|---|---|---|
|
|
219
|
-
| live column, no migration | `table "T" has column "C" not present in any migration` | `x db gen "add C"` |
|
|
220
|
+
| live column, no migration | `table "T" has column "C" not present in any migration` | `x db gen "add C"` — the name goes through `shellInertIdentifier()`, and one it refuses is left OUT of the command rather than escaped into it (`x db gen "add the undeclared column"`, the name in the cause) |
|
|
220
221
|
| migrated column, not live | `table "T" is missing column "C" that migrations declare` | `x db migrate` |
|
|
221
|
-
| live table, no migration | `table "T" is not present in any migration` | `x db gen
|
|
222
|
+
| live table, no migration | `table "T" is not present in any migration` | a `create table if not exists` in a migration, then `x db migrate` — or `drop table` in `psql` where nothing owns it. Never `x db gen`, which diffs a table nothing declares against nothing and writes no file (issue #345). The name goes through `shellInertIdentifier()`, and one it refuses leaves the fix as prose |
|
|
222
223
|
| migrated table, not live | `table "T" is declared by migrations but does not exist` | `x db migrate` |
|
|
223
224
|
| index rebuilt differently | `index "I" on "T" covers (…)` / `is unique` / `is descending` / `is partial`, `not what migrations declare` | `x db migrate` |
|
|
224
225
|
| foreign key, rule moved | `foreign key on "T" (C) to "R" is on delete cascade, not what migrations declare` | the `drop constraint` + `add constraint` pair, in a new migration |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/db",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "18.0.0",
|
|
4
4
|
"description": "Postgres access, transactions, migrations and drift detection",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,14 +24,14 @@
|
|
|
24
24
|
"LICENSE"
|
|
25
25
|
],
|
|
26
26
|
"engines": {
|
|
27
|
-
"bun": ">=1.
|
|
27
|
+
"bun": ">=1.4.0"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "18.0.0"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@electric-sql/pglite": ">=0.5.0"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Single responsibility: a JS array bound as a statement parameter, rendered as the Postgres array
|
|
2
|
+
// literal `Bun.SQL` does not render.
|
|
3
|
+
//
|
|
4
|
+
// WHAT IS BROKEN WITHOUT IT. `Bun.SQL`'s positional form serialises an array by JOINING ITS
|
|
5
|
+
// ELEMENTS WITH COMMAS, so `unsafe('select $1::text[]', [['x', 'y']])` sends the string `x,y` and
|
|
6
|
+
// Postgres answers `malformed array literal: "x,y"` (SQLSTATE 22P02). Measured on Bun 1.4.0
|
|
7
|
+
// against Postgres 17. Three shipped statements bound an array through that path and every one
|
|
8
|
+
// of them failed — `SQL_CLAIM` (the worker's claim loop), `SQL_OUTBOX_RELEASE` and
|
|
9
|
+
// `SQL_NOTIFY_INBOX_MARK_READ`. Issue #384.
|
|
10
|
+
//
|
|
11
|
+
// WHY HERE AND NOT AT THE THREE CALL SITES. `sendOn` is the one place this driver's `unsafe` is
|
|
12
|
+
// called, so one encoder here is every caller fixed and none of them has to remember — a helper
|
|
13
|
+
// each site imports is three chances to forget and a fourth site tomorrow that does. Axiom 1.
|
|
14
|
+
//
|
|
15
|
+
// WHY PGLITE IS UNTOUCHED. `pglite.ts` is a separate driver with its own `send`, and it encodes an
|
|
16
|
+
// array parameter correctly already — which is exactly why nothing caught this: `x dev` runs the
|
|
17
|
+
// embedded default, so the framework's own dev loop is systematically blind to a defect that only
|
|
18
|
+
// appears once `DATABASE_URL` selects `Bun.SQL`. A container is where it bites.
|
|
19
|
+
|
|
20
|
+
import { assert } from '@ultimat3/core';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One element, quoted only when it has to be.
|
|
24
|
+
*
|
|
25
|
+
* `NULL` unquoted is the array NULL and `"NULL"` is the four-character string, so a JS `null`
|
|
26
|
+
* MUST render bare and a string that happens to spell it must not. Everything else is quoted when
|
|
27
|
+
* it holds a character the literal grammar reads as structure — a comma, a brace, a quote, a
|
|
28
|
+
* backslash, or leading/trailing whitespace the parser would strip — plus the empty string, which
|
|
29
|
+
* unquoted is not an element at all.
|
|
30
|
+
*/
|
|
31
|
+
function element(value: unknown): string {
|
|
32
|
+
if (value === null || value === undefined) return 'NULL';
|
|
33
|
+
// A Date is ALWAYS quoted, even though an ISO-8601 instant carries no character the grammar
|
|
34
|
+
// reads as structure. A timestamp element is conventionally quoted, and the alternative is a
|
|
35
|
+
// rule that holds only while nothing ever renders a timestamp with a space in it.
|
|
36
|
+
if (value instanceof Date) return `"${value.toISOString()}"`;
|
|
37
|
+
const text = String(value);
|
|
38
|
+
const structural = /[{},"\\\s]/.test(text) || text.length === 0 || text.toUpperCase() === 'NULL';
|
|
39
|
+
if (!structural) return text;
|
|
40
|
+
return `"${text.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The array literal for one parameter: `{a,b,c}`, elements escaped.
|
|
45
|
+
*
|
|
46
|
+
* NESTED ARRAYS ARE RENDERED, not refused: Postgres reads `{{a,b},{c,d}}` as a 2-dimensional
|
|
47
|
+
* array, and rendering one is strictly closer to right than sending `a,b,c,d`. Nothing in this
|
|
48
|
+
* tree binds one today.
|
|
49
|
+
*
|
|
50
|
+
* A RAGGED nest is REFUSED, never rendered. Postgres has no jagged array — every extent of a
|
|
51
|
+
* dimension must match — and `{{a,b},{c}}` is `22P02 malformed array literal`, measured on 17
|
|
52
|
+
* beside the rectangular `{{a,b},{c,d}}` that parses (`array-parameter.live.test.ts`). So a
|
|
53
|
+
* literal this function is willing to emit is one the server is willing to read: rendering the
|
|
54
|
+
* jagged one puts the fault two layers away, in the driver's words, naming neither the parameter
|
|
55
|
+
* nor which row is short. `X_INVARIANT` through core's `assert`, the code this package already
|
|
56
|
+
* borrows for a value this build cannot honour (`createIndex`'s unique GIN, `generatedClause`'s
|
|
57
|
+
* generated-and-defaulted column).
|
|
58
|
+
*/
|
|
59
|
+
export function pgArrayLiteral(values: readonly unknown[]): string {
|
|
60
|
+
const nested = values.filter((value): value is readonly unknown[] => Array.isArray(value));
|
|
61
|
+
// Mixed depth is ragged too — `{a,{b,c}}` is a scalar beside a dimension, which Postgres reads
|
|
62
|
+
// as the same malformed literal. Comparing counts alone would let it through.
|
|
63
|
+
assert(
|
|
64
|
+
nested.length === 0 || nested.length === values.length,
|
|
65
|
+
'a nested array parameter mixes scalars and arrays at one level, and Postgres has no such array',
|
|
66
|
+
'bind one array of scalars, or one array whose every element is an array of equal length',
|
|
67
|
+
);
|
|
68
|
+
const width = nested[0]?.length;
|
|
69
|
+
assert(
|
|
70
|
+
nested.every((row) => row.length === width),
|
|
71
|
+
`a nested array parameter is ragged — its rows are ${nested.map((row) => row.length).join(', ')} long, and Postgres has no jagged array`,
|
|
72
|
+
'give every row the same length, or bind one array per row',
|
|
73
|
+
);
|
|
74
|
+
return `{${values.map((value) => (Array.isArray(value) ? pgArrayLiteral(value) : element(value))).join(',')}}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every parameter of one statement, arrays rendered and everything else passed through untouched.
|
|
79
|
+
*
|
|
80
|
+
* A NEW ARRAY ONLY WHEN SOMETHING CHANGED. Every statement the framework runs goes through this
|
|
81
|
+
* function, and almost none of them binds an array — so the common path is one `some` over a short
|
|
82
|
+
* list and the caller's own array object, byte for byte, which is what `sendOn` had before this
|
|
83
|
+
* existed (axiom 6).
|
|
84
|
+
*
|
|
85
|
+
* A `Uint8Array` is BYTEA and is deliberately not an array here: `Array.isArray` answers `false`
|
|
86
|
+
* for a typed array, which is the behaviour this relies on rather than a special case it writes.
|
|
87
|
+
*/
|
|
88
|
+
export function encodeArrayParameters(values: readonly unknown[]): readonly unknown[] {
|
|
89
|
+
if (!values.some(Array.isArray)) return values;
|
|
90
|
+
return values.map((value) => (Array.isArray(value) ? pgArrayLiteral(value) : value));
|
|
91
|
+
}
|
package/src/bun-sql.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Single responsibility: the slice of `Bun.SQL` this package uses, declared structurally, and the
|
|
2
|
+
// lazy lookup of the global that provides it. Reached through a function so importing the client
|
|
3
|
+
// never touches `Bun` at module evaluation — the CLI imports it to print help.
|
|
4
|
+
|
|
5
|
+
import { dbUnavailable } from './errors';
|
|
6
|
+
|
|
7
|
+
/** One connection pinned out of `Bun.SQL`'s pool, released back by hand. */
|
|
8
|
+
export interface BunSqlReserved {
|
|
9
|
+
unsafe(text: string, values?: readonly unknown[]): Promise<unknown>;
|
|
10
|
+
release(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The slice of `Bun.SQL` we use. Declared structurally so this package has no dependency. */
|
|
14
|
+
export interface BunSqlDriver {
|
|
15
|
+
unsafe(text: string, values?: readonly unknown[]): Promise<unknown>;
|
|
16
|
+
reserve(): Promise<BunSqlReserved>;
|
|
17
|
+
close(options?: { readonly timeout?: number }): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type BunSqlFactory = new (
|
|
21
|
+
url: string,
|
|
22
|
+
options?: Readonly<Record<string, unknown>>,
|
|
23
|
+
) => BunSqlDriver;
|
|
24
|
+
|
|
25
|
+
export function bunSqlFactory(): BunSqlFactory {
|
|
26
|
+
const host = globalThis as unknown as { readonly Bun?: { readonly SQL?: unknown } };
|
|
27
|
+
const factory = host.Bun?.SQL;
|
|
28
|
+
if (typeof factory !== 'function') {
|
|
29
|
+
throw dbUnavailable('Bun.SQL is unavailable — this package requires Bun >= 1.3');
|
|
30
|
+
}
|
|
31
|
+
return factory as BunSqlFactory;
|
|
32
|
+
}
|