@ultimat3/entity 23.0.0 → 25.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
@@ -4,19 +4,16 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
4
4
 
5
5
  ## Boundary
6
6
 
7
- - May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/db` and `@ultimat3/time` (tier 1 — `columns.ts` and `columns-data.ts` read its `isValidTimeZone`, so a zone this package accepts is one `@ultimat3/time` can do arithmetic in). Nothing else — `http`,
8
- `policy` and `auth` are the same tier.
9
- - `db` is tier 1 (it imports only `core`), which is what lets the Postgres driver live **here**
10
- rather than in a tier-3 package: `Driver` and its production implementation stay in one place.
11
- See [`docs/architecture/01-package-map.md`](../../docs/architecture/01-package-map.md).
12
- - No `drizzle-orm` dependency, and none is the production backing — `postgresDriver()`
13
- (`pg-driver.ts`/`pg-sql.ts`) is a hand-written SQL driver. `types.ts` declares the narrow
14
- structural column vocabulary this package consumes so the generated SQL stays readable and
15
- an agent can self-correct against it.
7
+ - May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/db` and `@ultimat3/time` (its
8
+ `isValidTimeZone`). Nothing else.
9
+ - `db` is tier 1, which lets the Postgres driver live **here**: `Driver` and its production
10
+ implementation in one place ([`01-package-map.md`](../../docs/architecture/01-package-map.md)).
11
+ - No `drizzle-orm`: `postgresDriver()` (`pg-driver.ts`/`pg-sql.ts`) is hand-written SQL;
12
+ `types.ts` declares the narrow column vocabulary it consumes.
16
13
 
17
14
  ## Do not regress — two drivers, one meaning
18
15
 
19
- - **`repo.ts` is the contract** (`Repo`, `Page`, `FindManyArgs`, `Transactor`); `memory-repo.ts` is
16
+ - **`repo.ts` is the contract** (`Repo`, `FindManyArgs`, `Transactor`); `memory-repo.ts` is
20
17
  `memoryRepo()`.
21
18
  - **Two drivers, one meaning.** `memoryDriver()` and `postgresDriver()` share `plan.ts`, `cursor.ts`
22
19
  and the `Repo` contract. Every bulk or read feature carries BOTH a `*-parity.test.ts` (identical rows
@@ -24,10 +21,14 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
24
21
  Both are the bar.
25
22
  - **`write-parity.test.ts` runs every write against memory AND PGlite**: a patch property present with
26
23
  `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)`;
24
+ unique — index OR `c.unique([…])` invariant, the one list in `unique-constraints.ts` — and a PK
25
+ patch onto another row with `X_DB_UNIQUE_VIOLATION` (partial uniques are not checked in memory);
26
+ `.transition()` filters on `singleKeyOf(entity)` and refuses an absent id (`X_NOT_FOUND`, no row
27
+ moved);
29
28
  `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.
29
+ columns; `text({ max })` counts code points; `integer()` holds int4, `bigint()` int8; a whole part
30
+ of `0` is no `numeric` digit; `url()` lower-cases its scheme and judges the STORED text.
31
+ `invariant-parity.test.ts` asks the app, the driver and the raw CHECK the same question.
31
32
  - **`min`/`max` over a `timestamptz` crosses as epoch ms** (`aggregate-time-parity.test.ts`). A whole
32
33
  row read under a non-UTC session still fails on PGlite — recorded, not fixed.
33
34
  - **Text ordering: byte (`C`) order is the parity target** — a linguistic production collation is a
@@ -37,10 +38,13 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
37
38
  holds a `number`); `RowWrite<Row>` types `insert`/`insertAll`/`upsertAll`; `narrowRow` (`columns.ts`)
38
39
  narrows at each write method's ENTRY, before `$assert`/`upsertPlan`. `pg-money-write.live.test.ts`,
39
40
  `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
41
+ - **A PREDICATE's meaning is decided by the column's KIND** (`memory-match.ts`); `compareByKind` /
42
+ `sameValueOfKind` are EXPORTED — `@ultimat3/query` calls them. **ONE numeric comparison,
43
+ `numericOrder`**: `DECIMAL_TEXT` columns by VALUE (`'2.50' = 2.5`), `number`/`bigint` numerically
44
+ (`compare-parity.test.ts`). A `uuid` is a value, compared lower-cased and
45
+ stored lower-case (`keyOf`, `parseUuid`, `narrowUuid`; text is never narrowed); `LIKE` (`like.ts`)
46
+ uses Postgres' default `\` escape, refuses a trailing escape, counts CHARACTERS and is a bounded
47
+ two-pointer walk — never a regex; `in` takes a
44
48
  list or nothing, and a NULL in it emits `(col in (…) or col is null)`; a column the row never NAMED
45
49
  is NULL for `eq`/`neq`/`in`.
46
50
  - **The Postgres driver is proved against a real Postgres** (`pg-driver.live.test.ts`, skipped without
@@ -48,9 +52,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
48
52
  - **A repository call rejects, never throws synchronously** (`tableFor`'s writes are `async`).
49
53
  - **`defaultDriver()`** is the process default; `Driver.reset?()` is optional (memory only), resets
50
54
  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.
55
+ - **A repository pinned to its own client joins a transaction opened ON that client** (`tx.origin`)
56
+ **and refuses any other** (`X_REPO_CLIENT_PINNED`, in `client()` in `pg-driver.ts`); the fix names
57
+ `setDbClient(client)` plus an unpinned repository.
54
58
 
55
59
  ## Do not regress — reading
56
60
 
@@ -63,7 +67,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
63
67
  `writing()`), keyed by id, bounded (`MAX_SIBLING_KEYS`, oldest page first). One switch:
64
68
  `postgresDriver({ jitPreload: false })` — never an `app.config.ts` key. Shared pieces live in
65
69
  `batch-read.ts`.
66
- - **`preload(name)` shares `batch-read.ts` and keeps no request cache.** Tenancy is CARRIED only when
70
+ - **`preload(name, { max })` shares `batch-read.ts`, keeps no request cache and is BOUNDED**
71
+ (`preload-ceiling.ts`: default `MAX_PRELOADED_ROWS`, refused past it, never truncated). Tenancy is CARRIED only when
67
72
  both entities are scoped by a column of that same name, else the related read refuses
68
73
  (`X_TENANCY_UNSCOPED`). Reach is the `database()` call's `RelatedTables`; `select()` widens with each
69
74
  preloaded relation's local key; attachment copies (`{ ...row }`); only `page()`/`all()`/`one()`
@@ -108,12 +113,12 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
108
113
  - **The four aggregates** (`sum`, `avg`, `min`, `max`) share `aggregate.ts`
109
114
  (`aggregate-fold.ts` memory, `aggregate-decode.ts` Postgres): never a float; `avg` at `AVG_SCALE` (6),
110
115
  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
116
+ (`X_AGGREGATE_UNSUPPORTED`), mixed currency or scale (`X_AGGREGATE_MIXED_CURRENCY`, two causes, two fixes), a money total
112
117
  past ±2^53. **`approximateCount()` is `reltuples`**; a filtered chain or tenant-scoped entity is
113
118
  `X_APPROXIMATE_COUNT_FILTERED`; `null` for an unanalysed table.
114
119
  - **`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.
120
+ Postgres' measured meaning in `containment.ts`; `has-key` emits `operator(pg_catalog.?)` (indexable)
121
+ and takes a STRING key, else no rows; a NULL array element equals nothing. No jsonpath operator.
117
122
  - **A GIN index is declarable** (`using: 'gin'`; the set is `@ultimat3/db`'s `INDEX_METHODS`); absent
118
123
  is `btree` byte for byte; the method joins the name discriminator only when declared; GIN cannot be
119
124
  unique or ordered (refused here). `pg-containment.live.test.ts`.
@@ -145,7 +150,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
145
150
  local renderers are deleted). An untranslatable predicate is `kind: 'assert'`, `sql: null`.
146
151
  **`InvariantDescription.columns` is projected** (`describe-invariant.test.ts`).
147
152
  - **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
153
+ refused (`matchOperator`); `minLength` counts code points; `trimmed()` strips U+0020 only (`btrim`);
154
+ `atLeast`/`eq(<number>)` judge by the column's KIND (`invariantColumns(name, columns)`), so a
155
+ `bigint()`/`decimal()` row compares by its digits. **`isNull()`/`isNotNull()` are the only
149
156
  total members**; `iff(a, b)` renders `(a) = (b)` — measured as the safe direction
150
157
  (`pg-invariant-null.live.test.ts`); `iff` is a function, and a `unique` operand is refused with the
151
158
  `c.unique([…])` fix built through `JSON.stringify`. One app-only operand makes the whole rule
@@ -200,7 +207,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
200
207
  - **`deleteWhere` / `updateWhere` are the only filtered writes**: empty filter `X_WRITE_UNFILTERED`,
201
208
  empty patch `X_PATCH_EMPTY` (undefined values dropped first); soft delete respected; both return a
202
209
  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
210
+ and refused past `MAX_ASSERTED_ROWS` (50,000) in BOTH drivers; on Postgres that statement and its
211
+ judgement share a transaction (`judged-write.ts`, `update` too), so a refusal rolls back. `updateStatement`'s `returning` is
204
212
  required.
205
213
  - **Every instant the write path stamps comes from `entityNow()`** (`clock.ts`, `ctx.clock`).
206
214
  **`touch()` in `query.ts` is the ONE place `onUpdateNow()` columns are stamped.**
@@ -214,10 +222,17 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
214
222
  - **A seed is replayable**: `SeedContext.insert` is one `upsertAll(…, { onMatch: 'nothing' })` per
215
223
  call; a generated PK the row does not name is refused. `upsert(entity, { by }, values)` keys on a
216
224
  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).
225
+ **The environment guard is the CLI's** (`seedTiersFor`; `run()` stays permissive). **A dry run is
226
+ the real run rolled back** through `Driver.transactor()` (REQUIRED on the interface) — one rule,
227
+ never a per-verb lookup.
218
228
  - **`setRowObserver` reports committed row changes above the driver** — one per process (returns the
219
229
  replaced one), applied by `database()`, one comparison per write when unset, `before` only when the PK
220
230
  is `id`, a filtered write is `onBulk`. Not a second change-feed path.
231
+ - **`appendOnly: true`** (`append-only.ts`): `appendOnlyRepo` wraps OUTSIDE `sealedRepo` in both
232
+ drivers (seed `upsert` appends, never rewrites); `update`/`delete`/`*Where` and an
233
+ `upsertAll` not `onMatch: 'nothing'` reject before any statement (`X_ENTITY_APPEND_ONLY`); a soft-delete,
234
+ `onUpdateNow()` or machine column is refused at declaration; `$describe().appendOnly` is `true` or
235
+ absent. No bypass, backfills included. `append-only{,-parity}.test.ts`.
221
236
  - **A state machine is the MECHANISM only** (`.transitions()` on `enumerated()`, a mapped
222
237
  `TransitionTable<S>`): a terminal state is one with no outgoing moves; the move is ONE statement with
223
238
  `from` in the predicate (`pg-transition.live.test.ts`: 1 winner of 20); `X_STATE_CONFLICT` is read
@@ -231,7 +246,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
231
246
  rewrites or refuses every predicate. A driver never seals for itself; an entity with no sealed
232
247
  column gets its repository back untouched. The cipher is core's (`scripts/seal-calls.ts`).
233
248
  - **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.
249
+ none. `sealValue` runs the UNWRAPPED plaintext parser (`$meta.sealed.plaintext`) BEFORE sealing — nothing
250
+ after can, and the column's own `$parse` would pass a sealed-shaped lookalike.
235
251
  - **No reading of an unsealed value.** Not sealed is `X_SEAL_INVALID`; existing plaintext is
236
252
  expand → `backfill()` → contract (README). No `legacy` option.
237
253
  - **Purpose is `entity:<table>.<column>`, physical names** (`sealed.ts`). **Sealing is not on
@@ -261,7 +277,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
261
277
  `persist` defaults `false`, read only off the projection. **The browser path is
262
278
  `@ultimat3/entity/record`**, whose modules import `entity-error.ts`, never `errors.ts`
263
279
  (`record-bundle.test.ts`).
264
- - Never throw a bare `Error` — use `errors.ts`.
280
+ - Never throw a bare `Error` — use `errors.ts`. `X_DB_DRIFT` is `@ultimat3/db`'s, factory included.
265
281
  - **Tests restore the registry with a FILE-scope `afterAll(() => { clearRegistry(); })`** — matched on
266
282
  exact text by `live-registry-cleanup.test.ts`; never inside a skippable suite's teardown.
267
283
 
@@ -273,9 +289,11 @@ Each file's header states its one job. The map, by area: `types.ts` (derivation;
273
289
  `entity.ts` / `describe.ts` / `index-name.ts` / `search.ts`, `state-machine.ts` / `transition.ts`,
274
290
  `feature-errors.ts`, `view.ts` / `row-schema.ts` / `record-projection.ts` / `record-key.ts` /
275
291
  `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` /
292
+ `database.ts` / `clock.ts`, `memory-match.ts` / `like.ts` / `numeric-compare.ts` / `unique-constraints.ts` / `repo.ts` /
293
+ `memory-repo.ts` / `tenancy.ts` /
277
294
  `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` /
295
+ `batch-read.ts` / `bulk-write.ts` / `count-by.ts` / `jit-preload.ts` / `preload.ts` /
296
+ `preload-ceiling.ts` / `judged-write.ts` / `pg-sql.ts` /
279
297
  `pg-row.ts`, `row-observer.ts` / `write-tag.ts` (a keyed request's write names itself in the WAL via
280
298
  `pg_logical_emit_message`), `registry.ts` / `relations.ts` / `n-plus-one.ts` / `seed.ts`,
281
299
  `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
 
@@ -348,13 +348,17 @@ database, and `x verify` reports the other as a rule the database does not know.
348
348
  ```ts
349
349
  export const db = database({ orgs, posts });
350
350
 
351
- db.posts.where({ orgId }).orderBy('createdAt').limit(50).page(); // { rows, nextCursor }
351
+ db.posts.where({ orgId }).orderBy('createdAt').limit(50).page(); // { rows, nextCursor, hasMore }
352
352
  ```
353
353
 
354
354
  `db.posts` exists because `posts` was declared. Pagination is **cursor-only**: `OFFSET` is wrong
355
355
  under concurrent writes, because an insert before the offset shifts every later page and the
356
356
  client silently skips and repeats rows.
357
357
 
358
+ The page is `@ultimat3/core`'s `Page` — the one page shape, the same a `query`'s `.page()` answers
359
+ (this package's own `Page`, `{ rows, nextCursor }` with no `hasMore`, left in 25.0.0). `nextCursor` is `null`
360
+ exactly when `hasMore` is false, so `while (page.hasMore)` stops on the last page.
361
+
358
362
  `nextCursor` is signed by `@ultimat3/core` and scoped to the plan that produced it — this entity,
359
363
  these filters, this sort order. A tampered cursor, or one taken from another listing, is
360
364
  `X_CURSOR_INVALID` rather than a silent page one. The page size is deliberately outside the scope:
@@ -487,7 +491,10 @@ written schema-qualified as `operator(pg_catalog.?)` — the function form `json
487
491
  the same test and the planner will not match a GIN index to it. Their
488
492
  meaning is Postgres', to the letter, in both drivers — including that the array-contains-a-primitive
489
493
  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.
494
+ operand overlaps nothing where `@>`'s is contained by everything. Two more rules both drivers hold:
495
+ a NULL **array element** equals nothing, itself included, on either side of `@>`, `<@` and `&&`
496
+ (a `jsonb` `null` is a value and still equals itself); and `has-key` takes a **string** key — any
497
+ other operand matches no row, never `String(operand)`.
491
498
 
492
499
  There is no jsonpath expression operator: `contains` already matches nested structure, and a path
493
500
  language inside the query language would be a second way to ask one question. `&&` on a `jsonb`
@@ -599,6 +606,7 @@ const shipped = await db.orders.transition('status', id, { from: 'paid', to: 'sh
599
606
  | `onUpdateNow()` moves, because a transition is an update | the audit of *when* it moved, with no second mechanism beside it |
600
607
  | the CHECK comes from `enumerated()` | the machine emits no DDL of its own — one declaration of what a legal value is |
601
608
  | `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 |
609
+ | 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
610
 
603
611
  What is deliberately **not** here: who may make a move, what happens on arrival, an approval chain,
604
612
  a reason code. Those differ per app — wrap `transition()` in your own function and put them there.
@@ -659,7 +667,46 @@ and never unwritten.
659
667
  | 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
668
  | 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
669
  | `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 |
670
+ | 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 |
671
+
672
+ ## Append-only entities
673
+
674
+ `appendOnly: true` declares a table whose rows are written once and never changed or removed — a
675
+ ledger, an audit trail, an event log. Held twice, like an invariant: the repository refuses, and
676
+ `x db gen` emits a trigger that refuses the same statement in raw SQL.
677
+
678
+ ```ts
679
+ import { database, entity, integer, newId, text, timestamp, uuid } from '@ultimat3/entity';
680
+
681
+ export const entries = entity('ledger_entries', {
682
+ columns: {
683
+ id: uuid().primaryKey(),
684
+ account: text({ max: 40 }),
685
+ amountMinor: integer(),
686
+ reverses: uuid().nullable(), // a correction is a NEW row naming the one it reverses
687
+ createdAt: timestamp().defaultNow(),
688
+ },
689
+ appendOnly: true,
690
+ });
691
+
692
+ const db = database({ entries });
693
+ const first = await db.entries.insert({ id: newId(), account: 'cash', amountMinor: 100 });
694
+ // Skips a row already stored, never overwrites it — the one upsert an append-only table takes.
695
+ await db.entries.upsertAll([first], { onConflict: ['id'], onMatch: 'nothing' });
696
+ // Rejects with X_ENTITY_APPEND_ONLY, as do delete, updateWhere and deleteWhere.
697
+ await db.entries.update(first.id, { amountMinor: 0 }).catch((refused: unknown) => refused);
698
+ ```
699
+
700
+ | | |
701
+ |---|---|
702
+ | Refused | `update`, `delete`, `updateWhere`, `deleteWhere`, and `upsertAll` unless `onMatch: 'nothing'` (the default is `'update'`). Rejected before any statement exists, in both drivers, so memory and Postgres answer the same code. `entries.$appendOnly` says which entities carry it |
703
+ | Refused at declaration | a `deletedAt` column (a soft delete is an UPDATE), an `onUpdateNow()` column (nothing would ever stamp it), a `.transitions()` column (a transition is an UPDATE) — `X_INVARIANT_VIOLATED` naming the column |
704
+ | The database | `x db gen` writes `create trigger "ultimate_append_only" before update or delete … for each row` calling one shared `ultimate_refuse_append_only()`; raw SQL, an `on conflict do update` and a second app on the same schema get `X_ENTITY_APPEND_ONLY: UPDATE on public.<table> is refused`, SQLSTATE `23001`. PGlite runs the same trigger. `truncate` is not refused — it is no row write, a migration already marks it destructive, and the test harness empties tables with it; revoke `TRUNCATE` from the web role if that matters |
705
+ | Drift | a trigger missing or disabled in the live database is `X_APPEND_ONLY_TRIGGER_MISSING` from `x db migrate`'s post-migrate check, with the two statements that restore it as the fix |
706
+ | Turning it on or off | a migration: `x db gen` adds the trigger or drops it, and `down` reverses it. Dropping the table drops the trigger |
707
+ | Backfills and seeds | refused too — no bypass. A `backfill()` that must rewrite existing rows is a two-migration change: drop `appendOnly`, backfill, put it back. A seed's `ctx.insert` is `onMatch: 'nothing'` and works; so is `ctx.upsert` of a row not yet stored — it appends with `onMatch: 'nothing'` (a replay is `'skipped'`, two boots racing settle on one row). Only an `upsert` that would CHANGE a stored row is refused (`X_ENTITY_APPEND_ONLY`) |
708
+ | Tenancy | unchanged — the refusal comes first and judges no tenant; an insert is scoped exactly as on any entity |
709
+ | The seam | `appendOnlyRepo(entity, repo)`, wrapped outside `sealedRepo` by both drivers — so `database()`, a seed's `driver.repo()` and a hand-held `postgresRepo(entity)` agree. A driver written outside this package returns through `appendOnlyRepo(entity, sealedRepo(entity, repo))` |
663
710
 
664
711
  ## Writing many rows
665
712
 
@@ -756,6 +803,7 @@ page.rows[0].author; // the member row, or null — always present
756
803
  | Unknown name | `X_PRELOAD_UNKNOWN_RELATION` at `preload()` itself, not a page later |
757
804
  | Shape | `belongsTo` attaches the row or `null`; `hasMany` an array — always present |
758
805
  | Statements | one extra per relation, resolved concurrently; naming one twice is one statement |
806
+ | 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
807
  | 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
808
  | Terminals | `page()`, `all()`, `one()` preload; `count()`, `countBy()` and `plan()` don't — none reads a row to attach one to |
761
809
 
@@ -775,11 +823,20 @@ database({ orgs, posts }, { driver: postgresDriver() }); // production
775
823
  | A predicate | `memory-match.ts`, by the column's declared KIND | the SQL `pg-sql.ts` compiles |
776
824
 
777
825
  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
826
+ the value: `bigint()`/`decimal()` hold decimal STRINGS, order by their digits (`2, 9, 10, 100`) and
827
+ are EQUAL by value (`'2.50'` matches `'2.5'`, `2.5` and `'2.500'`; `'10'` matches `10`),
828
+ a `uuid` is ordered and equated case-insensitively because Postgres compares it as a value, `\` escapes a `%` or
829
+ a `_` inside a `like` (whose `_` is one CHARACTER, astral or not, and whose match is bounded by
830
+ pattern × value — never a backtracking regex), a unique declared as `invariant(name, c.unique([…]))`
831
+ 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
832
  a `null` also matches the NULL rows, which `col = null` never does).
782
833
 
834
+ `compareByKind(kind, left, right)` and `sameValueOfKind(kind, left, right)` are that rule, exported
835
+ (`As of 2026-10`): `-1 | 0 | 1` and a boolean for two values of one column of a declared
836
+ `ColumnKind`, NULL sorting largest. `@ultimat3/query`'s live matcher and in-memory source call them
837
+ rather than keep a comparator of their own; `compare-parity.test.ts` runs the rule against both
838
+ drivers.
839
+
783
840
  `database()` called with no driver takes the process default, and `defaultDriver()` is that same
784
841
  object — the one seam a test harness needs, `As of 2026-08`:
785
842
 
@@ -803,14 +860,13 @@ returns the open transaction when there is one, so a repository call inside `wit
803
860
  joins it without being told — which is how a job's outbox row lands atomically with the write
804
861
  that enqueued it.
805
862
 
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.
863
+ `postgresDriver({ client })` pins one instead, for a test harness or `x db branch`. A pinned
864
+ repository joins a transaction that was opened ON its own client — `withTransaction(fn, { client })`,
865
+ `postgresTransactor({ client })`, `driver.transactor()` — because `tx.origin` says it is the same
866
+ database and the same reservation (`As of 2026-10-02`). Inside any OTHER open transaction it is
867
+ `X_REPO_CLIENT_PINNED`: a statement sent to the pinned client would commit whatever that
868
+ transaction decides and miss what it has written, both silently. `setDbClient(client)` plus an
869
+ unpinned repository is the shape that joins the ambient one.
814
870
 
815
871
  Every value is bound to `$n` and every identifier is resolved through the entity, so a column
816
872
  name can only be one the entity declared and a row value can never become SQL.
@@ -977,11 +1033,11 @@ Two write verbs, because only the author knows which key identifies a row:
977
1033
 
978
1034
  | Context member | Use it when | A replay |
979
1035
  |---|---|---|
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` |
1036
+ | `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
1037
  | `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
1038
  | `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
1039
  | `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 |
1040
+ | `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
1041
 
986
1042
  Rules worth knowing before the first seed: `upsert` never overwrites `createdAt` (`preserve` names
987
1043
  other columns to spare); `upsert` on a tenant-scoped entity needs the tenant column inside `by`,
@@ -997,7 +1053,7 @@ database from its boot code has decided to, and a library that overruled that wo
997
1053
 
998
1054
  `X_ENTITY_DUPLICATE` · `X_INVARIANT_VIOLATED` · `X_TENANCY_UNSCOPED` ·
999
1055
  `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` ·
1056
+ `X_NOT_FOUND` · `X_WRITE_UNFILTERED` · `X_PATCH_EMPTY` ·
1001
1057
  `X_PRELOAD_UNKNOWN_RELATION` · `X_N_PLUS_ONE_QUERY` · `X_N_PLUS_ONE_WRITE` ·
1002
1058
  `X_RECORD_KEY_MISSING` · `X_ENTITY_SEALED_PREDICATE` · `X_ENTITY_SEALED_IN_VIEW`
1003
1059
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "23.0.0",
3
+ "version": "25.0.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,16 +26,16 @@
26
26
  "LICENSE"
27
27
  ],
28
28
  "engines": {
29
- "bun": ">=1.4.0"
29
+ "bun": ">=1.4.2"
30
30
  },
31
31
  "scripts": {
32
32
  "typecheck": "tsc --noEmit -p tsconfig.json",
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": "25.0.0",
37
+ "@ultimat3/db": "25.0.0",
38
+ "@ultimat3/schema": "25.0.0",
39
+ "@ultimat3/time": "25.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
@@ -0,0 +1,53 @@
1
+ // The refusals `entity({ appendOnly: true })` raises: a repository call that would change or remove
2
+ // a stored row, and a declaration that only means something under UPDATE. Each fix names the
3
+ // append that replaces the rewrite, or the edit that lifts the declaration.
4
+ import { EntityError, type EntityErrorCode } from './entity-error';
5
+
6
+ /** The refusal's code, exported so every test asserts the one literal. */
7
+ export const APPEND_ONLY_CODE: EntityErrorCode = 'X_ENTITY_APPEND_ONLY';
8
+
9
+ /** The repository calls that rewrite or remove a row — every one a `Repo` has. */
10
+ export type AppendOnlyRefusedCall =
11
+ | 'update'
12
+ | 'delete'
13
+ | 'updateWhere'
14
+ | 'deleteWhere'
15
+ | 'upsertAll';
16
+
17
+ /** How to lift the rule, said once: the trigger goes with the flag, in a migration. */
18
+ const LIFT =
19
+ 'or, if these rows must change, remove appendOnly: true from the entity and run x db gen "allow row rewrites"';
20
+
21
+ const fixFor = (entityName: string, call: AppendOnlyRefusedCall): string => {
22
+ if (call === 'upsertAll') {
23
+ return `${entityName}.upsertAll(rows, { onConflict, onMatch: 'nothing' }) # a row already stored is skipped, never overwritten — ${LIFT}`;
24
+ }
25
+ const what = call === 'update' || call === 'updateWhere' ? 'the correction' : 'the reversal';
26
+ return `${entityName}.insert({ … }) # append ${what} as a new row that names the one it supersedes — ${LIFT}`;
27
+ };
28
+
29
+ export const appendOnlyRefused = (entityName: string, call: AppendOnlyRefusedCall): EntityError =>
30
+ new EntityError({
31
+ code: APPEND_ONLY_CODE,
32
+ cause:
33
+ `${entityName}.${call}() would ${call.startsWith('delete') ? 'remove' : 'rewrite'} a stored row, ` +
34
+ `and ${entityName} is declared appendOnly: true — its rows are written once and never ` +
35
+ 'changed or removed; the table trigger refuses the same statement in raw SQL',
36
+ fix: fixFor(entityName, call),
37
+ });
38
+
39
+ /**
40
+ * A column that only means something when a row is UPDATED, on an entity whose rows never are.
41
+ * Raised while the entity is being declared, so the fix is the edit, not a lookup of an entity
42
+ * that does not exist yet.
43
+ */
44
+ export const appendOnlyColumnRefused = (
45
+ entityName: string,
46
+ property: string,
47
+ why: string,
48
+ ): EntityError =>
49
+ new EntityError({
50
+ code: 'X_INVARIANT_VIOLATED',
51
+ cause: `${entityName}.${property}: ${why}, and ${entityName} is declared appendOnly: true — no row of it is ever updated`,
52
+ fix: `entity('${entityName}', { columns, appendOnly: true }) # with ${property} removed from columns — or keep ${property} and drop appendOnly: true`,
53
+ });
@@ -0,0 +1,82 @@
1
+ // `entity({ appendOnly: true })`: what the declaration may not hold, and the repository seam that
2
+ // refuses every write changing or removing a stored row. Applied by BOTH drivers, beside
3
+ // `sealedRepo`, so `database()`, a seed and anything else holding `driver.repo(entity)` are refused
4
+ // alike — and before any statement exists, so memory and Postgres answer the same code.
5
+
6
+ import { appendOnlyColumnRefused, appendOnlyRefused } from './append-only-errors';
7
+ import type { Repo, UpsertArgs } from './repo';
8
+ import type { AnyColumn } from './types';
9
+
10
+ /** The two members the seam reads — so a driver written outside this package can pass its entity. */
11
+ export interface AppendOnlySource {
12
+ readonly $name: string;
13
+ readonly $appendOnly: boolean;
14
+ }
15
+
16
+ /**
17
+ * Refused at declaration: each is a column whose whole meaning is an UPDATE. A soft delete IS one
18
+ * (`deleted_at = now()`), an `onUpdateNow()` stamp is written by one, and a state machine moves by
19
+ * one — on an append-only table all three would be declared and never able to happen.
20
+ */
21
+ export const assertAppendOnlyColumns = (
22
+ entityName: string,
23
+ entries: readonly (readonly [string, AnyColumn])[],
24
+ softDeleteColumn: string,
25
+ ): void => {
26
+ for (const [property, column] of entries) {
27
+ const meta = column.$meta;
28
+ if (property === softDeleteColumn) {
29
+ throw appendOnlyColumnRefused(
30
+ entityName,
31
+ property,
32
+ 'makes the entity soft-deletable, and a soft delete is an UPDATE',
33
+ );
34
+ }
35
+ if (meta.onUpdate !== undefined) {
36
+ throw appendOnlyColumnRefused(entityName, property, 'is onUpdateNow(), stamped by an UPDATE');
37
+ }
38
+ if (meta.machine !== undefined) {
39
+ throw appendOnlyColumnRefused(
40
+ entityName,
41
+ property,
42
+ 'declares .transitions(), and a transition is an UPDATE',
43
+ );
44
+ }
45
+ }
46
+ };
47
+
48
+ /**
49
+ * `repo`, refusing. Inserts and reads pass through untouched; the five calls that rewrite or remove
50
+ * a row reject — `upsertAll` unless `onMatch: 'nothing'`, which only ever appends. Every refusal is
51
+ * `async`, so it rejects rather than throws, as the contract requires; a spread over the inner
52
+ * repository keeps a member this file does not know (`MemoryRepo.reset()`).
53
+ *
54
+ * A backfill is refused too, deliberately: there is no bypass, because a ledger an author's job can
55
+ * rewrite is not append-only. Changing existing rows is lifting the flag, in a migration.
56
+ */
57
+ export const appendOnlyRepo = <Row, R extends Repo<Row>>(entity: AppendOnlySource, repo: R): R => {
58
+ if (!entity.$appendOnly) return repo;
59
+ const name = entity.$name;
60
+ const refusing: Pick<
61
+ Repo<Row>,
62
+ 'update' | 'delete' | 'updateWhere' | 'deleteWhere' | 'upsertAll'
63
+ > = {
64
+ update: async () => {
65
+ throw appendOnlyRefused(name, 'update');
66
+ },
67
+ delete: async () => {
68
+ throw appendOnlyRefused(name, 'delete');
69
+ },
70
+ updateWhere: async () => {
71
+ throw appendOnlyRefused(name, 'updateWhere');
72
+ },
73
+ deleteWhere: async () => {
74
+ throw appendOnlyRefused(name, 'deleteWhere');
75
+ },
76
+ async upsertAll(rows, args: UpsertArgs<Row>) {
77
+ if (args.onMatch !== 'nothing') throw appendOnlyRefused(name, 'upsertAll');
78
+ return repo.upsertAll(rows, args);
79
+ },
80
+ };
81
+ return { ...repo, ...refusing };
82
+ };