@ultimat3/entity 22.15.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +72 -51
- package/README.md +145 -21
- package/package.json +6 -5
- package/src/aggregate.ts +31 -22
- package/src/bulk-write.ts +4 -13
- package/src/column.ts +36 -1
- package/src/columns-data.ts +24 -3
- package/src/columns-scalar.ts +13 -6
- package/src/columns.ts +8 -1
- package/src/containment.ts +15 -2
- package/src/database.ts +9 -2
- package/src/entity-error.ts +45 -20
- package/src/entity.ts +81 -18
- package/src/errors.ts +16 -42
- package/src/expr.ts +66 -20
- package/src/index.ts +16 -1
- package/src/instant.ts +84 -5
- package/src/invariants.ts +3 -3
- package/src/judged-write.ts +28 -0
- package/src/like.ts +85 -0
- package/src/memory-match.ts +27 -82
- package/src/memory-repo.ts +16 -2
- package/src/memory-unique.ts +7 -6
- package/src/numeric-compare.ts +57 -0
- package/src/pg-driver.ts +38 -29
- package/src/pg-row.ts +4 -1
- package/src/pg-sql.ts +5 -1
- package/src/plan.ts +9 -3
- package/src/preload-ceiling.ts +75 -0
- package/src/preload.ts +23 -8
- package/src/query.ts +67 -34
- package/src/record-projection.ts +17 -2
- package/src/row-observer.ts +17 -2
- package/src/rows-of.ts +13 -1
- package/src/sealed-column.ts +123 -0
- package/src/sealed-declaration.ts +64 -0
- package/src/sealed-errors.ts +112 -0
- package/src/sealed-repo.ts +235 -0
- package/src/sealed.ts +196 -0
- package/src/seed.ts +97 -73
- package/src/transition.ts +3 -0
- package/src/type-pins.ts +109 -3
- package/src/types.ts +106 -0
- package/src/unique-constraints.ts +44 -0
package/CLAUDE.md
CHANGED
|
@@ -24,10 +24,14 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
24
24
|
Both are the bar.
|
|
25
25
|
- **`write-parity.test.ts` runs every write against memory AND PGlite**: a patch property present with
|
|
26
26
|
`undefined` is ABSENT (only `null` clears); memory refuses a duplicate PK, a duplicate non-partial
|
|
27
|
-
|
|
28
|
-
uniques are not checked in memory);
|
|
27
|
+
unique — index OR `c.unique([…])` invariant, the one list in `unique-constraints.ts` — and a PK
|
|
28
|
+
patch onto another row with `X_DB_UNIQUE_VIOLATION` (partial uniques are not checked in memory);
|
|
29
|
+
`.transition()` filters on `singleKeyOf(entity)` and refuses an absent id (`X_NOT_FOUND`, no row
|
|
30
|
+
moved);
|
|
29
31
|
`decimal()`/`bigint()` store Postgres's spelling (never rounded); a seed `upsert` compares only named
|
|
30
|
-
columns; `text({ max })` counts code points; `integer()` holds int4
|
|
32
|
+
columns; `text({ max })` counts code points; `integer()` holds int4, `bigint()` int8; a whole part
|
|
33
|
+
of `0` is no `numeric` digit; `url()` lower-cases its scheme and judges the STORED text.
|
|
34
|
+
`invariant-parity.test.ts` asks the app, the driver and the raw CHECK the same question.
|
|
31
35
|
- **`min`/`max` over a `timestamptz` crosses as epoch ms** (`aggregate-time-parity.test.ts`). A whole
|
|
32
36
|
row read under a non-UTC session still fails on PGlite — recorded, not fixed.
|
|
33
37
|
- **Text ordering: byte (`C`) order is the parity target** — a linguistic production collation is a
|
|
@@ -37,10 +41,13 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
37
41
|
holds a `number`); `RowWrite<Row>` types `insert`/`insertAll`/`upsertAll`; `narrowRow` (`columns.ts`)
|
|
38
42
|
narrows at each write method's ENTRY, before `$assert`/`upsertPlan`. `pg-money-write.live.test.ts`,
|
|
39
43
|
`money-write-parity.test.ts`, `type-pins.ts`.
|
|
40
|
-
- **A PREDICATE's meaning is decided by the column's KIND** (`memory-match.ts`)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
- **A PREDICATE's meaning is decided by the column's KIND** (`memory-match.ts`); `compareByKind` /
|
|
45
|
+
`sameValueOfKind` are EXPORTED — `@ultimat3/query` calls them. **ONE numeric comparison,
|
|
46
|
+
`numericOrder`**: `DECIMAL_TEXT` columns by VALUE (`'2.50' = 2.5`), `number`/`bigint` numerically
|
|
47
|
+
(`compare-parity.test.ts`). A `uuid` is a value, compared lower-cased and
|
|
48
|
+
stored lower-case (`keyOf`, `parseUuid`, `narrowUuid`; text is never narrowed); `LIKE` (`like.ts`)
|
|
49
|
+
uses Postgres' default `\` escape, refuses a trailing escape, counts CHARACTERS and is a bounded
|
|
50
|
+
two-pointer walk — never a regex; `in` takes a
|
|
44
51
|
list or nothing, and a NULL in it emits `(col in (…) or col is null)`; a column the row never NAMED
|
|
45
52
|
is NULL for `eq`/`neq`/`in`.
|
|
46
53
|
- **The Postgres driver is proved against a real Postgres** (`pg-driver.live.test.ts`, skipped without
|
|
@@ -48,9 +55,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
48
55
|
- **A repository call rejects, never throws synchronously** (`tableFor`'s writes are `async`).
|
|
49
56
|
- **`defaultDriver()`** is the process default; `Driver.reset?()` is optional (memory only), resets
|
|
50
57
|
repositories in place. Test seam only.
|
|
51
|
-
- **A repository pinned to its own client
|
|
52
|
-
(`X_REPO_CLIENT_PINNED`, in `client()` in `pg-driver.ts`); the fix names
|
|
53
|
-
an unpinned repository.
|
|
58
|
+
- **A repository pinned to its own client joins a transaction opened ON that client** (`tx.origin`)
|
|
59
|
+
**and refuses any other** (`X_REPO_CLIENT_PINNED`, in `client()` in `pg-driver.ts`); the fix names
|
|
60
|
+
`setDbClient(client)` plus an unpinned repository.
|
|
54
61
|
|
|
55
62
|
## Do not regress — reading
|
|
56
63
|
|
|
@@ -63,7 +70,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
63
70
|
`writing()`), keyed by id, bounded (`MAX_SIBLING_KEYS`, oldest page first). One switch:
|
|
64
71
|
`postgresDriver({ jitPreload: false })` — never an `app.config.ts` key. Shared pieces live in
|
|
65
72
|
`batch-read.ts`.
|
|
66
|
-
- **`preload(name)` shares `batch-read.ts
|
|
73
|
+
- **`preload(name, { max })` shares `batch-read.ts`, keeps no request cache and is BOUNDED**
|
|
74
|
+
(`preload-ceiling.ts`: default `MAX_PRELOADED_ROWS`, refused past it, never truncated). Tenancy is CARRIED only when
|
|
67
75
|
both entities are scoped by a column of that same name, else the related read refuses
|
|
68
76
|
(`X_TENANCY_UNSCOPED`). Reach is the `database()` call's `RelatedTables`; `select()` widens with each
|
|
69
77
|
preloaded relation's local key; attachment copies (`{ ...row }`); only `page()`/`all()`/`one()`
|
|
@@ -87,37 +95,13 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
87
95
|
(`close()` = `AsyncGenerator.return()`), `.cursor` is advanced before the yield, no empty batch, and
|
|
88
96
|
three refusals on the chain (size, `limit()` alongside, an order no cursor can carry — judged by
|
|
89
97
|
exported `totalOrder`).
|
|
90
|
-
- **A grouped count means one thing in both drivers,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
no caller can look up again and the result would be a map that only ever answers `undefined`. The
|
|
98
|
-
refusal is `X_INVARIANT_VIOLATED` naming a column of *this* entity that is groupable, never
|
|
99
|
-
`x entity explain`: what repairs it is one edit to the call, and the entity is the only place the
|
|
100
|
-
replacement column lives. **The bound is a refusal, not a truncation.** The statement asks for
|
|
101
|
-
`MAX_GROUPS + 1` groups — the trick a page already uses when it reads one row past its limit — and
|
|
102
|
-
that extra group is what says the answer was never going to fit, so `countsFrom` throws with the
|
|
103
|
-
`andWhere(…, 'in', <values>)` that bounds it. Truncating would hand back a map that reads exactly
|
|
104
|
-
like a complete one, and a caller recounting from it would write the wrong number to every row it
|
|
105
|
-
missed. **Absent is not `0`**: a value nothing matched has no entry, because that is what
|
|
106
|
-
`group by` returns and it is the only way a caller can tell "none" from "never asked" — the
|
|
107
|
-
`?? 0` is theirs to write, and inventing it here would answer for keys the table has never seen.
|
|
108
|
-
**NULL is one group**, keyed `null`: the memory driver reads the property as `?? null` so it lands
|
|
109
|
-
where Postgres puts its NULL rows, while `0`, `''` and `false` stay the values they are. **The
|
|
110
|
-
order is applied after the rows are in, never in SQL** — a hash aggregate returns groups in
|
|
111
|
-
whatever order it built them and a `Map` filled row by row returns insertion order, so an
|
|
112
|
-
`order by` in the statement would let the two drivers disagree about a result they agree on;
|
|
113
|
-
sorting groups (never rows) costs nothing at this size and is what puts the largest bucket at the
|
|
114
|
-
front. **Both output names are fixed aliases** — `group_value` and `group_count` in
|
|
115
|
-
`countByStatement` (`pg-sql.ts`) — because an entity is free to declare a column called `count`,
|
|
116
|
-
and the un-aliased form would then return two outputs of one name; the grouped value is re-parsed
|
|
117
|
-
by the column that declared it, since `int8` arrives as a string and would otherwise key the map
|
|
118
|
-
by text where memory keys it by a `bigint`. **Nothing new to declare**: no `groupBy()` builder and
|
|
119
|
-
no error code of its own — it is a terminal on the chain that already exists, over exactly the
|
|
120
|
-
rows `count()` counts.
|
|
98
|
+
- **A grouped count means one thing in both drivers, written in `count-by.ts`**: both call
|
|
99
|
+
`groupColumnOf` before the statement and `countsFrom` after the rows. Groupable kinds are a closed
|
|
100
|
+
set (`uuid`, `text`, `char`, `boolean`, `integer`, `bigint`); the bound is a REFUSAL at
|
|
101
|
+
`MAX_GROUPS + 1`, never a truncation; absent is not `0`; NULL is one group keyed `null`; the order
|
|
102
|
+
is applied after the rows are in, never in SQL; `group_value` / `group_count` are fixed aliases.
|
|
103
|
+
The refusal names a groupable column of THIS entity, never `x entity explain`.
|
|
104
|
+
The argument for each: [`docs/history/entity.md`](../../docs/history/entity.md).
|
|
121
105
|
- **The codec is `@ultimat3/core`'s**, reached through `cursorFor(entity, plan, row, id)` and
|
|
122
106
|
`seekFrom(entity, plan)`; **`assertSeekable` runs in `planFor`**, before a statement exists. A
|
|
123
107
|
`timestamptz` sort key is refused when its `<column>$US` alias would pass 63 bytes. A cursor is bound
|
|
@@ -132,12 +116,12 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
132
116
|
- **The four aggregates** (`sum`, `avg`, `min`, `max`) share `aggregate.ts`
|
|
133
117
|
(`aggregate-fold.ts` memory, `aggregate-decode.ts` Postgres): never a float; `avg` at `AVG_SCALE` (6),
|
|
134
118
|
half away from zero; `null` for an empty set. Refused: `min`/`max` on text, `avg` over money
|
|
135
|
-
(`X_AGGREGATE_UNSUPPORTED`), mixed currency or scale (`X_AGGREGATE_MIXED_CURRENCY
|
|
119
|
+
(`X_AGGREGATE_UNSUPPORTED`), mixed currency or scale (`X_AGGREGATE_MIXED_CURRENCY`, two causes, two fixes), a money total
|
|
136
120
|
past ±2^53. **`approximateCount()` is `reltuples`**; a filtered chain or tenant-scoped entity is
|
|
137
121
|
`X_APPROXIMATE_COUNT_FILTERED`; `null` for an unanalysed table.
|
|
138
122
|
- **`json()` / `arrayOf()` are filterable** (`contains`, `contained-by`, `overlaps`, `has-key`) with
|
|
139
|
-
Postgres' measured meaning in `containment.ts`; `has-key` emits `operator(pg_catalog.?)` (indexable)
|
|
140
|
-
|
|
123
|
+
Postgres' measured meaning in `containment.ts`; `has-key` emits `operator(pg_catalog.?)` (indexable)
|
|
124
|
+
and takes a STRING key, else no rows; a NULL array element equals nothing. No jsonpath operator.
|
|
141
125
|
- **A GIN index is declarable** (`using: 'gin'`; the set is `@ultimat3/db`'s `INDEX_METHODS`); absent
|
|
142
126
|
is `btree` byte for byte; the method joins the name discriminator only when declared; GIN cannot be
|
|
143
127
|
unique or ordered (refused here). `pg-containment.live.test.ts`.
|
|
@@ -169,7 +153,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
169
153
|
local renderers are deleted). An untranslatable predicate is `kind: 'assert'`, `sql: null`.
|
|
170
154
|
**`InvariantDescription.columns` is projected** (`describe-invariant.test.ts`).
|
|
171
155
|
- **The two halves must AGREE, term by term** (`expr.ts`): `matches(/…/i)` is `~*`, other flags are
|
|
172
|
-
refused (`matchOperator`); `minLength` counts code points
|
|
156
|
+
refused (`matchOperator`); `minLength` counts code points; `trimmed()` strips U+0020 only (`btrim`);
|
|
157
|
+
`atLeast`/`eq(<number>)` judge by the column's KIND (`invariantColumns(name, columns)`), so a
|
|
158
|
+
`bigint()`/`decimal()` row compares by its digits. **`isNull()`/`isNotNull()` are the only
|
|
173
159
|
total members**; `iff(a, b)` renders `(a) = (b)` — measured as the safe direction
|
|
174
160
|
(`pg-invariant-null.live.test.ts`); `iff` is a function, and a `unique` operand is refused with the
|
|
175
161
|
`c.unique([…])` fix built through `JSON.stringify`. One app-only operand makes the whole rule
|
|
@@ -224,7 +210,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
224
210
|
- **`deleteWhere` / `updateWhere` are the only filtered writes**: empty filter `X_WRITE_UNFILTERED`,
|
|
225
211
|
empty patch `X_PATCH_EMPTY` (undefined values dropped first); soft delete respected; both return a
|
|
226
212
|
count; rows come back only when a JS-only invariant needs them (`hasJsOnlyInvariant`), counted first
|
|
227
|
-
and refused past `MAX_ASSERTED_ROWS` (50,000) in BOTH drivers
|
|
213
|
+
and refused past `MAX_ASSERTED_ROWS` (50,000) in BOTH drivers; on Postgres that statement and its
|
|
214
|
+
judgement share a transaction (`judged-write.ts`, `update` too), so a refusal rolls back. `updateStatement`'s `returning` is
|
|
228
215
|
required.
|
|
229
216
|
- **Every instant the write path stamps comes from `entityNow()`** (`clock.ts`, `ctx.clock`).
|
|
230
217
|
**`touch()` in `query.ts` is the ONE place `onUpdateNow()` columns are stamped.**
|
|
@@ -238,7 +225,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
238
225
|
- **A seed is replayable**: `SeedContext.insert` is one `upsertAll(…, { onMatch: 'nothing' })` per
|
|
239
226
|
call; a generated PK the row does not name is refused. `upsert(entity, { by }, values)` keys on a
|
|
240
227
|
natural key, reads first for the report, writes one `on conflict … do update`, preserves `createdAt`.
|
|
241
|
-
**The environment guard is the CLI's** (`seedTiersFor`; `run()` stays permissive).
|
|
228
|
+
**The environment guard is the CLI's** (`seedTiersFor`; `run()` stays permissive). **A dry run is
|
|
229
|
+
the real run rolled back** through `Driver.transactor()` (REQUIRED on the interface) — one rule,
|
|
230
|
+
never a per-verb lookup.
|
|
242
231
|
- **`setRowObserver` reports committed row changes above the driver** — one per process (returns the
|
|
243
232
|
replaced one), applied by `database()`, one comparison per write when unset, `before` only when the PK
|
|
244
233
|
is `id`, a filtered write is `onBulk`. Not a second change-feed path.
|
|
@@ -248,6 +237,36 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
248
237
|
after the refusal from a tenant-scoped `findById`; no DDL beyond `enumerated()`'s CHECK; a machine
|
|
249
238
|
column may not be nullable; `whyNot` asks unknown → terminal → legal list.
|
|
250
239
|
|
|
240
|
+
## Do not regress — sealed columns
|
|
241
|
+
|
|
242
|
+
- **`sealed-repo.ts` is the ONE seam**: `memoryRepo()` and `postgresRepo()` both return through
|
|
243
|
+
`sealedRepo(entity, repo)`, which seals rows and patches going in, opens rows coming out and
|
|
244
|
+
rewrites or refuses every predicate. A driver never seals for itself; an entity with no sealed
|
|
245
|
+
column gets its repository back untouched. The cipher is core's (`scripts/seal-calls.ts`).
|
|
246
|
+
- **A sealed column's `$parse` passes a sealed string and judges a plaintext**; `decodeRow` parses
|
|
247
|
+
none. `sealValue` runs the UNWRAPPED plaintext parser (`$meta.sealed.plaintext`) BEFORE sealing — nothing
|
|
248
|
+
after can, and the column's own `$parse` would pass a sealed-shaped lookalike.
|
|
249
|
+
- **No reading of an unsealed value.** Not sealed is `X_SEAL_INVALID`; existing plaintext is
|
|
250
|
+
expand → `backfill()` → contract (README). No `legacy` option.
|
|
251
|
+
- **Purpose is `entity:<table>.<column>`, physical names** (`sealed.ts`). **Sealing is not on
|
|
252
|
+
`$describe()`**: it changes no DDL, so it must not move the drift hash — the manifest and
|
|
253
|
+
`x doctor` read `sealedFields(entity)`. Only `check`/`length` leave the description.
|
|
254
|
+
- **A sealed property on a repository row is SERVER-ONLY** — own, readable, writable, NOT
|
|
255
|
+
enumerable (`serverOnly`, `sealed.ts`; set by `openRow`). Every generic serialiser omits it, which
|
|
256
|
+
is what holds a `query`'s rows, a loose `output`, a log line, an island prop, a job payload and a
|
|
257
|
+
cache tier — none of them parses a row. So ABOVE the seam a row is copied with `copyRow` /
|
|
258
|
+
`pickRow` (descriptors), never `{ ...row }`, `Object.assign({}, row)` or `out[k] = row[k]`:
|
|
259
|
+
`preload.ts`, `select()` and `touch()` in `query.ts`. `sealRow` reads each sealed property BY NAME,
|
|
260
|
+
so a row passed back whole is written whole. A required one missing on insert is `sealedMissing`
|
|
261
|
+
(`X_INVARIANT_VIOLATED`, from `$parse` and from the seam), naming the spread. `sealed.test.ts`.
|
|
262
|
+
- **Nothing sealed leaves**: `$schema` (node and parsed value; typed `WireRow` on `Entity`,
|
|
263
|
+
`unknown` on `EntityCore`), `$view` (`X_ENTITY_SEALED_IN_VIEW`), `rowsOf`, the row observer, and
|
|
264
|
+
the repository row itself (above). `sealed-egress.test.ts` here and one per consuming package.
|
|
265
|
+
- **Opaque: NULL tests only. `lookup`: also `eq` / `in`, `.unique()`, an unordered index.**
|
|
266
|
+
Refused at the type (`SealedKeysOf<C>` on `ReadBuilder`, `type-pins.ts`) AND at runtime
|
|
267
|
+
(`X_ENTITY_SEALED_PREDICATE`). A filtered write's FILTER refuses both kinds. Declaration rules
|
|
268
|
+
are `sealed-column.ts` (the chain) and `sealed-declaration.ts` (the entity).
|
|
269
|
+
|
|
251
270
|
## Do not regress — records and tests
|
|
252
271
|
|
|
253
272
|
- **An entity row on the client is a RECORD, derived** (`row-schema.ts`, `ENTITY_BRAND` =
|
|
@@ -256,7 +275,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
256
275
|
`persist` defaults `false`, read only off the projection. **The browser path is
|
|
257
276
|
`@ultimat3/entity/record`**, whose modules import `entity-error.ts`, never `errors.ts`
|
|
258
277
|
(`record-bundle.test.ts`).
|
|
259
|
-
- Never throw a bare `Error` — use `errors.ts`.
|
|
278
|
+
- Never throw a bare `Error` — use `errors.ts`. `X_DB_DRIFT` is `@ultimat3/db`'s, factory included.
|
|
260
279
|
- **Tests restore the registry with a FILE-scope `afterAll(() => { clearRegistry(); })`** — matched on
|
|
261
280
|
exact text by `live-registry-cleanup.test.ts`; never inside a skippable suite's teardown.
|
|
262
281
|
|
|
@@ -268,9 +287,11 @@ Each file's header states its one job. The map, by area: `types.ts` (derivation;
|
|
|
268
287
|
`entity.ts` / `describe.ts` / `index-name.ts` / `search.ts`, `state-machine.ts` / `transition.ts`,
|
|
269
288
|
`feature-errors.ts`, `view.ts` / `row-schema.ts` / `record-projection.ts` / `record-key.ts` /
|
|
270
289
|
`rows-of.ts` / `record-table.ts` / `record.ts`, `entity-error.ts` / `errors.ts`, `query.ts` /
|
|
271
|
-
`database.ts` / `clock.ts`, `memory-match.ts` / `
|
|
290
|
+
`database.ts` / `clock.ts`, `memory-match.ts` / `like.ts` / `numeric-compare.ts` / `unique-constraints.ts` / `repo.ts` /
|
|
291
|
+
`memory-repo.ts` / `tenancy.ts` /
|
|
272
292
|
`cross-tenant.ts`, `plan.ts` / `cursor.ts` / `batch.ts`, `pg-driver.ts` / `coalesce.ts` /
|
|
273
|
-
`batch-read.ts` / `bulk-write.ts` / `count-by.ts` / `jit-preload.ts` / `preload.ts` /
|
|
293
|
+
`batch-read.ts` / `bulk-write.ts` / `count-by.ts` / `jit-preload.ts` / `preload.ts` /
|
|
294
|
+
`preload-ceiling.ts` / `judged-write.ts` / `pg-sql.ts` /
|
|
274
295
|
`pg-row.ts`, `row-observer.ts` / `write-tag.ts` (a keyed request's write names itself in the WAL via
|
|
275
296
|
`pg_logical_emit_message`), `registry.ts` / `relations.ts` / `n-plus-one.ts` / `seed.ts`,
|
|
276
297
|
`type-pins.ts`, `live-registry-cleanup.test.ts`.
|
package/README.md
CHANGED
|
@@ -110,13 +110,124 @@ never runs, while the subpath retains the projection, the key and the registry a
|
|
|
110
110
|
| `money()` | `<name>_minor bigint` + `<name>_currency char(3)` + `<name>_scale integer null` | never a float, never one implied currency. The row value is `@ultimat3/schema`'s `MoneyValue` — the same declaration `@ultimat3/money`'s `Money` is — so a decoded row goes straight to `add()`/`formatMoney()`. A writer may hand a `bigint` — `Insertable` says so on a table, `RowWrite<Row>` at a repository, and every whole-row write narrows it at its own entry, before an invariant or a statement sees it; a minor unit past ±2^53 is refused there and on read, never rounded. `scale` is the decimal exponent `minor` counts in when it is not the currency's own (`{ minor: 2, currency: 'USD', scale: 6 }` is $0.000002); NULL in the column means "the currency's own minor unit" and round-trips as an ABSENT key, never as `0` |
|
|
111
111
|
| `enumerated(v)` | `text` + CHECK | a variant is a one-line migration, not `ALTER TYPE` |
|
|
112
112
|
| `tz(zones)`, `locale(tags)` | `text` + CHECK, `Intl`-validated at declaration | an offset is not a time zone |
|
|
113
|
-
| `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too |
|
|
113
|
+
| `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too. `url()` judges the STORED text against its CHECK (`^https?://`), so leading whitespace, a tab inside the scheme and `https:/a.b` are refused here rather than by a raw `23514` |
|
|
114
114
|
|
|
115
115
|
Chain: `.primaryKey()` · `.nullable()` · `.unique()` · `.default(v)` · `.defaultNow()` ·
|
|
116
|
-
`.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)
|
|
116
|
+
`.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)` ·
|
|
117
|
+
`.sealed()` (`text()` only).
|
|
117
118
|
Physical names are derived from the property key (`orgId` → `org_id`); a name is written once, or
|
|
118
119
|
never — `.column()` is the exception, and it exists for tables this framework did not create.
|
|
119
120
|
|
|
121
|
+
## A sealed column
|
|
122
|
+
|
|
123
|
+
A credential column is declared `.sealed()`. The row type stays `string`; the database never sees
|
|
124
|
+
the plaintext.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { database, entity, text, uuid } from '@ultimat3/entity';
|
|
128
|
+
|
|
129
|
+
export const connections = entity('connections', {
|
|
130
|
+
columns: {
|
|
131
|
+
id: uuid().primaryKey(),
|
|
132
|
+
label: text({ max: 80 }),
|
|
133
|
+
password: text({ max: 200 }).sealed(), // opaque: read and written, never compared
|
|
134
|
+
token: text().nullable().sealed(),
|
|
135
|
+
email: text().sealed({ lookup: true }).unique(), // deterministic: equality only
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const db = database({ connections });
|
|
140
|
+
|
|
141
|
+
export async function passwordFor(email: string): Promise<string | null> {
|
|
142
|
+
const row = await db.connections.where({ email }).one();
|
|
143
|
+
return row?.password ?? null; // the plaintext — on the server, and nowhere else
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
| Fact | |
|
|
148
|
+
|---|---|
|
|
149
|
+
| Builder | `text()` only — `text().sealed()`, in any order with `.nullable()`, `.unique()`, `.column()`. A sealed number or date does not compile |
|
|
150
|
+
| Stored | `x1.<keyId>.<iv>.<ciphertext+tag>` from `@ultimat3/core`'s `seal()`, in a plain `text` column. No new SQL type; a `text({ max })` bound is checked against the plaintext before sealing and emits no CHECK |
|
|
151
|
+
| Key | the master key `x secrets` manages. No key is `X_SEAL_KEY_MISSING` — the plaintext is never stored instead |
|
|
152
|
+
| Purpose | derived: `entity:<table>.<column>`, physical names. Never stated, so two columns cannot share one; a value copied between sealed columns does not open. Renaming the table or the column is a re-seal — pin the name with `.column()` |
|
|
153
|
+
| Both drivers | `memoryDriver()` seals too; `memoryRepo(entity, seed)` refuses a seed for a sealed entity (`insertAll` instead) |
|
|
154
|
+
| The seam | `sealedRepo(entity, repo)` — both driver factories return through it, so `database()`'s handle and a hand-held `postgresRepo(entity)` agree. A driver written outside this package returns through it too |
|
|
155
|
+
| Cost | the key ring is resolved ONCE per repository call, however many values it seals or opens — a 500-row page is one resolution, not a thousand |
|
|
156
|
+
| Retry | `X_ENTITY_SEALED_PREDICATE` and `X_ENTITY_SEALED_IN_VIEW` are registered `terminal` (`ENTITY_ERROR_RETRY`): a job stops on its first attempt |
|
|
157
|
+
|
|
158
|
+
**What leaves the server: nothing.**
|
|
159
|
+
|
|
160
|
+
| Path | Sealed column |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `entity.$schema` (an action's or query's `output`) | absent from the node and from the parsed value; the type is `WireRow` — the row without it |
|
|
163
|
+
| `entity.$view([...])` | `X_ENTITY_SEALED_IN_VIEW` |
|
|
164
|
+
| a record (`rowsOf`, `recordProjection`, the `persist: true` browser store) | dropped from every collected row; `projection.sealed` lists them |
|
|
165
|
+
| a change reported to `setRowObserver` | dropped from `before` and `after` |
|
|
166
|
+
| `decodeRow` on a write-ahead-log row | the stored string, never the plaintext (`@ultimat3/realtime` drops the property before a change leaves) |
|
|
167
|
+
| a repository row serialised whole — a `query`'s rows, a loose `output`, a log line, an error's `meta`, an island prop, a job payload, a cache tier | absent: the property is **server-only** (below) |
|
|
168
|
+
| `{ token: row.token }` — the secret named by hand | leaves. Naming it is the one read there is |
|
|
169
|
+
| `$parse`, `$row`, the repository | the whole row, plaintext included — server code only |
|
|
170
|
+
|
|
171
|
+
**A sealed property is server-only**, `As of 2026-10`: own, readable and writable, **not
|
|
172
|
+
enumerable**. `row.password` is the plaintext; nothing that walks the row sees it.
|
|
173
|
+
|
|
174
|
+
| | |
|
|
175
|
+
|---|---|
|
|
176
|
+
| Reads it | `row.password`, `const { password } = row`, `update(id, row)` and `insert(row)` with the row passed whole |
|
|
177
|
+
| Does not | `JSON.stringify(row)`, `{ ...row }`, `Object.keys` / `entries` / `assign`, `structuredClone(row)` |
|
|
178
|
+
| Prints it | `console.log(row)` / `Bun.inspect(row)` — log through `logger` |
|
|
179
|
+
| A spread patch | `update(id, { ...row, label })` leaves the stored secret alone |
|
|
180
|
+
| A spread insert, required column | `X_INVARIANT_VIOLATED` naming the column and the rewrite: `insert({ ...row, password: row.password })` |
|
|
181
|
+
| A spread insert, **nullable** column | stores NULL — silent; an absent nullable column is a legal insert. Name it: `{ ...row, token: row.token }` |
|
|
182
|
+
| `select({ password: true })`, `preload()` | the row keeps it, still server-only |
|
|
183
|
+
|
|
184
|
+
**Testing a sealed row.** `toEqual` walks enumerable properties, so it cannot see the secret:
|
|
185
|
+
`expect(row).toEqual({ …, password })` fails with an empty diff, and `expect(a).toEqual(b)` passes
|
|
186
|
+
over a wrong one. Write one of:
|
|
187
|
+
|
|
188
|
+
| Assertion | Compares |
|
|
189
|
+
|---|---|
|
|
190
|
+
| `expect(row).toEqualRow({ id, label: 'primary', password: 'hunter2' })` | every own property (`@ultimat3/testing`) |
|
|
191
|
+
| `expect(row.password).toBe('hunter2')` | the one property, by name |
|
|
192
|
+
| `expect(row).toMatchObject({ password: 'hunter2' })` | the named subset |
|
|
193
|
+
|
|
194
|
+
`toEqualRow` compares every own property and names what differs — a server-only one by name,
|
|
195
|
+
never by value. No key is needed in a test: `@ultimat3/testing/preload` installs a throwaway one
|
|
196
|
+
when the app supplied none.
|
|
197
|
+
|
|
198
|
+
**What the database may compare: nothing, or equality.**
|
|
199
|
+
|
|
200
|
+
| Use | `.sealed()` | `.sealed({ lookup: true })` |
|
|
201
|
+
|---|---|---|
|
|
202
|
+
| `where` / `andWhere` `eq`, `in` | compile error; `X_ENTITY_SEALED_PREDICATE` at runtime | allowed — rewritten to the stored string |
|
|
203
|
+
| `andWhere(column, 'is-null')` / `'is-not-null'` | allowed — `null` is stored as NULL, never sealed, so presence is not a secret | allowed |
|
|
204
|
+
| any other operator, `orderBy`, `countBy`, an aggregate, a cursor | refused | refused |
|
|
205
|
+
| `deleteWhere` / `updateWhere` **filter** | refused | refused — find the row, then write by id |
|
|
206
|
+
| `.unique()`, a unique index, an upsert conflict target | refused | allowed |
|
|
207
|
+
| a plain index | refused | allowed, unordered |
|
|
208
|
+
| an invariant other than `unique` | refused | refused — a CHECK would read ciphertext |
|
|
209
|
+
| `.default()`, `.primaryKey()`, `.tenant()`, `.references()`, `.searchable()` | refused on the chain | refused on the chain |
|
|
210
|
+
|
|
211
|
+
`lookup` **reveals equality**: two rows holding one value store one string. Never for a low-entropy
|
|
212
|
+
value — a boolean, a status, a PIN.
|
|
213
|
+
|
|
214
|
+
**During a key rotation** a lookup value has one stored form per declared key, so equality reads
|
|
215
|
+
through `in (…)` over `sealAll()` and `.unique()` holds per key. `x doctor` reports
|
|
216
|
+
`X_SEAL_RESEAL_PENDING` until every row is rewritten (`update(id, { column: row.column })` seals
|
|
217
|
+
under the current key) and the retired key is dropped (`x secrets rotate --drop <keyId>`).
|
|
218
|
+
|
|
219
|
+
**A column that already holds plaintext is migrated, never tolerated.** A stored value that is not
|
|
220
|
+
a sealed value is `X_SEAL_INVALID`, always. Expand, backfill, contract:
|
|
221
|
+
|
|
222
|
+
| # | Step | Edit |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| 1 | expand | add `passwordSealed: text().nullable().sealed()` beside `password`; `x db gen "add sealed password"`; `x db migrate` |
|
|
225
|
+
| 2 | backfill | a `backfill()` over `db.connections.andWhere('passwordSealed', 'is-null')` — only the rows still to seal — whose `handle` runs `update(row.id, { passwordSealed: row.password })` per row. Idempotent, so a replayed page is harmless |
|
|
226
|
+
| 3 | switch | readers and writers use `passwordSealed` |
|
|
227
|
+
| 4 | contract | delete `password`; declare `password: text().sealed().column('password_sealed')` — the purpose follows the physical name, so nothing is re-sealed; `x db gen "drop plaintext password"` |
|
|
228
|
+
|
|
229
|
+
The full recipe with the `backfill()` declaration: [Entities and migrations](../../wiki/Entities-And-Migrations.md#sealing-a-column-that-already-holds-data).
|
|
230
|
+
|
|
120
231
|
## Wide columns
|
|
121
232
|
|
|
122
233
|
The vocabulary an EXISTING schema needs. The blessed set above is a decision the framework made
|
|
@@ -125,9 +236,9 @@ for a table it was going to create; these are the shapes a table already has, `A
|
|
|
125
236
|
| Builder | Emits | Row type, and why |
|
|
126
237
|
|---|---|---|
|
|
127
238
|
| `json(schema)` | `jsonb` | the schema is **required**: a `json()` returning `unknown` is the `any` hole this framework forbids, and a column is the worst place for one — the value arrives from the database as often as from a caller. The object is bound as an object, never as JSON text |
|
|
128
|
-
| `decimal({ precision, scale })` | `numeric(p, s)` | a `string`, the exact digits. Money is the one decimal with an opinion (integer minor units + a currency); this is every other one, and a value with more decimal places than the column stores is refused rather than rounded |
|
|
239
|
+
| `decimal({ precision, scale })` | `numeric(p, s)` | a `string`, the exact digits. Money is the one decimal with an opinion (integer minor units + a currency); this is every other one, and a value with more decimal places than the column stores is refused rather than rounded. A whole part of `0` counts no digit, so `numeric(2, 2)` holds `0.50` |
|
|
129
240
|
| `date()` | `date` | `@ultimat3/time`'s `PlainDate` — a calendar date, no time, no zone. `effective_on` is the date a rate applies, and as a `timestamptz` it is a different date on either side of midnight for half the planet |
|
|
130
|
-
| `bigint()` | `bigint` | a decimal `string`. A JS `bigint` is what `JSON.stringify` throws on and a `number` loses digits past 2^53 — which is exactly where a legacy `int8` key lives. Both driver spellings (a string from Bun's `sql`, a `bigint` from PGlite) arrive as one |
|
|
241
|
+
| `bigint()` | `bigint` | a decimal `string`. A JS `bigint` is what `JSON.stringify` throws on and a `number` loses digits past 2^53 — which is exactly where a legacy `int8` key lives. Both driver spellings (a string from Bun's `sql`, a `bigint` from PGlite) arrive as one. Bounded at `int8` (−2^63 … 2^63−1): one past either end is refused where Postgres answers `22003` |
|
|
131
242
|
| `bytes()` | `bytea` | a plain `Uint8Array`, normalised: Bun's `sql` returns a `Buffer` and PGlite a `Uint8Array`, and the two do not serialise alike |
|
|
132
243
|
| `arrayOf(column)` | `<element>[]` | `readonly T[]`, each member parsed by the element column it was given. Money and nested arrays are refused — an element is one scalar column |
|
|
133
244
|
|
|
@@ -376,7 +487,10 @@ written schema-qualified as `operator(pg_catalog.?)` — the function form `json
|
|
|
376
487
|
the same test and the planner will not match a GIN index to it. Their
|
|
377
488
|
meaning is Postgres', to the letter, in both drivers — including that the array-contains-a-primitive
|
|
378
489
|
exception applies at the **top level only** and to **primitives only**, and that `&&`'s empty
|
|
379
|
-
operand overlaps nothing where `@>`'s is contained by everything.
|
|
490
|
+
operand overlaps nothing where `@>`'s is contained by everything. Two more rules both drivers hold:
|
|
491
|
+
a NULL **array element** equals nothing, itself included, on either side of `@>`, `<@` and `&&`
|
|
492
|
+
(a `jsonb` `null` is a value and still equals itself); and `has-key` takes a **string** key — any
|
|
493
|
+
other operand matches no row, never `String(operand)`.
|
|
380
494
|
|
|
381
495
|
There is no jsonpath expression operator: `contains` already matches nested structure, and a path
|
|
382
496
|
language inside the query language would be a second way to ask one question. `&&` on a `jsonb`
|
|
@@ -488,6 +602,7 @@ const shipped = await db.orders.transition('status', id, { from: 'paid', to: 'sh
|
|
|
488
602
|
| `onUpdateNow()` moves, because a transition is an update | the audit of *when* it moved, with no second mechanism beside it |
|
|
489
603
|
| the CHECK comes from `enumerated()` | the machine emits no DDL of its own — one declaration of what a legal value is |
|
|
490
604
|
| `memoryDriver()` answers it exactly as Postgres does | a compare-and-set over a map is the same question; unlike a `tsvector` match, there is nothing to fake |
|
|
605
|
+
| an `undefined` or `null` id is `X_NOT_FOUND`, and moves no row | an absent id names no row — it is refused before the statement, where it used to drop out of the filter and move every row in the `from` state |
|
|
491
606
|
|
|
492
607
|
What is deliberately **not** here: who may make a move, what happens on arrival, an approval chain,
|
|
493
608
|
a reason code. Those differ per app — wrap `transition()` in your own function and put them there.
|
|
@@ -548,7 +663,7 @@ and never unwritten.
|
|
|
548
663
|
| Tenancy | the plan a read builds, through `scopedPlan` — the actor's org predicate is in the statement, and the empty-filter guard runs before it, because one tenant's every row is still every row. The patch is judged too, through `assertRowTenant`: a filter bounds which rows are written, never what they become |
|
|
549
664
|
| Soft delete | the entity's `deletedAt` column is the same switch `delete(id)` uses. Stamped rows are not matched again by either call, so the original deletion time survives and a deleted row is never patched back into shape |
|
|
550
665
|
| `onUpdateNow()` | stamped by `touch()`, the same helper `update(id, patch)` uses — one place, so the two can never disagree about `updatedAt` |
|
|
551
|
-
| Rows read back | **only when something can still refuse them.** A `check` or a `unique` invariant is a constraint Postgres enforced on the statement, so the answer is a count and the statement carries no `returning *`. Only a JS-only rule (`kind: 'assert'`, `sql: null`) has to be judged on the result — and then the match is counted first and refused past `MAX_ASSERTED_ROWS` (50,000), naming `inBatches(1000)`, because a refusal issued after `returning *` is already holding what it is refusing |
|
|
666
|
+
| Rows read back | **only when something can still refuse them.** A `check` or a `unique` invariant is a constraint Postgres enforced on the statement, so the answer is a count and the statement carries no `returning *`. Only a JS-only rule (`kind: 'assert'`, `sql: null`) has to be judged on the result — and then the match is counted first and refused past `MAX_ASSERTED_ROWS` (50,000), naming `inBatches(1000)`, because a refusal issued after `returning *` is already holding what it is refusing. On Postgres the statement and that judgement share one transaction (a SAVEPOINT inside an open one), for `update(id, patch)` too, so a refused write is rolled back rather than left committed |
|
|
552
667
|
|
|
553
668
|
## Writing many rows
|
|
554
669
|
|
|
@@ -645,6 +760,7 @@ page.rows[0].author; // the member row, or null — always present
|
|
|
645
760
|
| Unknown name | `X_PRELOAD_UNKNOWN_RELATION` at `preload()` itself, not a page later |
|
|
646
761
|
| Shape | `belongsTo` attaches the row or `null`; `hasMany` an array — always present |
|
|
647
762
|
| Statements | one extra per relation, resolved concurrently; naming one twice is one statement |
|
|
763
|
+
| Ceiling | `preload('<relation>', { max })` — the most related rows one relation may attach to one page. Default `MAX_PRELOADED_ROWS` (10,000, the largest page a read may ask for). Past it the read is **refused** (`X_INVARIANT_VIOLATED`), never truncated, and it reads at most one row past the ceiling to know. Naming a relation twice is still one statement: a later call that states `max` replaces the earlier one, as a second `.limit()` does, and one that states none leaves it. The refusal's cause spells the call to write (`.preload('<relation>', { max: n })`); its fix is `x entities describe <entity> --json` |
|
|
648
764
|
| Tenancy | carried onto the related read only when the other entity's tenant column shares the name; otherwise `X_TENANCY_UNSCOPED` refuses the related read rather than guess |
|
|
649
765
|
| Terminals | `page()`, `all()`, `one()` preload; `count()`, `countBy()` and `plan()` don't — none reads a row to attach one to |
|
|
650
766
|
|
|
@@ -664,11 +780,20 @@ database({ orgs, posts }, { driver: postgresDriver() }); // production
|
|
|
664
780
|
| A predicate | `memory-match.ts`, by the column's declared KIND | the SQL `pg-sql.ts` compiles |
|
|
665
781
|
|
|
666
782
|
Both answer the same predicate the same way, and the kind is what decides — never the JS type of
|
|
667
|
-
the value: `bigint()`/`decimal()` hold decimal STRINGS
|
|
668
|
-
|
|
669
|
-
a `
|
|
783
|
+
the value: `bigint()`/`decimal()` hold decimal STRINGS, order by their digits (`2, 9, 10, 100`) and
|
|
784
|
+
are EQUAL by value (`'2.50'` matches `'2.5'`, `2.5` and `'2.500'`; `'10'` matches `10`),
|
|
785
|
+
a `uuid` is ordered and equated case-insensitively because Postgres compares it as a value, `\` escapes a `%` or
|
|
786
|
+
a `_` inside a `like` (whose `_` is one CHARACTER, astral or not, and whose match is bounded by
|
|
787
|
+
pattern × value — never a backtracking regex), a unique declared as `invariant(name, c.unique([…]))`
|
|
788
|
+
is enforced by memory exactly as an index is, and `in` takes a list or nothing (a scalar matches no rows; a list carrying
|
|
670
789
|
a `null` also matches the NULL rows, which `col = null` never does).
|
|
671
790
|
|
|
791
|
+
`compareByKind(kind, left, right)` and `sameValueOfKind(kind, left, right)` are that rule, exported
|
|
792
|
+
(`As of 2026-10`): `-1 | 0 | 1` and a boolean for two values of one column of a declared
|
|
793
|
+
`ColumnKind`, NULL sorting largest. `@ultimat3/query`'s live matcher and in-memory source call them
|
|
794
|
+
rather than keep a comparator of their own; `compare-parity.test.ts` runs the rule against both
|
|
795
|
+
drivers.
|
|
796
|
+
|
|
672
797
|
`database()` called with no driver takes the process default, and `defaultDriver()` is that same
|
|
673
798
|
object — the one seam a test harness needs, `As of 2026-08`:
|
|
674
799
|
|
|
@@ -692,14 +817,13 @@ returns the open transaction when there is one, so a repository call inside `wit
|
|
|
692
817
|
joins it without being told — which is how a job's outbox row lands atomically with the write
|
|
693
818
|
that enqueued it.
|
|
694
819
|
|
|
695
|
-
`postgresDriver({ client })` pins one instead, for a test harness or `x db branch
|
|
696
|
-
repository
|
|
697
|
-
`
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
plus an unpinned repository is the shape that joins.
|
|
820
|
+
`postgresDriver({ client })` pins one instead, for a test harness or `x db branch`. A pinned
|
|
821
|
+
repository joins a transaction that was opened ON its own client — `withTransaction(fn, { client })`,
|
|
822
|
+
`postgresTransactor({ client })`, `driver.transactor()` — because `tx.origin` says it is the same
|
|
823
|
+
database and the same reservation (`As of 2026-10-02`). Inside any OTHER open transaction it is
|
|
824
|
+
`X_REPO_CLIENT_PINNED`: a statement sent to the pinned client would commit whatever that
|
|
825
|
+
transaction decides and miss what it has written, both silently. `setDbClient(client)` plus an
|
|
826
|
+
unpinned repository is the shape that joins the ambient one.
|
|
703
827
|
|
|
704
828
|
Every value is bound to `$n` and every identifier is resolved through the entity, so a column
|
|
705
829
|
name can only be one the entity declared and a row value can never become SQL.
|
|
@@ -866,11 +990,11 @@ Two write verbs, because only the author knows which key identifies a row:
|
|
|
866
990
|
|
|
867
991
|
| Context member | Use it when | A replay |
|
|
868
992
|
|---|---|---|
|
|
869
|
-
| `insert(entity, rows)` | the seed chose the ids — `id('post:tenancy')` is a UUID v5 of the label, so the same graph gets the same ids on every machine | one `on conflict … do nothing` statement per call; a stored row is left alone and counted `skipped
|
|
993
|
+
| `insert(entity, rows)` | the seed chose the ids — `id('post:tenancy')` is a UUID v5 of the label, so the same graph gets the same ids on every machine | one `on conflict … do nothing` statement per call; a stored row is left alone and counted `skipped`. |
|
|
870
994
|
| `upsert(entity, { by, preserve? }, values)` | the table owns the id and only a natural key identifies the row | reads first, so an unchanged row is `'skipped'` with no statement; otherwise one `on conflict … do update`, which settles the race between two containers booting at once |
|
|
871
995
|
| `exists(entity, where?)` / `count(entity, where?)` | bulk volume data, where the FILE is the unit of idempotency | the sentinel returns early and nothing is written |
|
|
872
996
|
| `deleteWhere(entity, where)` | a scoped wipe before a regenerate | **refused on a soft-deleting entity**: the stamp keeps the row's unique key and no replay can clear it |
|
|
873
|
-
| `id`, `now`, `environment`, `tier`, `dryRun`, `metrics` | — | `now` is one instant per run; `metrics` is `{ inserted, updated, skipped }` and `run()` returns it |
|
|
997
|
+
| `id`, `now`, `environment`, `tier`, `dryRun`, `metrics` | — | `now` is one instant per run; `metrics` is `{ inserted, updated, skipped }` and `run()` returns it. A **dry run is the real run, rolled back**: every verb writes inside one transaction from the driver's `transactor()`, so the metrics are what a real run reports — a key another tenant holds is skipped, a later verb sees an earlier one's rows — and nothing is kept. `Driver.transactor()` is required, so a hand-built driver that cannot undo does not compile; one that wraps another delegates (`transactor: () => inner.transactor()`) |
|
|
874
998
|
|
|
875
999
|
Rules worth knowing before the first seed: `upsert` never overwrites `createdAt` (`preserve` names
|
|
876
1000
|
other columns to spare); `upsert` on a tenant-scoped entity needs the tenant column inside `by`,
|
|
@@ -886,9 +1010,9 @@ database from its boot code has decided to, and a library that overruled that wo
|
|
|
886
1010
|
|
|
887
1011
|
`X_ENTITY_DUPLICATE` · `X_INVARIANT_VIOLATED` · `X_TENANCY_UNSCOPED` ·
|
|
888
1012
|
`X_TENANCY_ACTOR_MISMATCH` · `X_TENANCY_ACTOR_ORG_REQUIRED` · `X_TENANCY_CROSS_DENIED` ·
|
|
889
|
-
`
|
|
1013
|
+
`X_NOT_FOUND` · `X_WRITE_UNFILTERED` · `X_PATCH_EMPTY` ·
|
|
890
1014
|
`X_PRELOAD_UNKNOWN_RELATION` · `X_N_PLUS_ONE_QUERY` · `X_N_PLUS_ONE_WRITE` ·
|
|
891
|
-
`X_RECORD_KEY_MISSING`
|
|
1015
|
+
`X_RECORD_KEY_MISSING` · `X_ENTITY_SEALED_PREDICATE` · `X_ENTITY_SEALED_IN_VIEW`
|
|
892
1016
|
|
|
893
1017
|
### Error classes
|
|
894
1018
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.0.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"src",
|
|
22
22
|
"!src/**/*.test.ts",
|
|
23
|
+
"!src/**/*-fixture.ts",
|
|
23
24
|
"CLAUDE.md",
|
|
24
25
|
"README.md",
|
|
25
26
|
"LICENSE"
|
|
@@ -32,9 +33,9 @@
|
|
|
32
33
|
"test": "bun test"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/db": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
36
|
+
"@ultimat3/core": "24.0.0",
|
|
37
|
+
"@ultimat3/db": "24.0.0",
|
|
38
|
+
"@ultimat3/schema": "24.0.0",
|
|
39
|
+
"@ultimat3/time": "24.0.0"
|
|
39
40
|
}
|
|
40
41
|
}
|
package/src/aggregate.ts
CHANGED
|
@@ -68,23 +68,46 @@ export const notAggregatable = (
|
|
|
68
68
|
: `${entityName}.${fn}('${candidates[0]}') # ${fn} takes one of: ${candidates.join(', ')}`,
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
+
/** One amount is one currency at one scale. Two of either have no common unit. */
|
|
72
|
+
export interface MoneyUnit {
|
|
73
|
+
readonly currency: string;
|
|
74
|
+
readonly scale: number | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
/**
|
|
72
78
|
* Money crossing currencies has no sum, no minimum and no maximum: 100 JPY and 100 EUR are not
|
|
73
79
|
* comparable and adding them answers a number in no currency at all. Both drivers count the
|
|
74
|
-
* distinct
|
|
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
|