@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 +49 -31
- package/README.md +76 -20
- package/package.json +6 -6
- package/src/aggregate.ts +31 -22
- package/src/append-only-errors.ts +53 -0
- package/src/append-only.ts +82 -0
- package/src/batch.ts +1 -1
- package/src/bulk-write.ts +4 -13
- package/src/column.ts +36 -1
- package/src/columns-data.ts +24 -3
- package/src/columns.ts +9 -2
- package/src/containment.ts +15 -2
- package/src/database.ts +9 -2
- package/src/describe.ts +5 -0
- package/src/entity-error.ts +31 -23
- package/src/entity.ts +18 -30
- package/src/errors.ts +18 -44
- package/src/expr.ts +66 -20
- package/src/feature-errors.ts +3 -1
- package/src/index-init.ts +29 -0
- package/src/index.ts +12 -3
- package/src/invariants.ts +3 -3
- package/src/judged-write.ts +28 -0
- package/src/like.ts +85 -0
- package/src/memory-match.ts +20 -82
- package/src/memory-repo.ts +14 -9
- package/src/memory-unique.ts +7 -6
- package/src/numeric-compare.ts +57 -0
- package/src/pg-driver.ts +53 -45
- package/src/pg-sql.ts +5 -1
- package/src/plan.ts +9 -3
- package/src/preload-ceiling.ts +75 -0
- package/src/preload.ts +20 -7
- package/src/query.ts +19 -17
- package/src/registry.ts +2 -0
- package/src/repo.ts +5 -6
- package/src/row-observer.ts +2 -2
- package/src/sealed-column.ts +7 -3
- package/src/sealed-repo.ts +2 -1
- package/src/sealed.ts +6 -4
- package/src/seed.ts +113 -74
- package/src/transition.ts +3 -0
- package/src/type-pins.ts +25 -0
- package/src/types.ts +6 -0
- package/src/unique-constraints.ts +44 -0
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` (
|
|
8
|
-
`
|
|
9
|
-
- `db` is tier 1
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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`, `
|
|
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
|
-
|
|
28
|
-
uniques are not checked in memory);
|
|
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
|
|
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`)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
52
|
-
(`X_REPO_CLIENT_PINNED`, in `client()` in `pg-driver.ts`); the fix names
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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` / `
|
|
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` /
|
|
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
|
|
779
|
-
|
|
780
|
-
a `
|
|
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
|
|
807
|
-
repository
|
|
808
|
-
`
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
`
|
|
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": "
|
|
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.
|
|
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": "
|
|
37
|
-
"@ultimat3/db": "
|
|
38
|
-
"@ultimat3/schema": "
|
|
39
|
-
"@ultimat3/time": "
|
|
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
|
|
75
|
-
* silently answering in whichever
|
|
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
|
-
|
|
82
|
-
): EntityError =>
|
|
83
|
-
new
|
|
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 (${
|
|
86
|
-
fix: `${entityName}.andWhere('${property}.currency', 'eq', '${
|
|
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()]
|
|
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
|
+
};
|