@ultimat3/entity 22.15.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()`
@@ -87,37 +95,13 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
87
95
  (`close()` = `AsyncGenerator.return()`), `.cursor` is advanced before the yield, no empty batch, and
88
96
  three refusals on the chain (size, `limit()` alongside, an order no cursor can carry — judged by
89
97
  exported `totalOrder`).
90
- - **A grouped count means one thing in both drivers, and `count-by.ts` is where that one thing is
91
- written.** `countBy(column)` is the aggregate a `count()` per row is the N+1 of, so both drivers
92
- call `groupColumnOf` before their statement exists and `countsFrom` after their rows are in — a
93
- rule added to `pg-driver.ts` or to `memory-repo.ts` alone is exactly the drift that file exists
94
- to prevent. **Groupable kinds are a closed set**: `uuid`, `text`, `char`, `boolean`, `integer`,
95
- `bigint`. A `timestamptz` is a `Date`, a `jsonb` is an object and `money` is two physical columns
96
- — a `Map` compares a non-primitive key by identity, so any of those would file rows under a key
97
- no caller can look up again and the result would be a map that only ever answers `undefined`. The
98
- refusal is `X_INVARIANT_VIOLATED` naming a column of *this* entity that is groupable, never
99
- `x entity explain`: what repairs it is one edit to the call, and the entity is the only place the
100
- replacement column lives. **The bound is a refusal, not a truncation.** The statement asks for
101
- `MAX_GROUPS + 1` groups — the trick a page already uses when it reads one row past its limit — and
102
- that extra group is what says the answer was never going to fit, so `countsFrom` throws with the
103
- `andWhere(…, 'in', <values>)` that bounds it. Truncating would hand back a map that reads exactly
104
- like a complete one, and a caller recounting from it would write the wrong number to every row it
105
- missed. **Absent is not `0`**: a value nothing matched has no entry, because that is what
106
- `group by` returns and it is the only way a caller can tell "none" from "never asked" — the
107
- `?? 0` is theirs to write, and inventing it here would answer for keys the table has never seen.
108
- **NULL is one group**, keyed `null`: the memory driver reads the property as `?? null` so it lands
109
- where Postgres puts its NULL rows, while `0`, `''` and `false` stay the values they are. **The
110
- order is applied after the rows are in, never in SQL** — a hash aggregate returns groups in
111
- whatever order it built them and a `Map` filled row by row returns insertion order, so an
112
- `order by` in the statement would let the two drivers disagree about a result they agree on;
113
- sorting groups (never rows) costs nothing at this size and is what puts the largest bucket at the
114
- front. **Both output names are fixed aliases** — `group_value` and `group_count` in
115
- `countByStatement` (`pg-sql.ts`) — because an entity is free to declare a column called `count`,
116
- and the un-aliased form would then return two outputs of one name; the grouped value is re-parsed
117
- by the column that declared it, since `int8` arrives as a string and would otherwise key the map
118
- by text where memory keys it by a `bigint`. **Nothing new to declare**: no `groupBy()` builder and
119
- no error code of its own — it is a terminal on the chain that already exists, over exactly the
120
- rows `count()` counts.
98
+ - **A grouped count means one thing in both drivers, written in `count-by.ts`**: both call
99
+ `groupColumnOf` before the statement and `countsFrom` after the rows. Groupable kinds are a closed
100
+ set (`uuid`, `text`, `char`, `boolean`, `integer`, `bigint`); the bound is a REFUSAL at
101
+ `MAX_GROUPS + 1`, never a truncation; absent is not `0`; NULL is one group keyed `null`; the order
102
+ is applied after the rows are in, never in SQL; `group_value` / `group_count` are fixed aliases.
103
+ The refusal names a groupable column of THIS entity, never `x entity explain`.
104
+ The argument for each: [`docs/history/entity.md`](../../docs/history/entity.md).
121
105
  - **The codec is `@ultimat3/core`'s**, reached through `cursorFor(entity, plan, row, id)` and
122
106
  `seekFrom(entity, plan)`; **`assertSeekable` runs in `planFor`**, before a statement exists. A
123
107
  `timestamptz` sort key is refused when its `<column>$US` alias would pass 63 bytes. A cursor is bound
@@ -132,12 +116,12 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
132
116
  - **The four aggregates** (`sum`, `avg`, `min`, `max`) share `aggregate.ts`
133
117
  (`aggregate-fold.ts` memory, `aggregate-decode.ts` Postgres): never a float; `avg` at `AVG_SCALE` (6),
134
118
  half away from zero; `null` for an empty set. Refused: `min`/`max` on text, `avg` over money
135
- (`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
136
120
  past ±2^53. **`approximateCount()` is `reltuples`**; a filtered chain or tenant-scoped entity is
137
121
  `X_APPROXIMATE_COUNT_FILTERED`; `null` for an unanalysed table.
138
122
  - **`json()` / `arrayOf()` are filterable** (`contains`, `contained-by`, `overlaps`, `has-key`) with
139
- Postgres' measured meaning in `containment.ts`; `has-key` emits `operator(pg_catalog.?)` (indexable),
140
- 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.
141
125
  - **A GIN index is declarable** (`using: 'gin'`; the set is `@ultimat3/db`'s `INDEX_METHODS`); absent
142
126
  is `btree` byte for byte; the method joins the name discriminator only when declared; GIN cannot be
143
127
  unique or ordered (refused here). `pg-containment.live.test.ts`.
@@ -169,7 +153,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
169
153
  local renderers are deleted). An untranslatable predicate is `kind: 'assert'`, `sql: null`.
170
154
  **`InvariantDescription.columns` is projected** (`describe-invariant.test.ts`).
171
155
  - **The two halves must AGREE, term by term** (`expr.ts`): `matches(/…/i)` is `~*`, other flags are
172
- 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
173
159
  total members**; `iff(a, b)` renders `(a) = (b)` — measured as the safe direction
174
160
  (`pg-invariant-null.live.test.ts`); `iff` is a function, and a `unique` operand is refused with the
175
161
  `c.unique([…])` fix built through `JSON.stringify`. One app-only operand makes the whole rule
@@ -224,7 +210,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
224
210
  - **`deleteWhere` / `updateWhere` are the only filtered writes**: empty filter `X_WRITE_UNFILTERED`,
225
211
  empty patch `X_PATCH_EMPTY` (undefined values dropped first); soft delete respected; both return a
226
212
  count; rows come back only when a JS-only invariant needs them (`hasJsOnlyInvariant`), counted first
227
- 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
228
215
  required.
229
216
  - **Every instant the write path stamps comes from `entityNow()`** (`clock.ts`, `ctx.clock`).
230
217
  **`touch()` in `query.ts` is the ONE place `onUpdateNow()` columns are stamped.**
@@ -238,7 +225,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
238
225
  - **A seed is replayable**: `SeedContext.insert` is one `upsertAll(…, { onMatch: 'nothing' })` per
239
226
  call; a generated PK the row does not name is refused. `upsert(entity, { by }, values)` keys on a
240
227
  natural key, reads first for the report, writes one `on conflict … do update`, preserves `createdAt`.
241
- **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.
242
231
  - **`setRowObserver` reports committed row changes above the driver** — one per process (returns the
243
232
  replaced one), applied by `database()`, one comparison per write when unset, `before` only when the PK
244
233
  is `id`, a filtered write is `onBulk`. Not a second change-feed path.
@@ -248,6 +237,36 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
248
237
  after the refusal from a tenant-scoped `findById`; no DDL beyond `enumerated()`'s CHECK; a machine
249
238
  column may not be nullable; `whyNot` asks unknown → terminal → legal list.
250
239
 
240
+ ## Do not regress — sealed columns
241
+
242
+ - **`sealed-repo.ts` is the ONE seam**: `memoryRepo()` and `postgresRepo()` both return through
243
+ `sealedRepo(entity, repo)`, which seals rows and patches going in, opens rows coming out and
244
+ rewrites or refuses every predicate. A driver never seals for itself; an entity with no sealed
245
+ column gets its repository back untouched. The cipher is core's (`scripts/seal-calls.ts`).
246
+ - **A sealed column's `$parse` passes a sealed string and judges a plaintext**; `decodeRow` parses
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.
249
+ - **No reading of an unsealed value.** Not sealed is `X_SEAL_INVALID`; existing plaintext is
250
+ expand → `backfill()` → contract (README). No `legacy` option.
251
+ - **Purpose is `entity:<table>.<column>`, physical names** (`sealed.ts`). **Sealing is not on
252
+ `$describe()`**: it changes no DDL, so it must not move the drift hash — the manifest and
253
+ `x doctor` read `sealedFields(entity)`. Only `check`/`length` leave the description.
254
+ - **A sealed property on a repository row is SERVER-ONLY** — own, readable, writable, NOT
255
+ enumerable (`serverOnly`, `sealed.ts`; set by `openRow`). Every generic serialiser omits it, which
256
+ is what holds a `query`'s rows, a loose `output`, a log line, an island prop, a job payload and a
257
+ cache tier — none of them parses a row. So ABOVE the seam a row is copied with `copyRow` /
258
+ `pickRow` (descriptors), never `{ ...row }`, `Object.assign({}, row)` or `out[k] = row[k]`:
259
+ `preload.ts`, `select()` and `touch()` in `query.ts`. `sealRow` reads each sealed property BY NAME,
260
+ so a row passed back whole is written whole. A required one missing on insert is `sealedMissing`
261
+ (`X_INVARIANT_VIOLATED`, from `$parse` and from the seam), naming the spread. `sealed.test.ts`.
262
+ - **Nothing sealed leaves**: `$schema` (node and parsed value; typed `WireRow` on `Entity`,
263
+ `unknown` on `EntityCore`), `$view` (`X_ENTITY_SEALED_IN_VIEW`), `rowsOf`, the row observer, and
264
+ the repository row itself (above). `sealed-egress.test.ts` here and one per consuming package.
265
+ - **Opaque: NULL tests only. `lookup`: also `eq` / `in`, `.unique()`, an unordered index.**
266
+ Refused at the type (`SealedKeysOf<C>` on `ReadBuilder`, `type-pins.ts`) AND at runtime
267
+ (`X_ENTITY_SEALED_PREDICATE`). A filtered write's FILTER refuses both kinds. Declaration rules
268
+ are `sealed-column.ts` (the chain) and `sealed-declaration.ts` (the entity).
269
+
251
270
  ## Do not regress — records and tests
252
271
 
253
272
  - **An entity row on the client is a RECORD, derived** (`row-schema.ts`, `ENTITY_BRAND` =
@@ -256,7 +275,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
256
275
  `persist` defaults `false`, read only off the projection. **The browser path is
257
276
  `@ultimat3/entity/record`**, whose modules import `entity-error.ts`, never `errors.ts`
258
277
  (`record-bundle.test.ts`).
259
- - 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.
260
279
  - **Tests restore the registry with a FILE-scope `afterAll(() => { clearRegistry(); })`** — matched on
261
280
  exact text by `live-registry-cleanup.test.ts`; never inside a skippable suite's teardown.
262
281
 
@@ -268,9 +287,11 @@ Each file's header states its one job. The map, by area: `types.ts` (derivation;
268
287
  `entity.ts` / `describe.ts` / `index-name.ts` / `search.ts`, `state-machine.ts` / `transition.ts`,
269
288
  `feature-errors.ts`, `view.ts` / `row-schema.ts` / `record-projection.ts` / `record-key.ts` /
270
289
  `rows-of.ts` / `record-table.ts` / `record.ts`, `entity-error.ts` / `errors.ts`, `query.ts` /
271
- `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` /
272
292
  `cross-tenant.ts`, `plan.ts` / `cursor.ts` / `batch.ts`, `pg-driver.ts` / `coalesce.ts` /
273
- `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` /
274
295
  `pg-row.ts`, `row-observer.ts` / `write-tag.ts` (a keyed request's write names itself in the WAL via
275
296
  `pg_logical_emit_message`), `registry.ts` / `relations.ts` / `n-plus-one.ts` / `seed.ts`,
276
297
  `type-pins.ts`, `live-registry-cleanup.test.ts`.
package/README.md CHANGED
@@ -110,13 +110,124 @@ 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
- `.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)`.
116
+ `.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)` ·
117
+ `.sealed()` (`text()` only).
117
118
  Physical names are derived from the property key (`orgId` → `org_id`); a name is written once, or
118
119
  never — `.column()` is the exception, and it exists for tables this framework did not create.
119
120
 
121
+ ## A sealed column
122
+
123
+ A credential column is declared `.sealed()`. The row type stays `string`; the database never sees
124
+ the plaintext.
125
+
126
+ ```ts
127
+ import { database, entity, text, uuid } from '@ultimat3/entity';
128
+
129
+ export const connections = entity('connections', {
130
+ columns: {
131
+ id: uuid().primaryKey(),
132
+ label: text({ max: 80 }),
133
+ password: text({ max: 200 }).sealed(), // opaque: read and written, never compared
134
+ token: text().nullable().sealed(),
135
+ email: text().sealed({ lookup: true }).unique(), // deterministic: equality only
136
+ },
137
+ });
138
+
139
+ const db = database({ connections });
140
+
141
+ export async function passwordFor(email: string): Promise<string | null> {
142
+ const row = await db.connections.where({ email }).one();
143
+ return row?.password ?? null; // the plaintext — on the server, and nowhere else
144
+ }
145
+ ```
146
+
147
+ | Fact | |
148
+ |---|---|
149
+ | Builder | `text()` only — `text().sealed()`, in any order with `.nullable()`, `.unique()`, `.column()`. A sealed number or date does not compile |
150
+ | Stored | `x1.<keyId>.<iv>.<ciphertext+tag>` from `@ultimat3/core`'s `seal()`, in a plain `text` column. No new SQL type; a `text({ max })` bound is checked against the plaintext before sealing and emits no CHECK |
151
+ | Key | the master key `x secrets` manages. No key is `X_SEAL_KEY_MISSING` — the plaintext is never stored instead |
152
+ | Purpose | derived: `entity:<table>.<column>`, physical names. Never stated, so two columns cannot share one; a value copied between sealed columns does not open. Renaming the table or the column is a re-seal — pin the name with `.column()` |
153
+ | Both drivers | `memoryDriver()` seals too; `memoryRepo(entity, seed)` refuses a seed for a sealed entity (`insertAll` instead) |
154
+ | The seam | `sealedRepo(entity, repo)` — both driver factories return through it, so `database()`'s handle and a hand-held `postgresRepo(entity)` agree. A driver written outside this package returns through it too |
155
+ | Cost | the key ring is resolved ONCE per repository call, however many values it seals or opens — a 500-row page is one resolution, not a thousand |
156
+ | Retry | `X_ENTITY_SEALED_PREDICATE` and `X_ENTITY_SEALED_IN_VIEW` are registered `terminal` (`ENTITY_ERROR_RETRY`): a job stops on its first attempt |
157
+
158
+ **What leaves the server: nothing.**
159
+
160
+ | Path | Sealed column |
161
+ |---|---|
162
+ | `entity.$schema` (an action's or query's `output`) | absent from the node and from the parsed value; the type is `WireRow` — the row without it |
163
+ | `entity.$view([...])` | `X_ENTITY_SEALED_IN_VIEW` |
164
+ | a record (`rowsOf`, `recordProjection`, the `persist: true` browser store) | dropped from every collected row; `projection.sealed` lists them |
165
+ | a change reported to `setRowObserver` | dropped from `before` and `after` |
166
+ | `decodeRow` on a write-ahead-log row | the stored string, never the plaintext (`@ultimat3/realtime` drops the property before a change leaves) |
167
+ | a repository row serialised whole — a `query`'s rows, a loose `output`, a log line, an error's `meta`, an island prop, a job payload, a cache tier | absent: the property is **server-only** (below) |
168
+ | `{ token: row.token }` — the secret named by hand | leaves. Naming it is the one read there is |
169
+ | `$parse`, `$row`, the repository | the whole row, plaintext included — server code only |
170
+
171
+ **A sealed property is server-only**, `As of 2026-10`: own, readable and writable, **not
172
+ enumerable**. `row.password` is the plaintext; nothing that walks the row sees it.
173
+
174
+ | | |
175
+ |---|---|
176
+ | Reads it | `row.password`, `const { password } = row`, `update(id, row)` and `insert(row)` with the row passed whole |
177
+ | Does not | `JSON.stringify(row)`, `{ ...row }`, `Object.keys` / `entries` / `assign`, `structuredClone(row)` |
178
+ | Prints it | `console.log(row)` / `Bun.inspect(row)` — log through `logger` |
179
+ | A spread patch | `update(id, { ...row, label })` leaves the stored secret alone |
180
+ | A spread insert, required column | `X_INVARIANT_VIOLATED` naming the column and the rewrite: `insert({ ...row, password: row.password })` |
181
+ | A spread insert, **nullable** column | stores NULL — silent; an absent nullable column is a legal insert. Name it: `{ ...row, token: row.token }` |
182
+ | `select({ password: true })`, `preload()` | the row keeps it, still server-only |
183
+
184
+ **Testing a sealed row.** `toEqual` walks enumerable properties, so it cannot see the secret:
185
+ `expect(row).toEqual({ …, password })` fails with an empty diff, and `expect(a).toEqual(b)` passes
186
+ over a wrong one. Write one of:
187
+
188
+ | Assertion | Compares |
189
+ |---|---|
190
+ | `expect(row).toEqualRow({ id, label: 'primary', password: 'hunter2' })` | every own property (`@ultimat3/testing`) |
191
+ | `expect(row.password).toBe('hunter2')` | the one property, by name |
192
+ | `expect(row).toMatchObject({ password: 'hunter2' })` | the named subset |
193
+
194
+ `toEqualRow` compares every own property and names what differs — a server-only one by name,
195
+ never by value. No key is needed in a test: `@ultimat3/testing/preload` installs a throwaway one
196
+ when the app supplied none.
197
+
198
+ **What the database may compare: nothing, or equality.**
199
+
200
+ | Use | `.sealed()` | `.sealed({ lookup: true })` |
201
+ |---|---|---|
202
+ | `where` / `andWhere` `eq`, `in` | compile error; `X_ENTITY_SEALED_PREDICATE` at runtime | allowed — rewritten to the stored string |
203
+ | `andWhere(column, 'is-null')` / `'is-not-null'` | allowed — `null` is stored as NULL, never sealed, so presence is not a secret | allowed |
204
+ | any other operator, `orderBy`, `countBy`, an aggregate, a cursor | refused | refused |
205
+ | `deleteWhere` / `updateWhere` **filter** | refused | refused — find the row, then write by id |
206
+ | `.unique()`, a unique index, an upsert conflict target | refused | allowed |
207
+ | a plain index | refused | allowed, unordered |
208
+ | an invariant other than `unique` | refused | refused — a CHECK would read ciphertext |
209
+ | `.default()`, `.primaryKey()`, `.tenant()`, `.references()`, `.searchable()` | refused on the chain | refused on the chain |
210
+
211
+ `lookup` **reveals equality**: two rows holding one value store one string. Never for a low-entropy
212
+ value — a boolean, a status, a PIN.
213
+
214
+ **During a key rotation** a lookup value has one stored form per declared key, so equality reads
215
+ through `in (…)` over `sealAll()` and `.unique()` holds per key. `x doctor` reports
216
+ `X_SEAL_RESEAL_PENDING` until every row is rewritten (`update(id, { column: row.column })` seals
217
+ under the current key) and the retired key is dropped (`x secrets rotate --drop <keyId>`).
218
+
219
+ **A column that already holds plaintext is migrated, never tolerated.** A stored value that is not
220
+ a sealed value is `X_SEAL_INVALID`, always. Expand, backfill, contract:
221
+
222
+ | # | Step | Edit |
223
+ |---|---|---|
224
+ | 1 | expand | add `passwordSealed: text().nullable().sealed()` beside `password`; `x db gen "add sealed password"`; `x db migrate` |
225
+ | 2 | backfill | a `backfill()` over `db.connections.andWhere('passwordSealed', 'is-null')` — only the rows still to seal — whose `handle` runs `update(row.id, { passwordSealed: row.password })` per row. Idempotent, so a replayed page is harmless |
226
+ | 3 | switch | readers and writers use `passwordSealed` |
227
+ | 4 | contract | delete `password`; declare `password: text().sealed().column('password_sealed')` — the purpose follows the physical name, so nothing is re-sealed; `x db gen "drop plaintext password"` |
228
+
229
+ The full recipe with the `backfill()` declaration: [Entities and migrations](../../wiki/Entities-And-Migrations.md#sealing-a-column-that-already-holds-data).
230
+
120
231
  ## Wide columns
121
232
 
122
233
  The vocabulary an EXISTING schema needs. The blessed set above is a decision the framework made
@@ -125,9 +236,9 @@ for a table it was going to create; these are the shapes a table already has, `A
125
236
  | Builder | Emits | Row type, and why |
126
237
  |---|---|---|
127
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 |
128
- | `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` |
129
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 |
130
- | `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` |
131
242
  | `bytes()` | `bytea` | a plain `Uint8Array`, normalised: Bun's `sql` returns a `Buffer` and PGlite a `Uint8Array`, and the two do not serialise alike |
132
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 |
133
244
 
@@ -376,7 +487,10 @@ written schema-qualified as `operator(pg_catalog.?)` — the function form `json
376
487
  the same test and the planner will not match a GIN index to it. Their
377
488
  meaning is Postgres', to the letter, in both drivers — including that the array-contains-a-primitive
378
489
  exception applies at the **top level only** and to **primitives only**, and that `&&`'s empty
379
- 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)`.
380
494
 
381
495
  There is no jsonpath expression operator: `contains` already matches nested structure, and a path
382
496
  language inside the query language would be a second way to ask one question. `&&` on a `jsonb`
@@ -488,6 +602,7 @@ const shipped = await db.orders.transition('status', id, { from: 'paid', to: 'sh
488
602
  | `onUpdateNow()` moves, because a transition is an update | the audit of *when* it moved, with no second mechanism beside it |
489
603
  | the CHECK comes from `enumerated()` | the machine emits no DDL of its own — one declaration of what a legal value is |
490
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 |
491
606
 
492
607
  What is deliberately **not** here: who may make a move, what happens on arrival, an approval chain,
493
608
  a reason code. Those differ per app — wrap `transition()` in your own function and put them there.
@@ -548,7 +663,7 @@ and never unwritten.
548
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 |
549
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 |
550
665
  | `onUpdateNow()` | stamped by `touch()`, the same helper `update(id, patch)` uses — one place, so the two can never disagree about `updatedAt` |
551
- | 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 |
552
667
 
553
668
  ## Writing many rows
554
669
 
@@ -645,6 +760,7 @@ page.rows[0].author; // the member row, or null — always present
645
760
  | Unknown name | `X_PRELOAD_UNKNOWN_RELATION` at `preload()` itself, not a page later |
646
761
  | Shape | `belongsTo` attaches the row or `null`; `hasMany` an array — always present |
647
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` |
648
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 |
649
765
  | Terminals | `page()`, `all()`, `one()` preload; `count()`, `countBy()` and `plan()` don't — none reads a row to attach one to |
650
766
 
@@ -664,11 +780,20 @@ database({ orgs, posts }, { driver: postgresDriver() }); // production
664
780
  | A predicate | `memory-match.ts`, by the column's declared KIND | the SQL `pg-sql.ts` compiles |
665
781
 
666
782
  Both answer the same predicate the same way, and the kind is what decides — never the JS type of
667
- the value: `bigint()`/`decimal()` hold decimal STRINGS and order by their digits (`2, 9, 10, 100`),
668
- a `uuid` compares case-insensitively because Postgres compares it as a value, `\` escapes a `%` or
669
- 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
670
789
  a `null` also matches the NULL rows, which `col = null` never does).
671
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
+
672
797
  `database()` called with no driver takes the process default, and `defaultDriver()` is that same
673
798
  object — the one seam a test harness needs, `As of 2026-08`:
674
799
 
@@ -692,14 +817,13 @@ returns the open transaction when there is one, so a repository call inside `wit
692
817
  joins it without being told — which is how a job's outbox row lands atomically with the write
693
818
  that enqueued it.
694
819
 
695
- `postgresDriver({ client })` pins one instead, for a test harness or `x db branch` — and a pinned
696
- repository used while a transaction is open is `X_REPO_CLIENT_PINNED`, `As of 2026-08`.
697
- `withTransaction` reserved a connection and ran `BEGIN` on it; a pinned repository sends straight
698
- to its own client, so the write would commit whatever the transaction decides and the read would
699
- miss what the transaction has written, both silently. It is refused rather than resolved because a
700
- `DbTx` does not name the client it was opened on: on a sharded app "the same connection" and "the
701
- same database" are two different questions and this layer can answer neither. `setDbClient(client)`
702
- 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.
703
827
 
704
828
  Every value is bound to `$n` and every identifier is resolved through the entity, so a column
705
829
  name can only be one the entity declared and a row value can never become SQL.
@@ -866,11 +990,11 @@ Two write verbs, because only the author knows which key identifies a row:
866
990
 
867
991
  | Context member | Use it when | A replay |
868
992
  |---|---|---|
869
- | `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`. |
870
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 |
871
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 |
872
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 |
873
- | `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()`) |
874
998
 
875
999
  Rules worth knowing before the first seed: `upsert` never overwrites `createdAt` (`preserve` names
876
1000
  other columns to spare); `upsert` on a tenant-scoped entity needs the tenant column inside `by`,
@@ -886,9 +1010,9 @@ database from its boot code has decided to, and a library that overruled that wo
886
1010
 
887
1011
  `X_ENTITY_DUPLICATE` · `X_INVARIANT_VIOLATED` · `X_TENANCY_UNSCOPED` ·
888
1012
  `X_TENANCY_ACTOR_MISMATCH` · `X_TENANCY_ACTOR_ORG_REQUIRED` · `X_TENANCY_CROSS_DENIED` ·
889
- `X_DB_DRIFT` · `X_NOT_FOUND` · `X_WRITE_UNFILTERED` · `X_PATCH_EMPTY` ·
1013
+ `X_NOT_FOUND` · `X_WRITE_UNFILTERED` · `X_PATCH_EMPTY` ·
890
1014
  `X_PRELOAD_UNKNOWN_RELATION` · `X_N_PLUS_ONE_QUERY` · `X_N_PLUS_ONE_WRITE` ·
891
- `X_RECORD_KEY_MISSING`
1015
+ `X_RECORD_KEY_MISSING` · `X_ENTITY_SEALED_PREDICATE` · `X_ENTITY_SEALED_IN_VIEW`
892
1016
 
893
1017
  ### Error classes
894
1018
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "22.15.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",
@@ -20,6 +20,7 @@
20
20
  "files": [
21
21
  "src",
22
22
  "!src/**/*.test.ts",
23
+ "!src/**/*-fixture.ts",
23
24
  "CLAUDE.md",
24
25
  "README.md",
25
26
  "LICENSE"
@@ -32,9 +33,9 @@
32
33
  "test": "bun test"
33
34
  },
34
35
  "dependencies": {
35
- "@ultimat3/core": "22.15.0",
36
- "@ultimat3/db": "22.15.0",
37
- "@ultimat3/schema": "22.15.0",
38
- "@ultimat3/time": "22.15.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"
39
40
  }
40
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