@ultimat3/entity 23.0.0 → 24.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 CHANGED
@@ -24,10 +24,14 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
24
24
  Both are the bar.
25
25
  - **`write-parity.test.ts` runs every write against memory AND PGlite**: a patch property present with
26
26
  `undefined` is ABSENT (only `null` clears); memory refuses a duplicate PK, a duplicate non-partial
27
- `unique` and a PK patch onto another row with `X_DB_UNIQUE_VIOLATION` (`memory-unique.ts`; partial
28
- uniques are not checked in memory); `.transition()` filters on `singleKeyOf(entity)`;
27
+ unique — index OR `c.unique([…])` invariant, the one list in `unique-constraints.ts` — and a PK
28
+ patch onto another row with `X_DB_UNIQUE_VIOLATION` (partial uniques are not checked in memory);
29
+ `.transition()` filters on `singleKeyOf(entity)` and refuses an absent id (`X_NOT_FOUND`, no row
30
+ moved);
29
31
  `decimal()`/`bigint()` store Postgres's spelling (never rounded); a seed `upsert` compares only named
30
- columns; `text({ max })` counts code points; `integer()` holds int4; `url()` lower-cases its scheme.
32
+ columns; `text({ max })` counts code points; `integer()` holds int4, `bigint()` int8; a whole part
33
+ of `0` is no `numeric` digit; `url()` lower-cases its scheme and judges the STORED text.
34
+ `invariant-parity.test.ts` asks the app, the driver and the raw CHECK the same question.
31
35
  - **`min`/`max` over a `timestamptz` crosses as epoch ms** (`aggregate-time-parity.test.ts`). A whole
32
36
  row read under a non-UTC session still fails on PGlite — recorded, not fixed.
33
37
  - **Text ordering: byte (`C`) order is the parity target** — a linguistic production collation is a
@@ -37,10 +41,13 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
37
41
  holds a `number`); `RowWrite<Row>` types `insert`/`insertAll`/`upsertAll`; `narrowRow` (`columns.ts`)
38
42
  narrows at each write method's ENTRY, before `$assert`/`upsertPlan`. `pg-money-write.live.test.ts`,
39
43
  `money-write-parity.test.ts`, `type-pins.ts`.
40
- - **A PREDICATE's meaning is decided by the column's KIND** (`memory-match.ts`): decimal-text columns
41
- compare through core's `compareDecimalText` (`DECIMAL_TEXT` decides who asks); a `uuid` is a value
42
- stored lower-case (`keyOf`, `parseUuid`, `narrowUuid`; text is never narrowed); `LIKE` uses
43
- Postgres' default `\` escape and refuses a trailing escape; a run of `%` is one `.*`; `in` takes a
44
+ - **A PREDICATE's meaning is decided by the column's KIND** (`memory-match.ts`); `compareByKind` /
45
+ `sameValueOfKind` are EXPORTED — `@ultimat3/query` calls them. **ONE numeric comparison,
46
+ `numericOrder`**: `DECIMAL_TEXT` columns by VALUE (`'2.50' = 2.5`), `number`/`bigint` numerically
47
+ (`compare-parity.test.ts`). A `uuid` is a value, compared lower-cased and
48
+ stored lower-case (`keyOf`, `parseUuid`, `narrowUuid`; text is never narrowed); `LIKE` (`like.ts`)
49
+ uses Postgres' default `\` escape, refuses a trailing escape, counts CHARACTERS and is a bounded
50
+ two-pointer walk — never a regex; `in` takes a
44
51
  list or nothing, and a NULL in it emits `(col in (…) or col is null)`; a column the row never NAMED
45
52
  is NULL for `eq`/`neq`/`in`.
46
53
  - **The Postgres driver is proved against a real Postgres** (`pg-driver.live.test.ts`, skipped without
@@ -48,9 +55,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
48
55
  - **A repository call rejects, never throws synchronously** (`tableFor`'s writes are `async`).
49
56
  - **`defaultDriver()`** is the process default; `Driver.reset?()` is optional (memory only), resets
50
57
  repositories in place. Test seam only.
51
- - **A repository pinned to its own client refuses to run inside a transaction**
52
- (`X_REPO_CLIENT_PINNED`, in `client()` in `pg-driver.ts`); the fix names `setDbClient(client)` plus
53
- an unpinned repository.
58
+ - **A repository pinned to its own client joins a transaction opened ON that client** (`tx.origin`)
59
+ **and refuses any other** (`X_REPO_CLIENT_PINNED`, in `client()` in `pg-driver.ts`); the fix names
60
+ `setDbClient(client)` plus an unpinned repository.
54
61
 
55
62
  ## Do not regress — reading
56
63
 
@@ -63,7 +70,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
63
70
  `writing()`), keyed by id, bounded (`MAX_SIBLING_KEYS`, oldest page first). One switch:
64
71
  `postgresDriver({ jitPreload: false })` — never an `app.config.ts` key. Shared pieces live in
65
72
  `batch-read.ts`.
66
- - **`preload(name)` shares `batch-read.ts` and keeps no request cache.** Tenancy is CARRIED only when
73
+ - **`preload(name, { max })` shares `batch-read.ts`, keeps no request cache and is BOUNDED**
74
+ (`preload-ceiling.ts`: default `MAX_PRELOADED_ROWS`, refused past it, never truncated). Tenancy is CARRIED only when
67
75
  both entities are scoped by a column of that same name, else the related read refuses
68
76
  (`X_TENANCY_UNSCOPED`). Reach is the `database()` call's `RelatedTables`; `select()` widens with each
69
77
  preloaded relation's local key; attachment copies (`{ ...row }`); only `page()`/`all()`/`one()`
@@ -108,12 +116,12 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
108
116
  - **The four aggregates** (`sum`, `avg`, `min`, `max`) share `aggregate.ts`
109
117
  (`aggregate-fold.ts` memory, `aggregate-decode.ts` Postgres): never a float; `avg` at `AVG_SCALE` (6),
110
118
  half away from zero; `null` for an empty set. Refused: `min`/`max` on text, `avg` over money
111
- (`X_AGGREGATE_UNSUPPORTED`), mixed currency or scale (`X_AGGREGATE_MIXED_CURRENCY`), a money total
119
+ (`X_AGGREGATE_UNSUPPORTED`), mixed currency or scale (`X_AGGREGATE_MIXED_CURRENCY`, two causes, two fixes), a money total
112
120
  past ±2^53. **`approximateCount()` is `reltuples`**; a filtered chain or tenant-scoped entity is
113
121
  `X_APPROXIMATE_COUNT_FILTERED`; `null` for an unanalysed table.
114
122
  - **`json()` / `arrayOf()` are filterable** (`contains`, `contained-by`, `overlaps`, `has-key`) with
115
- Postgres' measured meaning in `containment.ts`; `has-key` emits `operator(pg_catalog.?)` (indexable),
116
- pinned by `pg-sql.test.ts`. No jsonpath operator.
123
+ Postgres' measured meaning in `containment.ts`; `has-key` emits `operator(pg_catalog.?)` (indexable)
124
+ and takes a STRING key, else no rows; a NULL array element equals nothing. No jsonpath operator.
117
125
  - **A GIN index is declarable** (`using: 'gin'`; the set is `@ultimat3/db`'s `INDEX_METHODS`); absent
118
126
  is `btree` byte for byte; the method joins the name discriminator only when declared; GIN cannot be
119
127
  unique or ordered (refused here). `pg-containment.live.test.ts`.
@@ -145,7 +153,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
145
153
  local renderers are deleted). An untranslatable predicate is `kind: 'assert'`, `sql: null`.
146
154
  **`InvariantDescription.columns` is projected** (`describe-invariant.test.ts`).
147
155
  - **The two halves must AGREE, term by term** (`expr.ts`): `matches(/…/i)` is `~*`, other flags are
148
- refused (`matchOperator`); `minLength` counts code points. **`isNull()`/`isNotNull()` are the only
156
+ refused (`matchOperator`); `minLength` counts code points; `trimmed()` strips U+0020 only (`btrim`);
157
+ `atLeast`/`eq(<number>)` judge by the column's KIND (`invariantColumns(name, columns)`), so a
158
+ `bigint()`/`decimal()` row compares by its digits. **`isNull()`/`isNotNull()` are the only
149
159
  total members**; `iff(a, b)` renders `(a) = (b)` — measured as the safe direction
150
160
  (`pg-invariant-null.live.test.ts`); `iff` is a function, and a `unique` operand is refused with the
151
161
  `c.unique([…])` fix built through `JSON.stringify`. One app-only operand makes the whole rule
@@ -200,7 +210,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
200
210
  - **`deleteWhere` / `updateWhere` are the only filtered writes**: empty filter `X_WRITE_UNFILTERED`,
201
211
  empty patch `X_PATCH_EMPTY` (undefined values dropped first); soft delete respected; both return a
202
212
  count; rows come back only when a JS-only invariant needs them (`hasJsOnlyInvariant`), counted first
203
- and refused past `MAX_ASSERTED_ROWS` (50,000) in BOTH drivers. `updateStatement`'s `returning` is
213
+ and refused past `MAX_ASSERTED_ROWS` (50,000) in BOTH drivers; on Postgres that statement and its
214
+ judgement share a transaction (`judged-write.ts`, `update` too), so a refusal rolls back. `updateStatement`'s `returning` is
204
215
  required.
205
216
  - **Every instant the write path stamps comes from `entityNow()`** (`clock.ts`, `ctx.clock`).
206
217
  **`touch()` in `query.ts` is the ONE place `onUpdateNow()` columns are stamped.**
@@ -214,7 +225,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
214
225
  - **A seed is replayable**: `SeedContext.insert` is one `upsertAll(…, { onMatch: 'nothing' })` per
215
226
  call; a generated PK the row does not name is refused. `upsert(entity, { by }, values)` keys on a
216
227
  natural key, reads first for the report, writes one `on conflict … do update`, preserves `createdAt`.
217
- **The environment guard is the CLI's** (`seedTiersFor`; `run()` stays permissive).
228
+ **The environment guard is the CLI's** (`seedTiersFor`; `run()` stays permissive). **A dry run is
229
+ the real run rolled back** through `Driver.transactor()` (REQUIRED on the interface) — one rule,
230
+ never a per-verb lookup.
218
231
  - **`setRowObserver` reports committed row changes above the driver** — one per process (returns the
219
232
  replaced one), applied by `database()`, one comparison per write when unset, `before` only when the PK
220
233
  is `id`, a filtered write is `onBulk`. Not a second change-feed path.
@@ -231,7 +244,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
231
244
  rewrites or refuses every predicate. A driver never seals for itself; an entity with no sealed
232
245
  column gets its repository back untouched. The cipher is core's (`scripts/seal-calls.ts`).
233
246
  - **A sealed column's `$parse` passes a sealed string and judges a plaintext**; `decodeRow` parses
234
- none. `sealValue` runs the plaintext parser BEFORE sealing — nothing after can.
247
+ none. `sealValue` runs the UNWRAPPED plaintext parser (`$meta.sealed.plaintext`) BEFORE sealing — nothing
248
+ after can, and the column's own `$parse` would pass a sealed-shaped lookalike.
235
249
  - **No reading of an unsealed value.** Not sealed is `X_SEAL_INVALID`; existing plaintext is
236
250
  expand → `backfill()` → contract (README). No `legacy` option.
237
251
  - **Purpose is `entity:<table>.<column>`, physical names** (`sealed.ts`). **Sealing is not on
@@ -261,7 +275,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
261
275
  `persist` defaults `false`, read only off the projection. **The browser path is
262
276
  `@ultimat3/entity/record`**, whose modules import `entity-error.ts`, never `errors.ts`
263
277
  (`record-bundle.test.ts`).
264
- - Never throw a bare `Error` — use `errors.ts`.
278
+ - Never throw a bare `Error` — use `errors.ts`. `X_DB_DRIFT` is `@ultimat3/db`'s, factory included.
265
279
  - **Tests restore the registry with a FILE-scope `afterAll(() => { clearRegistry(); })`** — matched on
266
280
  exact text by `live-registry-cleanup.test.ts`; never inside a skippable suite's teardown.
267
281
 
@@ -273,9 +287,11 @@ Each file's header states its one job. The map, by area: `types.ts` (derivation;
273
287
  `entity.ts` / `describe.ts` / `index-name.ts` / `search.ts`, `state-machine.ts` / `transition.ts`,
274
288
  `feature-errors.ts`, `view.ts` / `row-schema.ts` / `record-projection.ts` / `record-key.ts` /
275
289
  `rows-of.ts` / `record-table.ts` / `record.ts`, `entity-error.ts` / `errors.ts`, `query.ts` /
276
- `database.ts` / `clock.ts`, `memory-match.ts` / `repo.ts` / `memory-repo.ts` / `tenancy.ts` /
290
+ `database.ts` / `clock.ts`, `memory-match.ts` / `like.ts` / `numeric-compare.ts` / `unique-constraints.ts` / `repo.ts` /
291
+ `memory-repo.ts` / `tenancy.ts` /
277
292
  `cross-tenant.ts`, `plan.ts` / `cursor.ts` / `batch.ts`, `pg-driver.ts` / `coalesce.ts` /
278
- `batch-read.ts` / `bulk-write.ts` / `count-by.ts` / `jit-preload.ts` / `preload.ts` / `pg-sql.ts` /
293
+ `batch-read.ts` / `bulk-write.ts` / `count-by.ts` / `jit-preload.ts` / `preload.ts` /
294
+ `preload-ceiling.ts` / `judged-write.ts` / `pg-sql.ts` /
279
295
  `pg-row.ts`, `row-observer.ts` / `write-tag.ts` (a keyed request's write names itself in the WAL via
280
296
  `pg_logical_emit_message`), `registry.ts` / `relations.ts` / `n-plus-one.ts` / `seed.ts`,
281
297
  `type-pins.ts`, `live-registry-cleanup.test.ts`.
package/README.md CHANGED
@@ -110,7 +110,7 @@ never runs, while the subpath retains the projection, the key and the registry a
110
110
  | `money()` | `<name>_minor bigint` + `<name>_currency char(3)` + `<name>_scale integer null` | never a float, never one implied currency. The row value is `@ultimat3/schema`'s `MoneyValue` — the same declaration `@ultimat3/money`'s `Money` is — so a decoded row goes straight to `add()`/`formatMoney()`. A writer may hand a `bigint` — `Insertable` says so on a table, `RowWrite<Row>` at a repository, and every whole-row write narrows it at its own entry, before an invariant or a statement sees it; a minor unit past ±2^53 is refused there and on read, never rounded. `scale` is the decimal exponent `minor` counts in when it is not the currency's own (`{ minor: 2, currency: 'USD', scale: 6 }` is $0.000002); NULL in the column means "the currency's own minor unit" and round-trips as an ABSENT key, never as `0` |
111
111
  | `enumerated(v)` | `text` + CHECK | a variant is a one-line migration, not `ALTER TYPE` |
112
112
  | `tz(zones)`, `locale(tags)` | `text` + CHECK, `Intl`-validated at declaration | an offset is not a time zone |
113
- | `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too |
113
+ | `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too. `url()` judges the STORED text against its CHECK (`^https?://`), so leading whitespace, a tab inside the scheme and `https:/a.b` are refused here rather than by a raw `23514` |
114
114
 
115
115
  Chain: `.primaryKey()` · `.nullable()` · `.unique()` · `.default(v)` · `.defaultNow()` ·
116
116
  `.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)` ·
@@ -236,9 +236,9 @@ for a table it was going to create; these are the shapes a table already has, `A
236
236
  | Builder | Emits | Row type, and why |
237
237
  |---|---|---|
238
238
  | `json(schema)` | `jsonb` | the schema is **required**: a `json()` returning `unknown` is the `any` hole this framework forbids, and a column is the worst place for one — the value arrives from the database as often as from a caller. The object is bound as an object, never as JSON text |
239
- | `decimal({ precision, scale })` | `numeric(p, s)` | a `string`, the exact digits. Money is the one decimal with an opinion (integer minor units + a currency); this is every other one, and a value with more decimal places than the column stores is refused rather than rounded |
239
+ | `decimal({ precision, scale })` | `numeric(p, s)` | a `string`, the exact digits. Money is the one decimal with an opinion (integer minor units + a currency); this is every other one, and a value with more decimal places than the column stores is refused rather than rounded. A whole part of `0` counts no digit, so `numeric(2, 2)` holds `0.50` |
240
240
  | `date()` | `date` | `@ultimat3/time`'s `PlainDate` — a calendar date, no time, no zone. `effective_on` is the date a rate applies, and as a `timestamptz` it is a different date on either side of midnight for half the planet |
241
- | `bigint()` | `bigint` | a decimal `string`. A JS `bigint` is what `JSON.stringify` throws on and a `number` loses digits past 2^53 — which is exactly where a legacy `int8` key lives. Both driver spellings (a string from Bun's `sql`, a `bigint` from PGlite) arrive as one |
241
+ | `bigint()` | `bigint` | a decimal `string`. A JS `bigint` is what `JSON.stringify` throws on and a `number` loses digits past 2^53 — which is exactly where a legacy `int8` key lives. Both driver spellings (a string from Bun's `sql`, a `bigint` from PGlite) arrive as one. Bounded at `int8` (−2^63 … 2^63−1): one past either end is refused where Postgres answers `22003` |
242
242
  | `bytes()` | `bytea` | a plain `Uint8Array`, normalised: Bun's `sql` returns a `Buffer` and PGlite a `Uint8Array`, and the two do not serialise alike |
243
243
  | `arrayOf(column)` | `<element>[]` | `readonly T[]`, each member parsed by the element column it was given. Money and nested arrays are refused — an element is one scalar column |
244
244
 
@@ -487,7 +487,10 @@ written schema-qualified as `operator(pg_catalog.?)` — the function form `json
487
487
  the same test and the planner will not match a GIN index to it. Their
488
488
  meaning is Postgres', to the letter, in both drivers — including that the array-contains-a-primitive
489
489
  exception applies at the **top level only** and to **primitives only**, and that `&&`'s empty
490
- operand overlaps nothing where `@>`'s is contained by everything.
490
+ operand overlaps nothing where `@>`'s is contained by everything. Two more rules both drivers hold:
491
+ a NULL **array element** equals nothing, itself included, on either side of `@>`, `<@` and `&&`
492
+ (a `jsonb` `null` is a value and still equals itself); and `has-key` takes a **string** key — any
493
+ other operand matches no row, never `String(operand)`.
491
494
 
492
495
  There is no jsonpath expression operator: `contains` already matches nested structure, and a path
493
496
  language inside the query language would be a second way to ask one question. `&&` on a `jsonb`
@@ -599,6 +602,7 @@ const shipped = await db.orders.transition('status', id, { from: 'paid', to: 'sh
599
602
  | `onUpdateNow()` moves, because a transition is an update | the audit of *when* it moved, with no second mechanism beside it |
600
603
  | the CHECK comes from `enumerated()` | the machine emits no DDL of its own — one declaration of what a legal value is |
601
604
  | `memoryDriver()` answers it exactly as Postgres does | a compare-and-set over a map is the same question; unlike a `tsvector` match, there is nothing to fake |
605
+ | an `undefined` or `null` id is `X_NOT_FOUND`, and moves no row | an absent id names no row — it is refused before the statement, where it used to drop out of the filter and move every row in the `from` state |
602
606
 
603
607
  What is deliberately **not** here: who may make a move, what happens on arrival, an approval chain,
604
608
  a reason code. Those differ per app — wrap `transition()` in your own function and put them there.
@@ -659,7 +663,7 @@ and never unwritten.
659
663
  | Tenancy | the plan a read builds, through `scopedPlan` — the actor's org predicate is in the statement, and the empty-filter guard runs before it, because one tenant's every row is still every row. The patch is judged too, through `assertRowTenant`: a filter bounds which rows are written, never what they become |
660
664
  | Soft delete | the entity's `deletedAt` column is the same switch `delete(id)` uses. Stamped rows are not matched again by either call, so the original deletion time survives and a deleted row is never patched back into shape |
661
665
  | `onUpdateNow()` | stamped by `touch()`, the same helper `update(id, patch)` uses — one place, so the two can never disagree about `updatedAt` |
662
- | Rows read back | **only when something can still refuse them.** A `check` or a `unique` invariant is a constraint Postgres enforced on the statement, so the answer is a count and the statement carries no `returning *`. Only a JS-only rule (`kind: 'assert'`, `sql: null`) has to be judged on the result — and then the match is counted first and refused past `MAX_ASSERTED_ROWS` (50,000), naming `inBatches(1000)`, because a refusal issued after `returning *` is already holding what it is refusing |
666
+ | Rows read back | **only when something can still refuse them.** A `check` or a `unique` invariant is a constraint Postgres enforced on the statement, so the answer is a count and the statement carries no `returning *`. Only a JS-only rule (`kind: 'assert'`, `sql: null`) has to be judged on the result — and then the match is counted first and refused past `MAX_ASSERTED_ROWS` (50,000), naming `inBatches(1000)`, because a refusal issued after `returning *` is already holding what it is refusing. On Postgres the statement and that judgement share one transaction (a SAVEPOINT inside an open one), for `update(id, patch)` too, so a refused write is rolled back rather than left committed |
663
667
 
664
668
  ## Writing many rows
665
669
 
@@ -756,6 +760,7 @@ page.rows[0].author; // the member row, or null — always present
756
760
  | Unknown name | `X_PRELOAD_UNKNOWN_RELATION` at `preload()` itself, not a page later |
757
761
  | Shape | `belongsTo` attaches the row or `null`; `hasMany` an array — always present |
758
762
  | Statements | one extra per relation, resolved concurrently; naming one twice is one statement |
763
+ | Ceiling | `preload('<relation>', { max })` — the most related rows one relation may attach to one page. Default `MAX_PRELOADED_ROWS` (10,000, the largest page a read may ask for). Past it the read is **refused** (`X_INVARIANT_VIOLATED`), never truncated, and it reads at most one row past the ceiling to know. Naming a relation twice is still one statement: a later call that states `max` replaces the earlier one, as a second `.limit()` does, and one that states none leaves it. The refusal's cause spells the call to write (`.preload('<relation>', { max: n })`); its fix is `x entities describe <entity> --json` |
759
764
  | Tenancy | carried onto the related read only when the other entity's tenant column shares the name; otherwise `X_TENANCY_UNSCOPED` refuses the related read rather than guess |
760
765
  | Terminals | `page()`, `all()`, `one()` preload; `count()`, `countBy()` and `plan()` don't — none reads a row to attach one to |
761
766
 
@@ -775,11 +780,20 @@ database({ orgs, posts }, { driver: postgresDriver() }); // production
775
780
  | A predicate | `memory-match.ts`, by the column's declared KIND | the SQL `pg-sql.ts` compiles |
776
781
 
777
782
  Both answer the same predicate the same way, and the kind is what decides — never the JS type of
778
- the value: `bigint()`/`decimal()` hold decimal STRINGS and order by their digits (`2, 9, 10, 100`),
779
- a `uuid` compares case-insensitively because Postgres compares it as a value, `\` escapes a `%` or
780
- a `_` inside a `like`, and `in` takes a list or nothing (a scalar matches no rows; a list carrying
783
+ the value: `bigint()`/`decimal()` hold decimal STRINGS, order by their digits (`2, 9, 10, 100`) and
784
+ are EQUAL by value (`'2.50'` matches `'2.5'`, `2.5` and `'2.500'`; `'10'` matches `10`),
785
+ a `uuid` is ordered and equated case-insensitively because Postgres compares it as a value, `\` escapes a `%` or
786
+ a `_` inside a `like` (whose `_` is one CHARACTER, astral or not, and whose match is bounded by
787
+ pattern × value — never a backtracking regex), a unique declared as `invariant(name, c.unique([…]))`
788
+ is enforced by memory exactly as an index is, and `in` takes a list or nothing (a scalar matches no rows; a list carrying
781
789
  a `null` also matches the NULL rows, which `col = null` never does).
782
790
 
791
+ `compareByKind(kind, left, right)` and `sameValueOfKind(kind, left, right)` are that rule, exported
792
+ (`As of 2026-10`): `-1 | 0 | 1` and a boolean for two values of one column of a declared
793
+ `ColumnKind`, NULL sorting largest. `@ultimat3/query`'s live matcher and in-memory source call them
794
+ rather than keep a comparator of their own; `compare-parity.test.ts` runs the rule against both
795
+ drivers.
796
+
783
797
  `database()` called with no driver takes the process default, and `defaultDriver()` is that same
784
798
  object — the one seam a test harness needs, `As of 2026-08`:
785
799
 
@@ -803,14 +817,13 @@ returns the open transaction when there is one, so a repository call inside `wit
803
817
  joins it without being told — which is how a job's outbox row lands atomically with the write
804
818
  that enqueued it.
805
819
 
806
- `postgresDriver({ client })` pins one instead, for a test harness or `x db branch` — and a pinned
807
- repository used while a transaction is open is `X_REPO_CLIENT_PINNED`, `As of 2026-08`.
808
- `withTransaction` reserved a connection and ran `BEGIN` on it; a pinned repository sends straight
809
- to its own client, so the write would commit whatever the transaction decides and the read would
810
- miss what the transaction has written, both silently. It is refused rather than resolved because a
811
- `DbTx` does not name the client it was opened on: on a sharded app "the same connection" and "the
812
- same database" are two different questions and this layer can answer neither. `setDbClient(client)`
813
- plus an unpinned repository is the shape that joins.
820
+ `postgresDriver({ client })` pins one instead, for a test harness or `x db branch`. A pinned
821
+ repository joins a transaction that was opened ON its own client — `withTransaction(fn, { client })`,
822
+ `postgresTransactor({ client })`, `driver.transactor()` — because `tx.origin` says it is the same
823
+ database and the same reservation (`As of 2026-10-02`). Inside any OTHER open transaction it is
824
+ `X_REPO_CLIENT_PINNED`: a statement sent to the pinned client would commit whatever that
825
+ transaction decides and miss what it has written, both silently. `setDbClient(client)` plus an
826
+ unpinned repository is the shape that joins the ambient one.
814
827
 
815
828
  Every value is bound to `$n` and every identifier is resolved through the entity, so a column
816
829
  name can only be one the entity declared and a row value can never become SQL.
@@ -977,11 +990,11 @@ Two write verbs, because only the author knows which key identifies a row:
977
990
 
978
991
  | Context member | Use it when | A replay |
979
992
  |---|---|---|
980
- | `insert(entity, rows)` | the seed chose the ids — `id('post:tenancy')` is a UUID v5 of the label, so the same graph gets the same ids on every machine | one `on conflict … do nothing` statement per call; a stored row is left alone and counted `skipped` |
993
+ | `insert(entity, rows)` | the seed chose the ids — `id('post:tenancy')` is a UUID v5 of the label, so the same graph gets the same ids on every machine | one `on conflict … do nothing` statement per call; a stored row is left alone and counted `skipped`. |
981
994
  | `upsert(entity, { by, preserve? }, values)` | the table owns the id and only a natural key identifies the row | reads first, so an unchanged row is `'skipped'` with no statement; otherwise one `on conflict … do update`, which settles the race between two containers booting at once |
982
995
  | `exists(entity, where?)` / `count(entity, where?)` | bulk volume data, where the FILE is the unit of idempotency | the sentinel returns early and nothing is written |
983
996
  | `deleteWhere(entity, where)` | a scoped wipe before a regenerate | **refused on a soft-deleting entity**: the stamp keeps the row's unique key and no replay can clear it |
984
- | `id`, `now`, `environment`, `tier`, `dryRun`, `metrics` | — | `now` is one instant per run; `metrics` is `{ inserted, updated, skipped }` and `run()` returns it |
997
+ | `id`, `now`, `environment`, `tier`, `dryRun`, `metrics` | — | `now` is one instant per run; `metrics` is `{ inserted, updated, skipped }` and `run()` returns it. A **dry run is the real run, rolled back**: every verb writes inside one transaction from the driver's `transactor()`, so the metrics are what a real run reports — a key another tenant holds is skipped, a later verb sees an earlier one's rows — and nothing is kept. `Driver.transactor()` is required, so a hand-built driver that cannot undo does not compile; one that wraps another delegates (`transactor: () => inner.transactor()`) |
985
998
 
986
999
  Rules worth knowing before the first seed: `upsert` never overwrites `createdAt` (`preserve` names
987
1000
  other columns to spare); `upsert` on a tenant-scoped entity needs the tenant column inside `by`,
@@ -997,7 +1010,7 @@ database from its boot code has decided to, and a library that overruled that wo
997
1010
 
998
1011
  `X_ENTITY_DUPLICATE` · `X_INVARIANT_VIOLATED` · `X_TENANCY_UNSCOPED` ·
999
1012
  `X_TENANCY_ACTOR_MISMATCH` · `X_TENANCY_ACTOR_ORG_REQUIRED` · `X_TENANCY_CROSS_DENIED` ·
1000
- `X_DB_DRIFT` · `X_NOT_FOUND` · `X_WRITE_UNFILTERED` · `X_PATCH_EMPTY` ·
1013
+ `X_NOT_FOUND` · `X_WRITE_UNFILTERED` · `X_PATCH_EMPTY` ·
1001
1014
  `X_PRELOAD_UNKNOWN_RELATION` · `X_N_PLUS_ONE_QUERY` · `X_N_PLUS_ONE_WRITE` ·
1002
1015
  `X_RECORD_KEY_MISSING` · `X_ENTITY_SEALED_PREDICATE` · `X_ENTITY_SEALED_IN_VIEW`
1003
1016
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "23.0.0",
3
+ "version": "24.0.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,9 +33,9 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "dependencies": {
36
- "@ultimat3/core": "23.0.0",
37
- "@ultimat3/db": "23.0.0",
38
- "@ultimat3/schema": "23.0.0",
39
- "@ultimat3/time": "23.0.0"
36
+ "@ultimat3/core": "24.0.0",
37
+ "@ultimat3/db": "24.0.0",
38
+ "@ultimat3/schema": "24.0.0",
39
+ "@ultimat3/time": "24.0.0"
40
40
  }
41
41
  }
package/src/aggregate.ts CHANGED
@@ -68,23 +68,46 @@ export const notAggregatable = (
68
68
  : `${entityName}.${fn}('${candidates[0]}') # ${fn} takes one of: ${candidates.join(', ')}`,
69
69
  });
70
70
 
71
+ /** One amount is one currency at one scale. Two of either have no common unit. */
72
+ export interface MoneyUnit {
73
+ readonly currency: string;
74
+ readonly scale: number | null;
75
+ }
76
+
71
77
  /**
72
78
  * Money crossing currencies has no sum, no minimum and no maximum: 100 JPY and 100 EUR are not
73
79
  * comparable and adding them answers a number in no currency at all. Both drivers count the
74
- * distinct currencies of the rows they are about to aggregate and refuse past one, rather than
75
- * silently answering in whichever currency happened to come first.
80
+ * distinct units of the rows they are about to aggregate and refuse past one, rather than
81
+ * silently answering in whichever happened to come first.
82
+ *
83
+ * Two situations under the one code, told apart because they do not share a repair. Several
84
+ * CURRENCIES are narrowed by a predicate on the currency. One currency at several SCALES is not —
85
+ * `{ minor: 5, currency: 'USD' }` is five cents and the same at `scale: 6` is five millionths of a
86
+ * dollar, a filter on `'USD'` keeps both, and the scale is not a part a predicate can name
87
+ * (`MONEY_PARTS`) — so those rows are folded in the app, at a scale somebody chose.
76
88
  */
77
89
  export const mixedCurrency = (
78
90
  entityName: string,
79
91
  fn: AggregateFn,
80
92
  property: string,
81
- currencies: readonly string[],
82
- ): EntityError =>
83
- new EntityError({
93
+ units: readonly MoneyUnit[],
94
+ ): EntityError => {
95
+ const currencies = [...new Set(units.map((unit) => unit.currency))].sort();
96
+ const [only] = currencies;
97
+ if (currencies.length === 1 && only !== undefined) {
98
+ const scales = units.map((unit) => (unit.scale === null ? 'the currency default' : unit.scale));
99
+ return new EntityError({
100
+ code: 'X_AGGREGATE_MIXED_CURRENCY',
101
+ cause: `${entityName}.${fn}('${property}') covers ${scales.length} scales of ${only} (${scales.join(', ')}) — one minor unit is a different amount at each, so they have no common unit`,
102
+ fix: `for await (const rows of ${entityName}.inBatches(1000)) { … } # fold row.${property} at ONE scale in the app: the scale is not a part a predicate can name`,
103
+ });
104
+ }
105
+ return new EntityError({
84
106
  code: 'X_AGGREGATE_MIXED_CURRENCY',
85
- cause: `${entityName}.${fn}('${property}') covers ${currencies.length} currencies (${[...currencies].sort().join(', ')}) — they have no common unit`,
86
- fix: `${entityName}.andWhere('${property}.currency', 'eq', '${[...currencies].sort()[0]}').${fn}('${property}') # one currency per call, or countBy('${property}.currency') first`,
107
+ cause: `${entityName}.${fn}('${property}') covers ${currencies.length} currencies (${currencies.join(', ')}) — they have no common unit`,
108
+ fix: `${entityName}.andWhere('${property}.currency', 'eq', '${only}').${fn}('${property}') # one currency per call, or countBy('${property}.currency') first`,
87
109
  });
110
+ };
88
111
 
89
112
  /** Digits only, optionally signed, optionally with a fraction. What `decimal()` hands back. */
90
113
  const DECIMAL_TEXT = /^-?\d+(\.\d+)?$/;
@@ -180,12 +203,6 @@ export const aggregateColumnOf = <Row>(
180
203
  return column;
181
204
  };
182
205
 
183
- /** One amount is one currency at one scale. Two of either have no common unit. */
184
- export interface MoneyUnit {
185
- readonly currency: string;
186
- readonly scale: number | null;
187
- }
188
-
189
206
  export const assertOneUnit = <Row>(
190
207
  entity: EntityCore<Row>,
191
208
  fn: AggregateFn,
@@ -195,19 +212,11 @@ export const assertOneUnit = <Row>(
195
212
  const seen = new Map<string, MoneyUnit>();
196
213
  for (const unit of units) seen.set(`${unit.currency}/${unit.scale ?? ''}`, unit);
197
214
  if (seen.size > 1) {
198
- throw mixedCurrency(entity.$name, fn, property, [...seen.values()].map(unitLabel));
215
+ throw mixedCurrency(entity.$name, fn, property, [...seen.values()]);
199
216
  }
200
217
  return [...seen.values()][0];
201
218
  };
202
219
 
203
- /**
204
- * What the refusal names. The scale rides along because it is half of what makes two amounts
205
- * incomparable: `{ minor: 5, currency: 'USD' }` is five cents and `{ minor: 5, currency: 'USD',
206
- * scale: 6 }` is five millionths of a dollar, and adding them is a 10,000x error with no symptom.
207
- */
208
- const unitLabel = (unit: MoneyUnit): string =>
209
- unit.scale === null ? unit.currency : `${unit.currency}@${unit.scale}`;
210
-
211
220
  /**
212
221
  * The minor unit an aggregate answers with, narrowed exactly where every other reader of that
213
222
  * column narrows it. The column is `bigint` and `MoneyValue.minor` is a `number`, so a total past
package/src/bulk-write.ts CHANGED
@@ -11,6 +11,7 @@ import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
11
11
  import { EntityError, invariantViolated } from './errors';
12
12
  import { columnsOf } from './pg-row';
13
13
  import type { RowPatch } from './types';
14
+ import { uniqueConstraints } from './unique-constraints';
14
15
 
15
16
  /**
16
17
  * Postgres binds at most 65535 parameters in one statement and a multi-row insert spends
@@ -134,22 +135,12 @@ const crossTenantUpsert = (
134
135
  });
135
136
 
136
137
  /**
137
- * Every conflict target Postgres could infer an index for. Three sources, because this framework
138
- * has three ways to declare one unique index and a target refused for being declared the "wrong"
139
- * way would send its author to add a SECOND declaration of a constraint they already wrote:
140
- * the primary key, `unique()`/`indexes: [{ unique: true }]` (both land in `$indexes`), and
141
- * `invariant(name, c.unique([…]))`, which emits its `create unique index` out of `$invariants`.
142
- *
143
- * A partial unique index is excluded from all three: its predicate would have to be repeated in
144
- * the `on conflict` clause, which this layer does not spell. `bindInvariant` stamps that `where`
145
- * on a soft-deleting entity, so the same one rule covers both lists.
138
+ * Every conflict target Postgres could infer an index for: the primary key, and every non-partial
139
+ * unique the entity declares by either spelling (`unique-constraints.ts`).
146
140
  */
147
141
  const uniqueTargets = <Row>(entity: EntityCore<Row>): readonly (readonly string[])[] => [
148
142
  insertColumns(entity, entity.$primaryKey),
149
- ...entity.$indexes.filter((i) => i.unique && i.where === undefined).map((i) => i.columns),
150
- ...entity.$invariants
151
- .filter((i) => i.kind === 'unique' && i.where === undefined)
152
- .map((i) => i.columns),
143
+ ...uniqueConstraints(entity).map((unique) => unique.columns),
153
144
  ];
154
145
 
155
146
  const sameColumns = (left: readonly string[], right: readonly string[]): boolean =>
package/src/column.ts CHANGED
@@ -240,8 +240,43 @@ export const makeColumn = <T, Optional extends boolean>(
240
240
  * the derived name is checked, because that runs once per column at `entity()` rather than on
241
241
  * every statement.
242
242
  */
243
+ const isPhysicalName = (name: string): boolean =>
244
+ /^[a-z_][a-z0-9_$]*$/.test(name) && name.length <= 63;
245
+
246
+ /** The nearest legal spelling of a name, so the fix hands back a value and never a placeholder. */
247
+ const physicalSpelling = (name: string): string => {
248
+ const snake = name
249
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
250
+ .toLowerCase()
251
+ .replace(/[^a-z0-9_$]+/g, '_')
252
+ .replace(/^_+|_+$/g, '');
253
+ return (/^[a-z_]/.test(snake) ? snake : `t_${snake}`).slice(0, 63);
254
+ };
255
+
256
+ /** A name as a TypeScript literal that parses whatever it holds — it is pasted into source. */
257
+ const asSource = (name: string): string =>
258
+ /^[\w-]+$/.test(name) ? `'${name}'` : JSON.stringify(name);
259
+
260
+ /**
261
+ * The TABLE's physical name — `init.table`, or the entity name when none is declared. The same
262
+ * rule as a column's, and its own refusal: it used to go through `assertColumnName`, which called
263
+ * a table "not a physical column name" and handed back `.column('created_at')`, an edit to a
264
+ * column that was never the problem. The repair is at `entity(name, { table })`, and the entity
265
+ * NAME stays what it was — cache tags and policies key on it.
266
+ */
267
+ export const assertTableName = (entityName: string, table: string): string => {
268
+ if (!isPhysicalName(table)) {
269
+ refuseColumn(
270
+ 'table-name',
271
+ `"${table}" is not a physical table name: lower-case letters, digits and underscores, at most 63 of them`,
272
+ `entity(${asSource(entityName)}, { table: '${physicalSpelling(table)}', columns }) # table is the physical name; the entity name is unchanged`,
273
+ );
274
+ }
275
+ return table;
276
+ };
277
+
243
278
  export const assertColumnName = (name: string): string => {
244
- if (!/^[a-z_][a-z0-9_$]*$/.test(name) || name.length > 63) {
279
+ if (!isPhysicalName(name)) {
245
280
  refuseColumn(
246
281
  'column-name',
247
282
  `"${name}" is not a physical column name: lower-case letters, digits and underscores, at most 63 of them`,
@@ -58,6 +58,25 @@ const canonicalDigits = (digits: string): string => {
58
58
  return negative && whole !== '0' ? `-${whole}` : whole;
59
59
  };
60
60
 
61
+ const INT8_MIN = -(2n ** 63n);
62
+ const INT8_MAX = 2n ** 63n - 1n;
63
+
64
+ /**
65
+ * Whole digits inside `int8`, in Postgres's spelling. Memory stored a 40-digit value the server
66
+ * answers `22003 — bigint out of range` for, so a row every app test wrote was one production
67
+ * refuses. The digits are never echoed: a column message reaches the log line.
68
+ */
69
+ const int8 = (digits: string): string => {
70
+ const value = BigInt(digits);
71
+ return value >= INT8_MIN && value <= INT8_MAX
72
+ ? canonicalDigits(digits)
73
+ : refuseColumn(
74
+ 'bigint',
75
+ 'the value is past the int8 range (-2^63 to 2^63 - 1), which Postgres answers 22003 for',
76
+ 'declare the column decimal({ precision: 40, scale: 0 }) — a numeric holds whole numbers past int8 — and run x db gen "widen the bigint to numeric"',
77
+ );
78
+ };
79
+
61
80
  /**
62
81
  * An ACCEPTED decimal in Postgres's spelling: leading zeros stripped, `-0` and `-0.00` unsigned,
63
82
  * and a short fraction padded to the column's scale (`numeric(8, 2)` answers `7.50` for `7.5`).
@@ -86,7 +105,7 @@ const canonicalDecimal = (text: string, scale: number | undefined): string => {
86
105
  */
87
106
  export const bigint = (): Column<string> =>
88
107
  column<string>('bigint', (value) => {
89
- if (typeof value === 'bigint') return value.toString();
108
+ if (typeof value === 'bigint') return int8(value.toString());
90
109
  if (typeof value === 'number') {
91
110
  return Number.isSafeInteger(value)
92
111
  ? String(value)
@@ -97,7 +116,7 @@ export const bigint = (): Column<string> =>
97
116
  );
98
117
  }
99
118
  return typeof value === 'string' && DIGITS.test(value)
100
- ? canonicalDigits(value)
119
+ ? int8(value)
101
120
  : refuseColumn(
102
121
  'bigint',
103
122
  `expected whole digits, ${got(value)}`,
@@ -167,7 +186,9 @@ export const decimal = (options: DecimalOptions = {}): Column<string> => {
167
186
  `Number(value).toFixed(${scale}) at the call site decides the rounding, or widen the column to decimal({ precision: ${(precision ?? fraction) + fraction - scale}, scale: ${fraction} }) and run x db gen "widen the numeric"`,
168
187
  );
169
188
  }
170
- const whole = (digits[0] ?? '').replace(/^0+(?=\d)/, '').length;
189
+ // A whole part of `0` is NO digit: `numeric(2, 2)` holds `0.50`, and counting the zero
190
+ // refused every legal value of a `numeric(p, p)` column.
191
+ const whole = (digits[0] ?? '').replace(/^0+/, '').length;
171
192
  if (precision !== undefined && whole > precision - (scale ?? 0)) {
172
193
  return refuseColumn(
173
194
  'numeric',
package/src/columns.ts CHANGED
@@ -109,6 +109,9 @@ const parseInstant = (value: unknown): Date => {
109
109
  export const timestamp = (): TimestampColumn =>
110
110
  makeTimestamp<false>({ ...BARE, kind: 'timestamptz' }, parseInstant, false);
111
111
 
112
+ /** The column's CHECK (`~ '^https?://'`), before the scheme is lower-cased. */
113
+ const STORED_HTTP = /^https?:\/\//i;
114
+
112
115
  /**
113
116
  * An absolute http(s) URL, validated on write rather than on render: a bad URL stored once is
114
117
  * served to every reader, and `<img src>` fails silently in the browser.
@@ -126,7 +129,11 @@ export const url = (): Column<string> =>
126
129
  // The scheme is stored in its canonical LOWER case: the CHECK is `~ '^https?://'`, so
127
130
  // `HTTPS://a.b` was stored by memory and refused by Postgres. Only the scheme is
128
131
  // rewritten — the rest of the URL is the caller's, byte for byte.
129
- if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
132
+ // And the STORED text must itself open with it: WHATWG strips leading whitespace and
133
+ // embedded tabs and mends `https:/a.b` before `protocol` is read, while the column keeps
134
+ // the caller's bytes — which the CHECK then refused as a raw 23514.
135
+ const http = parsed.protocol === 'http:' || parsed.protocol === 'https:';
136
+ if (http && STORED_HTTP.test(value)) {
130
137
  return value.replace(/^https?(?=:)/i, (scheme) => scheme.toLowerCase());
131
138
  }
132
139
  } catch {
@@ -28,6 +28,15 @@ const sameElement = (left: unknown, right: unknown): boolean => {
28
28
  return left === right;
29
29
  };
30
30
 
31
+ /**
32
+ * Two elements of a SQL ARRAY, equal — and a NULL element is equal to nothing, itself included.
33
+ * `array['a', null] @> array[null]` and `array[null] && array[null]` are both FALSE in Postgres:
34
+ * the element comparison is `=`, and `NULL = NULL` is unknown. Arrays only — a `jsonb` `null` is a
35
+ * VALUE and equals itself, which is why `jsonContains` keeps `sameElement`.
36
+ */
37
+ const sameArrayElement = (left: unknown, right: unknown): boolean =>
38
+ !isNull(left) && !isNull(right) && sameElement(left, right);
39
+
31
40
  /**
32
41
  * `left @> right` for `jsonb`, to the letter of Postgres' definition — each clause below measured
33
42
  * on Postgres 16 rather than read off a summary, because three of them are easy to state wrongly:
@@ -67,7 +76,7 @@ const contains = (left: unknown, right: unknown, top: boolean): boolean => {
67
76
  * by every array, which is what Postgres answers.
68
77
  */
69
78
  export const arrayContains = (left: readonly unknown[], right: readonly unknown[]): boolean =>
70
- right.every((item) => left.some((candidate) => sameElement(candidate, item)));
79
+ right.every((item) => left.some((candidate) => sameArrayElement(candidate, item)));
71
80
 
72
81
  /**
73
82
  * `left && right`: they share at least one element. Arrays only — `jsonb` has no `&&` — and the
@@ -75,7 +84,7 @@ export const arrayContains = (left: readonly unknown[], right: readonly unknown[
75
84
  * contained by everything.
76
85
  */
77
86
  export const arrayOverlaps = (left: readonly unknown[], right: readonly unknown[]): boolean =>
78
- right.some((item) => left.some((candidate) => sameElement(candidate, item)));
87
+ right.some((item) => left.some((candidate) => sameArrayElement(candidate, item)));
79
88
 
80
89
  /**
81
90
  * The memory half of `col ? key`. The SQL side emits the OPERATOR, schema-qualified —
@@ -88,6 +97,10 @@ export const arrayOverlaps = (left: readonly unknown[], right: readonly unknown[
88
97
  * Three shapes, all of them Postgres': a top-level key of an object, a string ELEMENT of an array,
89
98
  * and a string value equal to the key. A number never matches — `'[1]'::jsonb ? '1'` is false
90
99
  * there, and a `String(item) === key` here would have made it true.
100
+ *
101
+ * The KEY is a string or the test matches no row, in both drivers: `pg-sql.ts` compiles any other
102
+ * operand to no rows rather than binding `String(key)`, which found a key named `"null"` for a
103
+ * `null` operand and `"1"` for the number `1`.
91
104
  */
92
105
  export const jsonHasKey = (value: unknown, key: unknown): boolean => {
93
106
  if (typeof key !== 'string' || isNull(value)) return false;
package/src/database.ts CHANGED
@@ -2,11 +2,11 @@
2
2
  // writes a repository class per entity, and nobody can reach a table that is not in the set.
3
3
 
4
4
  import type { EntityCore } from './entity';
5
- import { memoryRepo } from './memory-repo';
5
+ import { memoryRepo, memoryTransactor } from './memory-repo';
6
6
  import type { RelatedTables } from './preload';
7
7
  import type { Table } from './query';
8
8
  import { tableFor } from './query';
9
- import type { Repo } from './repo';
9
+ import type { Repo, Transactor } from './repo';
10
10
  import { observedRepo } from './row-observer';
11
11
 
12
12
  export type EntitySet = Readonly<Record<string, EntityCore>>;
@@ -25,6 +25,12 @@ export interface Driver {
25
25
  * `reset()` eventually would. A harness therefore asks and does not assume: `driver.reset?.()`.
26
26
  */
27
27
  reset?(): void;
28
+ /**
29
+ * The transactor that undoes THIS driver's writes. Required: a seed dry run writes for real and
30
+ * rolls back through it, and a driver that could not undo would be found out at run time. A
31
+ * driver that wraps another delegates — `transactor: () => inner.transactor()`.
32
+ */
33
+ transactor(): Transactor;
28
34
  }
29
35
 
30
36
  export interface DatabaseOptions {
@@ -54,6 +60,7 @@ export const memoryDriver = (): Driver => {
54
60
  reset() {
55
61
  for (const reset of resets) reset();
56
62
  },
63
+ transactor: () => memoryTransactor(),
57
64
  };
58
65
  };
59
66