@ultimat3/entity 29.3.2 → 30.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 +66 -96
- package/README.md +125 -8
- package/package.json +15 -5
- package/src/append-only.ts +7 -0
- package/src/batch-read.ts +12 -4
- package/src/bulk-write-send.ts +49 -0
- package/src/bulk-write.ts +48 -11
- package/src/column.ts +29 -4
- package/src/counter.ts +109 -0
- package/src/cross-tenant.ts +13 -0
- package/src/cursor.ts +17 -1
- package/src/database.ts +16 -10
- package/src/describe-tenancy.ts +25 -0
- package/src/describe.ts +146 -3
- package/src/entity-error.ts +33 -0
- package/src/entity-parse.ts +89 -0
- package/src/entity-patch.ts +42 -0
- package/src/entity.ts +95 -98
- package/src/errors.ts +8 -0
- package/src/history-actor.ts +22 -0
- package/src/history.ts +86 -0
- package/src/index-identity.ts +46 -0
- package/src/index-init.ts +12 -0
- package/src/index.ts +20 -1
- package/src/jit-preload.ts +35 -25
- package/src/lifecycle.ts +100 -0
- package/src/lock-key.ts +27 -0
- package/src/lock-unsupported.ts +11 -0
- package/src/memory-aggregates.ts +99 -0
- package/src/memory-batch.ts +69 -0
- package/src/memory-journal.ts +68 -0
- package/src/memory-lifecycle.ts +106 -0
- package/src/memory-lock.ts +144 -0
- package/src/memory-observed.ts +121 -0
- package/src/memory-repo.ts +199 -186
- package/src/memory-same-tenant.ts +83 -0
- package/src/memory-seek.ts +44 -0
- package/src/memory-transactor.ts +94 -0
- package/src/memory-unique.ts +19 -4
- package/src/n-plus-one.ts +28 -10
- package/src/numeric-compare.ts +43 -0
- package/src/pg-aggregates.ts +185 -0
- package/src/pg-driver.ts +79 -166
- package/src/pg-lock.ts +50 -0
- package/src/pg-row.ts +29 -2
- package/src/pg-sql-aggregate.ts +31 -0
- package/src/pg-sql.ts +17 -1
- package/src/query.ts +26 -105
- package/src/read-aggregates.ts +138 -0
- package/src/read-capture.ts +22 -0
- package/src/registry.ts +49 -1
- package/src/repo.ts +34 -0
- package/src/retain.ts +71 -0
- package/src/row-observer.ts +22 -4
- package/src/row-security-settings.ts +20 -0
- package/src/sealed-repo.ts +15 -0
- package/src/seed.ts +43 -7
- package/src/series-unsupported.ts +11 -0
- package/src/table-locks.ts +41 -0
- package/src/test-driver.ts +90 -0
- package/src/time-bucket.ts +147 -0
- package/src/touch.ts +33 -0
- package/src/types.ts +22 -0
- package/src/view-entity.ts +78 -0
- package/src/view-issue.ts +47 -0
- package/src/view-repo.ts +38 -0
- package/src/view.ts +41 -27
package/CLAUDE.md
CHANGED
|
@@ -23,11 +23,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
23
23
|
`undefined` is ABSENT (only `null` clears); memory refuses a duplicate PK, a duplicate non-partial
|
|
24
24
|
unique — index OR `c.unique([…])` invariant, the one list in `unique-constraints.ts` — and a PK
|
|
25
25
|
patch onto another row with `X_DB_UNIQUE_VIOLATION` (partial uniques are not checked in memory);
|
|
26
|
-
|
|
27
|
-
moved);
|
|
28
|
-
`decimal()`/`bigint()` store Postgres's spelling (never rounded); a seed `upsert` compares only named
|
|
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.
|
|
26
|
+
each kind's stored spelling: `docs/history/entity.md`.
|
|
31
27
|
`invariant-parity.test.ts` asks the app, the driver and the raw CHECK the same question.
|
|
32
28
|
- **`min`/`max` over a `timestamptz` crosses as epoch ms** (`aggregate-time-parity.test.ts`). A whole
|
|
33
29
|
row read under a non-UTC session still fails on PGlite — recorded, not fixed.
|
|
@@ -38,6 +34,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
38
34
|
holds a `number`); `RowWrite<Row>` types `insert`/`insertAll`/`upsertAll`; `narrowRow` (`columns.ts`)
|
|
39
35
|
narrows at each write method's ENTRY, before `$assert`/`upsertPlan`. `pg-money-write.live.test.ts`,
|
|
40
36
|
`money-write-parity.test.ts`, `type-pins.ts`.
|
|
37
|
+
- **A numeric key has one spelling** (`keyOf`): batches, conflict targets and memory's uniques. **A null test on a whole money property reads `<p>.minor`.**
|
|
41
38
|
- **A PREDICATE's meaning is decided by the column's KIND** (`memory-match.ts`); `compareByKind` /
|
|
42
39
|
`sameValueOfKind` are EXPORTED — `@ultimat3/query` calls them. **ONE numeric comparison,
|
|
43
40
|
`numericOrder`**: `DECIMAL_TEXT` columns by VALUE (`'2.50' = 2.5`), `number`/`bigint` numerically
|
|
@@ -52,6 +49,9 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
52
49
|
- **A repository call rejects, never throws synchronously** (`tableFor`'s writes are `async`).
|
|
53
50
|
- **`defaultDriver()`** is the process default; `Driver.reset?()` is optional (memory only), resets
|
|
54
51
|
repositories in place. Test seam only.
|
|
52
|
+
- **`testDriver()` (`test-driver.ts`) follows `@ultimat3/db`'s `dbClientInstalled()` on every call.**
|
|
53
|
+
A plain object of forwarders, never a `Proxy`; a side change inside `transactor().run` rejects
|
|
54
|
+
`X_TEST_DRIVER_CLIENT_CHANGED`. Full rule: `docs/history/entity.md`.
|
|
55
55
|
- **A repository pinned to its own client joins a transaction opened ON that client** (`tx.origin`)
|
|
56
56
|
**and refuses any other** (`X_REPO_CLIENT_PINNED`, in `client()` in `pg-driver.ts`); the fix names
|
|
57
57
|
`setDbClient(client)` plus an unpinned repository.
|
|
@@ -63,21 +63,18 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
63
63
|
it declines rather than guesses; past `MAX_IDS_PER_STATEMENT` it is several statements; no caller is
|
|
64
64
|
ever left unsettled (`coalesce.test.ts` races a deadline).
|
|
65
65
|
- **A page batches the loop it causes** (`jit-preload.ts`): a preloaded row is served only under the
|
|
66
|
-
SAME `scopeKey` (a security boundary), same client, dropped by any write
|
|
67
|
-
|
|
66
|
+
SAME `scopeKey` (a security boundary), same client, dropped by any write to ANY entity — triggers
|
|
67
|
+
and FK actions rewrite rows a statement never named (`forgetPreloaded` in `writing()`;
|
|
68
|
+
`jit-preload-db-writes.test.ts`), keyed by id, bounded (`MAX_SIBLING_KEYS`, oldest page first). One switch:
|
|
68
69
|
`postgresDriver({ jitPreload: false })` — never an `app.config.ts` key. Shared pieces live in
|
|
69
70
|
`batch-read.ts`.
|
|
70
71
|
- **`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
|
|
72
|
-
|
|
73
|
-
(`X_TENANCY_UNSCOPED`). Reach is the `database()` call's `RelatedTables`; `select()` widens with each
|
|
74
|
-
preloaded relation's local key; attachment copies (`{ ...row }`); only `page()`/`all()`/`one()`
|
|
75
|
-
preload.
|
|
72
|
+
(`preload-ceiling.ts`: default `MAX_PRELOADED_ROWS`, refused past it, never truncated). Tenancy is CARRIED only between
|
|
73
|
+
entities scoped by a same-named column (`X_TENANCY_UNSCOPED`). Full rule: `docs/history/entity.md`.
|
|
76
74
|
- **Every repository method attributes its statement** through `@ultimat3/db`'s
|
|
77
75
|
`withStatementAttribution(entity.$name, op, send)` via `attributed(op, send)`, `op` declared once per
|
|
78
|
-
method and passed to the plan builders; `
|
|
79
|
-
the
|
|
80
|
-
entity. `memoryRepo` sends no statement. `pg-driver-attribution.test.ts` — one case per method.
|
|
76
|
+
method and passed to the plan builders; `memoryRepo` reports one event per operation (`memory-observed.ts`)
|
|
77
|
+
under the same pair. `pg-driver-attribution.test.ts` — one case per method. Full rule: `docs/history/entity.md`.
|
|
81
78
|
- **The two N+1 codes are owned here** (`X_N_PLUS_ONE_QUERY`, `X_N_PLUS_ONE_WRITE`, `n-plus-one.ts`):
|
|
82
79
|
it detects nothing, takes a `StatementLoop` verdict, and derives the fix from `relationMap()`
|
|
83
80
|
(`preloadsFor()`), falling back to the `in` form. `N_PLUS_ONE_THRESHOLD` (5) is shared by `x dev`'s
|
|
@@ -88,43 +85,30 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
88
85
|
sort VALUES. **The tiebreak takes the LAST DECLARED key's direction**; `seekSql` sends a ROW
|
|
89
86
|
comparison when every key sorts one way and the or-chain otherwise
|
|
90
87
|
(`pg-driver-cursor.live.test.ts`).
|
|
91
|
-
- **`inBatches(size)` is the same page in a loop** (`batch.ts`)
|
|
92
|
-
(`close()` = `AsyncGenerator.return()`), `.cursor` is advanced before the yield, no empty batch, and
|
|
93
|
-
three refusals on the chain (size, `limit()` alongside, an order no cursor can carry — judged by
|
|
94
|
-
exported `totalOrder`).
|
|
88
|
+
- **`inBatches(size)` is the same page in a loop** (`batch.ts`). Full rule: `docs/history/entity.md`.
|
|
95
89
|
- **A grouped count means one thing in both drivers, written in `count-by.ts`**: both call
|
|
96
|
-
`groupColumnOf` before the statement and `countsFrom` after the rows.
|
|
97
|
-
|
|
98
|
-
`
|
|
99
|
-
is applied after the rows are in, never in SQL; `group_value` / `group_count` are fixed aliases.
|
|
100
|
-
The refusal names a groupable column of THIS entity, never `x entity explain`.
|
|
90
|
+
`groupColumnOf` before the statement and `countsFrom` after the rows. The bound is a REFUSAL at
|
|
91
|
+
`MAX_GROUPS + 1`, never a truncation; it names a groupable column of THIS entity, never
|
|
92
|
+
`x entity explain`. Full rule: `docs/history/entity.md`.
|
|
101
93
|
- **The codec is `@ultimat3/core`'s**, reached through `cursorFor(entity, plan, row, id)` and
|
|
102
|
-
`seekFrom(entity, plan)`; **`assertSeekable` runs in `planFor`**, before a statement exists. A
|
|
94
|
+
`seekFrom(entity, plan)`; **`assertSeekable` runs in `planFor`**, before a statement exists, and refuses `jsonb`, `bytea`, `array` (`UNSEEKABLE`). A
|
|
103
95
|
`timestamptz` sort key is refused when its `<column>$US` alias would pass 63 bytes. A cursor is bound
|
|
104
96
|
to `planScope(plan)` (entity, filters, sort); a bad one is `X_CURSOR_INVALID`, never "from the top".
|
|
105
97
|
- **A NULLABLE sort key orders** `asc nulls last` / `desc nulls first`; the cursor tags
|
|
106
98
|
`~` for NULL and `!` for present; the seek reaches NULLs; a nullable key has no row comparison. Only a
|
|
107
99
|
nullable PRIMARY-KEY column is refused. `pg-null-order.live.test.ts`.
|
|
108
|
-
- **A `timestamp` cursor carries MICROSECONDS
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
`
|
|
112
|
-
- **The four aggregates** (`sum`, `avg`, `min`, `max`) share `aggregate.ts`
|
|
113
|
-
(`aggregate-fold.ts` memory, `aggregate-decode.ts` Postgres): never a float; `avg` at `AVG_SCALE` (6),
|
|
114
|
-
half away from zero; `null` for an empty set. Refused: `min`/`max` on text, `avg` over money
|
|
115
|
-
(`X_AGGREGATE_UNSUPPORTED`), mixed currency or scale (`X_AGGREGATE_MIXED_CURRENCY`, two causes, two fixes), a money total
|
|
116
|
-
past ±2^53. **`approximateCount()` is `reltuples`**; a filtered chain or tenant-scoped entity is
|
|
100
|
+
- **A `timestamp` cursor carries MICROSECONDS** (`seekPrecision`, `instant.ts`). Full rule: `docs/history/entity.md`.
|
|
101
|
+
- **The four aggregates** (`sum`, `avg`, `min`, `max`) share `aggregate.ts`:
|
|
102
|
+
never a float; `null` for an empty set; refusals `X_AGGREGATE_UNSUPPORTED`,
|
|
103
|
+
`X_AGGREGATE_MIXED_CURRENCY`. Full rule: `docs/history/entity.md`. **`approximateCount()` is `reltuples`**; a filtered chain or tenant-scoped entity is
|
|
117
104
|
`X_APPROXIMATE_COUNT_FILTERED`; `null` for an unanalysed table.
|
|
105
|
+
- **`series`, views**: `time-bucket.ts` (zone rules, measured), `view-repo.ts` (read-only); `time-bucket.test.ts`, `view-entity.test.ts`.
|
|
118
106
|
- **`json()` / `arrayOf()` are filterable** (`contains`, `contained-by`, `overlaps`, `has-key`) with
|
|
119
107
|
Postgres' measured meaning in `containment.ts`; `has-key` emits `operator(pg_catalog.?)` (indexable)
|
|
120
108
|
and takes a STRING key, else no rows; a NULL array element equals nothing. No jsonpath operator.
|
|
121
109
|
- **A GIN index is declarable** (`using: 'gin'`; the set is `@ultimat3/db`'s `INDEX_METHODS`); absent
|
|
122
110
|
is `btree` byte for byte; the method joins the name discriminator only when declared; GIN cannot be
|
|
123
111
|
unique or ordered (refused here). `pg-containment.live.test.ts`.
|
|
124
|
-
- **Full-text search** (`search.ts`): one generated `tsvector` per entity, `websearch_to_tsquery`, a
|
|
125
|
-
closed `SEARCH_LANGUAGES` set spliced, `coalesce(col, '')` per source, NOT NULL vector column; the
|
|
126
|
-
memory driver refuses (`X_SEARCH_IN_MEMORY`); relevance is not a served order.
|
|
127
|
-
`pg-search.live.test.ts`.
|
|
128
112
|
|
|
129
113
|
## Do not regress — schema and DDL
|
|
130
114
|
|
|
@@ -147,14 +131,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
147
131
|
- **Invariants run twice and only ONE side is rendered here**: app-side `assertInvariants`, and the
|
|
148
132
|
CHECK/UNIQUE DDL is `@ultimat3/db`'s `invariant-ddl.ts` reading `$describe()`. An untranslatable predicate is `kind: 'assert'`, `sql: null`.
|
|
149
133
|
**`InvariantDescription.columns` is projected** (`describe-invariant.test.ts`).
|
|
150
|
-
- **The two halves must AGREE, term by term** (`expr.ts
|
|
151
|
-
refused (`matchOperator`); `minLength` counts code points; `trimmed()` strips U+0020 only (`btrim`);
|
|
152
|
-
`atLeast`/`eq(<number>)` judge by the column's KIND (`invariantColumns(name, columns)`), so a
|
|
153
|
-
`bigint()`/`decimal()` row compares by its digits. **`isNull()`/`isNotNull()` are the only
|
|
154
|
-
total members**; `iff(a, b)` renders `(a) = (b)` — measured as the safe direction
|
|
155
|
-
(`pg-invariant-null.live.test.ts`); `iff` is a function, and a `unique` operand is refused with the
|
|
156
|
-
`c.unique([…])` fix built through `JSON.stringify`. One app-only operand makes the whole rule
|
|
157
|
-
app-only.
|
|
134
|
+
- **The two halves must AGREE, term by term** (`expr.ts`, `pg-invariant-null.live.test.ts`); one app-only operand makes the whole rule app-only. Full rule: `docs/history/entity.md`.
|
|
158
135
|
- **A `matches()` pattern reaches the CHECK as the same string or is refused**
|
|
159
136
|
(`pattern-portability.ts`, every entry measured; `pg-invariant-pattern.live.test.ts` asserts kept
|
|
160
137
|
constructs agree and refused ones disagree).
|
|
@@ -165,21 +142,13 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
165
142
|
`invariantColumns()` stays for JS callers. **`Invariant<T>.holds` is a method** (variance).
|
|
166
143
|
- **Nothing is interpolated into SQL** (`pg-sql.ts`): `raw()` only for `asc|desc` and the `default`
|
|
167
144
|
cell. The seek operator is chosen in TypeScript.
|
|
168
|
-
- **Money is three columns**: `<p>_minor bigint`, `<p>_currency char(3)`, `<p>_scale integer null`.
|
|
169
|
-
NULL scale decodes to an ABSENT key (never `0`); the scale bound is `@ultimat3/schema`'s
|
|
170
|
-
(`isMoneyScale`, `scaleCheck`); scale is not addressable (`MONEY_PARTS`). The currency bound is
|
|
171
|
-
schema's in both halves (`isCurrencyCode`, `CURRENCY_CODE_PATTERN`; `currency-check.live.test.ts`).
|
|
172
|
-
`MoneyValue` is re-exported from `@ultimat3/schema` — one declaration (`type-pins.ts`). `parseMinor`
|
|
173
|
-
refuses past ±2^53; `narrowMoney` runs in both drivers. `money({ columns })` merges per part and
|
|
174
|
-
`scale: null` means a two-column amount (`decodeRow` branches on `$meta.kind === 'money'`).
|
|
175
|
-
- **Timestamps are `timestamptz`.** A naive timestamp stays inexpressible.
|
|
145
|
+
- **Money is three columns**: `<p>_minor bigint`, `<p>_currency char(3)`, `<p>_scale integer null`; a NULL scale decodes to an ABSENT key, never `0`. Full rule: `docs/history/entity.md`.
|
|
176
146
|
- **A `jsonb` value is bound `::text::jsonb`** (load-bearing); an array is written as a quoted `{…}`
|
|
177
147
|
literal, read by its DECLARATION (`pg-array-decode.ts`); `column-matrix-fixture.ts` is every kind
|
|
178
148
|
on both drivers. **Wide types are normalised in `$parse`** (`bigint()`, `decimal()`: strings).
|
|
179
149
|
- **Every framework member on an entity is `$`-prefixed.** Row types are derived, never re-declared.
|
|
180
150
|
**A branded id survives to the signature** (`IdOf<Row>`). `type-pins.ts` enforces all of it.
|
|
181
151
|
- **`$parse` tells absence from `null`** (`raw === undefined ? defaultValue(...) : raw`).
|
|
182
|
-
- `src/index.ts` re-exports `t` from `@ultimat3/schema` **verbatim** (`index.test.ts`).
|
|
183
152
|
- **A rejected column value is rendered as its SHAPE** (`got(value)` over `describeValue`); only
|
|
184
153
|
`parseMinor`'s two messages echo a provably numeric value.
|
|
185
154
|
- **A refusal raised before any entity exists carries an EDIT** (`refuse.ts`,
|
|
@@ -199,45 +168,66 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
199
168
|
never stamp; all or nothing; the incoming rows too; before the statement exists
|
|
200
169
|
(`write-tenancy-parity.test.ts`, `pg-driver-tenancy.live.test.ts`). With the conflict-target rule, a
|
|
201
170
|
cross-tenant upsert is unrepresentable.
|
|
171
|
+
- **A reference stays inside its tenant** (`sameTenantOf`, `describe.ts`, at describe time): `sameTenant` is `null` on the record and ABSENT on the column otherwise (the hash). `entity()` never resolves a thunk at declaration. Memory (`memory-same-tenant.ts`) refuses only a target FOUND elsewhere. `same-tenant-parity.test.ts`, `same-tenant.live.test.ts`.
|
|
172
|
+
- **`lock`/`lockKey`**: optional on `Repo`, required on `Table` (`table-locks.ts`, `X_LOCK_UNSUPPORTED`); `pg-lock.ts`, `memory-lock.ts` (`update`/`delete` queue too, the filtered writes do not), one tenant derivation (`lock-key.ts`, via `readPlan`). `lock-parity.test.ts`, `lock.live.test.ts`, `lock-seams.test.ts`. Why: `docs/history/entity.md` (2026-11-02).
|
|
202
173
|
- **`crossTenant(reason, fn)` is the ONE way to read across tenants** — an `asyncContext` scope from
|
|
203
174
|
core (`scripts/async-context-guard.ts`), `CROSS_TENANT_SCOPE` (`tenancy:cross`) proven at the call
|
|
204
175
|
and at every plan (`X_TENANCY_CROSS_DENIED`), refused outside a request, blank reason `X_INVARIANT`.
|
|
205
176
|
- **`deleteWhere` / `updateWhere` are the only filtered writes**: empty filter `X_WRITE_UNFILTERED`,
|
|
206
177
|
empty patch `X_PATCH_EMPTY` (undefined values dropped first); soft delete respected; both return a
|
|
207
|
-
count;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
required.
|
|
178
|
+
count; a JS-only invariant is judged
|
|
179
|
+
in the statement's transaction (`judged-write.ts`), refused past `MAX_ASSERTED_ROWS`. Full rule: `docs/history/entity.md`.
|
|
180
|
+
- **A write past a coded abort is refused on both drivers**: `refuseAbandoned()` in `writing()` and each `memoryRepo` write. `write-abandon.test.ts`.
|
|
211
181
|
- **Every instant the write path stamps comes from `entityNow()`** (`clock.ts`, `ctx.clock`).
|
|
212
|
-
**`touch()` in `
|
|
182
|
+
**`touch()` in `touch.ts` is the ONE place `onUpdateNow()` columns are stamped.**
|
|
183
|
+
**Both repositories parse a patch at the door** (`$parsePatch`): `$parse`'s issues, no default, a fragment refused.
|
|
213
184
|
- **A many-row write is one statement** (`insertStatement` builds every insert; `bulk-write.ts`
|
|
214
185
|
decides in property space): a collision overwrites everything but the conflict target, the PK and the
|
|
215
186
|
soft-delete stamp; the target must be a declared non-partial unique constraint (PK, `$indexes`, or a
|
|
216
187
|
`kind: 'unique'` invariant); under `'update'` the tenant column must be in the target
|
|
217
188
|
(`X_TENANCY_UNSCOPED`), a repeated target and an uneven batch are refused; NULL in the target
|
|
218
189
|
collides with nothing; memory judges the whole batch first; past `MAX_BIND_PARAMETERS` it is several
|
|
219
|
-
statements.
|
|
190
|
+
statements in ONE transaction (`bulk-write-send.ts`). **`db.*.upsertAll` and `seed.upsert`
|
|
191
|
+
overwrite what the caller NAMED** (`overwrittenBy`). Full rule: `docs/history/entity.md`.
|
|
192
|
+
- **`memoryTransactor().run` is ambient** (`memory-journal.ts`): a write naming no `tx` joins it; a nested
|
|
193
|
+
commit hands its undos and effects up; a write after the root ended is `X_DB_TRANSACTION_CLOSED`; a
|
|
194
|
+
row lock taken with no tx is held to the root. `memory-journal.test.ts`, `memory-transactor.test.ts`.
|
|
195
|
+
- **`shared: '<why>'` is the one way an untenanted child of a tenant-owned table says so**
|
|
196
|
+
(`assertShared`, `describe.ts`); the reach check is the CLI's `exercise-static.ts`
|
|
197
|
+
(`X_ENTITY_TENANT_UNDECLARED`), over `EntityDescription.shared`.
|
|
198
|
+
- **`scan: '<why>'`**: a table read whole (`assertScan`).
|
|
220
199
|
- **A seed is replayable**: `SeedContext.insert` is one `upsertAll(…, { onMatch: 'nothing' })` per
|
|
221
|
-
call; a generated PK the row does not name is refused
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
the real run rolled back** through `Driver.transactor()` (REQUIRED on the interface) — one rule,
|
|
225
|
-
never a per-verb lookup.
|
|
200
|
+
call; a generated PK the row does not name is refused; `upsert` reads with `includeDeleted` and
|
|
201
|
+
SKIPS a soft-deleted match. **A dry run is the real run rolled back**
|
|
202
|
+
(`Driver.transactor()`, REQUIRED). Full rule: `docs/history/entity.md`.
|
|
226
203
|
- **`setRowObserver` reports committed row changes above the driver** — one per process (returns the
|
|
227
204
|
replaced one), applied by `database()`, one comparison per write when unset, `before` only when the PK
|
|
228
205
|
is `id`, a filtered write is `onBulk`. Not a second change-feed path.
|
|
229
206
|
- **`appendOnly: true`** (`append-only.ts`): `appendOnlyRepo` wraps OUTSIDE `sealedRepo` in both
|
|
230
|
-
drivers
|
|
231
|
-
|
|
232
|
-
`onUpdateNow()` or machine column is refused at declaration; `$describe().appendOnly` is `true` or
|
|
233
|
-
absent. No bypass, backfills included. `append-only{,-parity}.test.ts`.
|
|
207
|
+
drivers; every rewrite rejects before any statement (`X_ENTITY_APPEND_ONLY`). No bypass, backfills
|
|
208
|
+
included. Full rule: `docs/history/entity.md`.
|
|
234
209
|
- **A state machine is the MECHANISM only** (`.transitions()` on `enumerated()`, a mapped
|
|
235
|
-
`TransitionTable<S>`):
|
|
236
|
-
`from` in the predicate (`
|
|
237
|
-
after the refusal from a tenant-scoped `findById`; no DDL beyond `enumerated()`'s CHECK; a machine
|
|
238
|
-
column may not be nullable; `whyNot` asks unknown → terminal → legal list. **`move.observed`
|
|
210
|
+
`TransitionTable<S>`): the move is ONE statement with
|
|
211
|
+
`from` in the predicate (`X_STATE_CONFLICT`). Full rule: `docs/history/entity.md`. **`move.observed`
|
|
239
212
|
pins what a decision read** (#702, `transition-pins.ts`).
|
|
240
213
|
|
|
214
|
+
## Do not regress — lifecycle (retain, history, counters)
|
|
215
|
+
|
|
216
|
+
| Declaration | Here | The rule |
|
|
217
|
+
|---|---|---|
|
|
218
|
+
| `retain: { after, on }` | `retain.ts` | no default window; `on` a `timestamp()` some index STARTS with (`X_ENTITY_RETAIN_UNINDEXED`); refused beside `appendOnly` (`X_ENTITY_RETAIN_INVALID`). The sweep is `@ultimat3/jobs`' `retainTarget` |
|
|
219
|
+
| `history: true` | `history.ts`, `history-actor.ts` | `<table>_history` is a REAL registered entity (`$historyOf`), tenant column copied without its key. Written by `@ultimat3/db`'s trigger; `stampHistoryActor` sets `x.actor`/`x.request` once per transaction, never on a pinned repository — no transaction records `null`. `bigint`/`decimal`/`timestamp` columns ride `text`/`instants`: the image is the memory driver's strings (`history-values.test.ts`) |
|
|
220
|
+
| `countedIn` on a key | `counter.ts` | the target is a NOT NULL `integer()` of the entity the key points at (Postgres' `NULL + 1` stays NULL), by its single primary key (`X_ENTITY_COUNTS_INVALID`); resolved at describe time, memoised on `registryGeneration()` |
|
|
221
|
+
| all three, described | `lifecycle.ts` | each `$describe()` key ABSENT unless declared, so no other entity's hash moves |
|
|
222
|
+
| the memory driver | `memory-lifecycle.ts` | mirrors both triggers through the table's raw store, so a unit test answers what Postgres answers |
|
|
223
|
+
|
|
224
|
+
- **Known gap: the row observer does not see trigger writes.** A history row and a counter move
|
|
225
|
+
are the trigger's (memory: `memoryLifecycle`'s raw store), never a repository write, so
|
|
226
|
+
`setRowObserver` reports neither; a live counter on PGlite or memory moves on the parent's next
|
|
227
|
+
write. The WAL replicator does report them. As of 2026-11.
|
|
228
|
+
- **`read-capture.ts` is failure capture's read half**: `observedRepo`'s `findById`/`findMany` hand
|
|
229
|
+
rows to core's `recordFailureFact` — sealed properties dropped first; one check when off.
|
|
230
|
+
|
|
241
231
|
## Do not regress — sealed columns
|
|
242
232
|
|
|
243
233
|
- **`sealed-repo.ts` is the ONE seam**: `memoryRepo()` and `postgresRepo()` both return through
|
|
@@ -252,14 +242,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
252
242
|
- **Purpose is `entity:<table>.<column>`, physical names** (`sealed.ts`). **Sealing is not on
|
|
253
243
|
`$describe()`**: it changes no DDL, so it must not move the drift hash — the manifest and
|
|
254
244
|
`x doctor` read `sealedFields(entity)`. Only `check`/`length` leave the description.
|
|
255
|
-
- **A sealed property on a repository row is SERVER-ONLY
|
|
256
|
-
enumerable (`serverOnly`, `sealed.ts`; set by `openRow`). Every generic serialiser omits it, which
|
|
257
|
-
is what holds a `query`'s rows, a loose `output`, a log line, an island prop, a job payload and a
|
|
258
|
-
cache tier — none of them parses a row. So ABOVE the seam a row is copied with `copyRow` /
|
|
259
|
-
`pickRow` (descriptors), never `{ ...row }`, `Object.assign({}, row)` or `out[k] = row[k]`:
|
|
260
|
-
`preload.ts`, `select()` and `touch()` in `query.ts`. `sealRow` reads each sealed property BY NAME,
|
|
261
|
-
so a row passed back whole is written whole. A required one missing on insert is `sealedMissing`
|
|
262
|
-
(`X_INVARIANT_VIOLATED`, from `$parse` and from the seam), naming the spread. `sealed.test.ts`.
|
|
245
|
+
- **A sealed property on a repository row is SERVER-ONLY**, not enumerable (`sealed.ts`): above the seam copy with `copyRow` / `pickRow`, never `{ ...row }`. Full rule: `docs/history/entity.md`.
|
|
263
246
|
- **Nothing sealed leaves**: `$schema` (node and parsed value; typed `WireRow` on `Entity`,
|
|
264
247
|
`unknown` on `EntityCore`), `$view` (`X_ENTITY_SEALED_IN_VIEW`), `rowsOf`, the row observer, and
|
|
265
248
|
the repository row itself (above). `sealed-egress.test.ts` here and one per consuming package.
|
|
@@ -282,20 +265,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
|
|
|
282
265
|
|
|
283
266
|
## Files
|
|
284
267
|
|
|
285
|
-
Each file's header states its one job
|
|
286
|
-
`column.ts` / `columns.ts` / `columns-data.ts` / `array-element.ts` / `enum-column.ts` /
|
|
287
|
-
`column-values.ts` (builders, `columnName`, `narrowMoney`), `refuse.ts`, `expr.ts` / `invariants.ts`,
|
|
288
|
-
`entity.ts` / `describe.ts` / `index-name.ts` / `search.ts`, `state-machine.ts` / `transition*.ts`,
|
|
289
|
-
`feature-errors.ts`, `view.ts` / `row-schema.ts` / `record-projection.ts` / `record-key.ts` /
|
|
290
|
-
`rows-of.ts` / `record-table.ts` / `record.ts`, `entity-error.ts` / `errors.ts`, `query.ts` /
|
|
291
|
-
`database.ts` / `clock.ts`, `memory-match.ts` / `like.ts` / `numeric-compare.ts` / `unique-constraints.ts` / `repo.ts` /
|
|
292
|
-
`memory-repo.ts` / `tenancy.ts` /
|
|
293
|
-
`cross-tenant.ts`, `plan.ts` / `cursor.ts` / `batch.ts`, `pg-driver.ts` / `coalesce.ts` /
|
|
294
|
-
`batch-read.ts` / `bulk-write.ts` / `count-by.ts` / `jit-preload.ts` / `preload.ts` /
|
|
295
|
-
`preload-ceiling.ts` / `judged-write.ts` / `pg-sql.ts` /
|
|
296
|
-
`pg-row.ts`, `row-observer.ts` / `write-tag.ts` (a keyed request's write names itself in the WAL via
|
|
297
|
-
`pg_logical_emit_message`), `registry.ts` / `relations.ts` / `n-plus-one.ts` / `seed.ts`,
|
|
298
|
-
`type-pins.ts`, `live-registry-cleanup.test.ts`.
|
|
268
|
+
Each file's header states its one job; `ls packages/entity/src` is the map, kept by no hand.
|
|
299
269
|
|
|
300
270
|
## Commands
|
|
301
271
|
|
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ An entity is **columns + the invariants that hold for every row**. The row type
|
|
|
4
4
|
the columns: `type Post = typeof posts.$row`. Declare the shape once — repos, migrations, the
|
|
5
5
|
admin screen, cache tags and the manifest are all projections of that one call.
|
|
6
6
|
|
|
7
|
+
`bun add @ultimat3/entity` · guide: [Entities And Migrations](https://github.com/developerz-ai/ultimate/wiki/Entities-And-Migrations)
|
|
8
|
+
|
|
7
9
|
```ts
|
|
8
10
|
import {
|
|
9
11
|
entity, enumerated, integer, invariant, text, timestamp, url, uuid,
|
|
@@ -46,6 +48,7 @@ directly — `output: PostView` — and the projected type flows on to the clien
|
|
|
46
48
|
| Keys are checked twice | unknown key ⇒ `tsc` error, and `X_INVARIANT_VIOLATED` at declaration for a JS caller |
|
|
47
49
|
| Values are the columns' | each key is parsed by the column that declared it; no second copy of the rule |
|
|
48
50
|
| Nothing is invented | a view projects a row that exists — an absent required key is missing data, not a default |
|
|
51
|
+
| It is an input too | `input: posts.$view(['title'])` publishes `maxLength` and refuses past it with one issue PER KEY — `path: ['title']`, a `rule` from the column's fact (`string.max` `{ max }`, `enum` `{ values }`, `required`, else `type` `{ type }`) and the column's own sentence (`expected at most 200 characters, got 300`) — so a form binds it to the field. There is no `$input.pick()`: this is it |
|
|
49
52
|
| `$name` | `posts.view.id_title_coverUrl_status` — stable, and legal as an OpenAPI `components.schemas` key |
|
|
50
53
|
| It projects | a view carries the schema IR (`node`) the same way every `t.*` schema does, so `output: PostView` reaches OpenAPI, the MCP tool's `outputSchema` and the typed client — `As of 2026-09-05`; before that it validated fine and was refused at registration with `X_SCHEMA_UNSUPPORTED`, so every app re-declared its outputs as `t.object`. Column kinds publish the ROW value's shape: `bigint()`/`decimal()` are strings, `date()` is a `YYYY-MM-DD` string, `money()` is `{ minor, currency, scale? }`, `enumerated()` is an `enum`, a nullable column is `anyOf: [<type>, null]` and still required |
|
|
51
54
|
|
|
@@ -113,7 +116,7 @@ never runs, while the subpath retains the projection, the key and the registry a
|
|
|
113
116
|
| `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
117
|
|
|
115
118
|
Chain: `.primaryKey()` · `.nullable()` · `.unique()` · `.default(v)` · `.defaultNow()` ·
|
|
116
|
-
`.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)` ·
|
|
119
|
+
`.onUpdateNow()` · `.references(() => other.id, { onDelete, crossTenant })` · `.tenant()` · `.column(name)` ·
|
|
117
120
|
`.sealed()` (`text()` only).
|
|
118
121
|
Physical names are derived from the property key (`orgId` → `org_id`); a name is written once, or
|
|
119
122
|
never — `.column()` is the exception, and it exists for tables this framework did not create.
|
|
@@ -376,6 +379,10 @@ written into the statement rather than inherited from the server, and the same s
|
|
|
376
379
|
in both drivers; the cursor carries "this row had none" as a position of its own. The one ordering
|
|
377
380
|
still refused is a nullable **primary-key** column, which no tiebreak can make total.
|
|
378
381
|
|
|
382
|
+
`orderBy` on a `json`, `bytes` or array column is refused where the plan is built, at any row count
|
|
383
|
+
(`X_INVARIANT_VIOLATED`). It has no position a cursor can carry: it paged wrongly or threw past page
|
|
384
|
+
1. Order by a scalar.
|
|
385
|
+
|
|
379
386
|
A `timestamp()` sort key is carried at the column's own **microsecond** precision, not at the
|
|
380
387
|
millisecond a JS `Date` holds: the read projects the instant as text beside the column and the seek
|
|
381
388
|
binds all six digits. Without it a `desc` page over rows sharing one millisecond — which
|
|
@@ -534,6 +541,13 @@ export const posts = entity('posts', {
|
|
|
534
541
|
| Refused | a unique GIN and an ordered GIN, at `entity()` — Postgres has neither, and the refusal names the edit |
|
|
535
542
|
| Naming | the method is part of what separates two indexes on the same columns, so a btree and a GIN on one column are two indexes with two names |
|
|
536
543
|
|
|
544
|
+
On a big, hot table, build an index with `concurrently: true` — `{ on: ['status'], concurrently:
|
|
545
|
+
true }`. `x db gen` emits `create index concurrently if not exists` in a migration of its own,
|
|
546
|
+
marked `-- transaction: false`, so writes keep flowing while it builds; an interrupted build is
|
|
547
|
+
rebuilt by the next `x db migrate`. It is how the index is built, not what it is: not part of the
|
|
548
|
+
name, not recorded, and toggling it generates nothing. Every index REMOVED from `indexes` is dropped
|
|
549
|
+
the same way, concurrently or not — `@ultimat3/db`'s README, "Indexes built concurrently".
|
|
550
|
+
|
|
537
551
|
## Full-text search
|
|
538
552
|
|
|
539
553
|
`.searchable()` on a `text()` column puts it in the entity's **one** generated `tsvector`, with a
|
|
@@ -631,6 +645,18 @@ await db.orders.transition('status', id, {
|
|
|
631
645
|
What is deliberately **not** here: who may make a move, what happens on arrival, an approval chain,
|
|
632
646
|
a reason code. Those differ per app — wrap `transition()` in your own function and put them there.
|
|
633
647
|
|
|
648
|
+
## A series of time buckets, and views
|
|
649
|
+
|
|
650
|
+
`As of 2026-11`. `series({ on, unit, timeZone, value })` is the grouped count per bucket of time —
|
|
651
|
+
`minute`/`hour`/`day`/`week`/`month`, in the IANA zone named (required), truncated as Postgres'
|
|
652
|
+
`date_trunc(unit, col, zone)` truncates, by the same rules in the memory driver (`time-bucket.ts`).
|
|
653
|
+
`value` is `'count'` or `{ sum: column }`; ascending; past 1000 buckets `X_ENTITY_SERIES_INVALID`.
|
|
654
|
+
Optional on `Repo` (`X_ENTITY_SERIES_UNSUPPORTED` for a hand-written one).
|
|
655
|
+
|
|
656
|
+
`entity(name, { view: { sql, materialized? }, columns })` is a read-only relation: `$viewQuery`,
|
|
657
|
+
`$describe().view`, every write `X_ENTITY_VIEW_READ_ONLY` (`view-repo.ts`, applied through
|
|
658
|
+
`appendOnlyRepo`). Full reference: [Entities and migrations](https://github.com/developerz-ai/ultimate/wiki/Entities-And-Migrations#a-count-per-day-is-a-series).
|
|
659
|
+
|
|
634
660
|
## Counting by a column
|
|
635
661
|
|
|
636
662
|
`As of 2026-08`. `count()` answers one number, so a screen or a backfill that needs one per row
|
|
@@ -664,7 +690,7 @@ not `n`.
|
|
|
664
690
|
|
|
665
691
|
```ts
|
|
666
692
|
db.posts.delete(id); // by a single primary key
|
|
667
|
-
db.posts.update(id, { title }); // ditto
|
|
693
|
+
db.posts.update(id, { title }); // ditto; named columns validate as insert does
|
|
668
694
|
|
|
669
695
|
db.likes.deleteWhere({ postId, userId }); // -> 1 · the only way to unlike
|
|
670
696
|
db.likes.deleteWhere({ postId }); // -> n · every like on that post
|
|
@@ -684,6 +710,7 @@ and never unwritten.
|
|
|
684
710
|
| Returns | the **number of rows affected**, so "nothing matched" is distinguishable from "it worked" |
|
|
685
711
|
| Empty filter | `X_WRITE_UNFILTERED`. An `undefined` value is dropped before the count, so a forgotten variable is the error and not the whole table |
|
|
686
712
|
| Empty patch | `X_PATCH_EMPTY`. Counting rows for a statement that set nothing is the same silent no-op, one argument along |
|
|
713
|
+
| Patch values | named columns pass their schemas, the same issues `insert` raises (`X_INVARIANT_VIOLATED`), before any statement, on both drivers. Absent stays absent, `null` clears a nullable column |
|
|
687
714
|
| 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 |
|
|
688
715
|
| 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 |
|
|
689
716
|
| `onUpdateNow()` | stamped by `touch()`, the same helper `update(id, patch)` uses — one place, so the two can never disagree about `updatedAt` |
|
|
@@ -691,6 +718,8 @@ and never unwritten.
|
|
|
691
718
|
|
|
692
719
|
## Append-only entities
|
|
693
720
|
|
|
721
|
+
`retired: ['legacy']` is the first half of a column drop under a rolling deploy: delete the column from `columns` and name its physical spelling here. It leaves the row type, the projection and every insert, so the new release never reads it, while `x db gen` writes only a marker, so the old release keeps working. Remove it from the list in the next release and `x db gen "drop legacy" --allow-destructive` drops it. A name still in `columns` is `X_INVARIANT_VIOLATED`. → `wiki/Migrations-And-Backfills.md`.
|
|
722
|
+
|
|
694
723
|
`appendOnly: true` declares a table whose rows are written once and never changed or removed — a
|
|
695
724
|
ledger, an audit trail, an event log. Held twice, like an invariant: the repository refuses, and
|
|
696
725
|
`x db gen` emits a trigger that refuses the same statement in raw SQL.
|
|
@@ -728,8 +757,22 @@ await db.entries.update(first.id, { amountMinor: 0 }).catch((refused: unknown) =
|
|
|
728
757
|
| Tenancy | unchanged — the refusal comes first and judges no tenant; an insert is scoped exactly as on any entity |
|
|
729
758
|
| 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))` |
|
|
730
759
|
|
|
760
|
+
## Retention, history and counters
|
|
761
|
+
|
|
762
|
+
Three declarations, each a trigger or a purge target `x db gen` and `purge()` compile — full rules in `wiki/Entities-And-Migrations.md#lifecycle-retention-history-counters`.
|
|
763
|
+
|
|
764
|
+
| Declaration | Does |
|
|
765
|
+
|---|---|
|
|
766
|
+
| `retain: { after: '90d', on: 'createdAt' }` | `purge({ entities: 'declared' })` (`@ultimat3/jobs`) deletes older rows in batches, across tenants. The window is required; `on` is an indexed `timestamp()` (`X_ENTITY_RETAIN_UNINDEXED`); never beside `appendOnly` (`X_ENTITY_RETAIN_INVALID`). `$retain` is the checked declaration. `x verify` refuses one no purge covers (`X_ENTITY_RETAIN_UNSWEPT`) |
|
|
767
|
+
| `history: true` · `{ retain: { after } }` | `<table>_history`, a registered entity (`$history`; it carries `$historyOf`), written by trigger with a before and after image of every change — `updateWhere`, cascades and raw SQL included. Its repository refuses every rewrite (`X_ENTITY_APPEND_ONLY`), the table too, except a retention sweep's delete |
|
|
768
|
+
| `references(() => posts.id, { countedIn: () => posts.likeCount })` | the child's trigger keeps the parent's `integer()` equal to its row count, in the write's transaction; a soft-deleted child is not counted. Declared on the key because the child already names its parent: a thunk on the parent naming the child would make each declaration the other's type. `X_ENTITY_COUNTS_INVALID` when the target is no integer or the key points at no single primary key |
|
|
769
|
+
|
|
770
|
+
Memory parity: the memory driver records history and moves counters in its repository (`memory-lifecycle.ts`); `history.test.ts` and `counter.test.ts` are the parity tables.
|
|
771
|
+
|
|
731
772
|
## Writing many rows
|
|
732
773
|
|
|
774
|
+
Each call is all or nothing, `As of 2026-11-02`.
|
|
775
|
+
|
|
733
776
|
```ts
|
|
734
777
|
await db.tags.insertAll(names.map((name) => ({ orgId, name }))); // one statement, n rows
|
|
735
778
|
|
|
@@ -754,14 +797,14 @@ caller counts what it actually inserted.
|
|
|
754
797
|
| | |
|
|
755
798
|
|---|---|
|
|
756
799
|
| One builder | `insertStatement` compiles every insert in the framework, so `insertAll([row])` is the text `insert(row)` always produced. There is no second insert path to drift |
|
|
757
|
-
| What a collision overwrites | every column the batch
|
|
800
|
+
| What a collision overwrites | every column the batch's rows **named** (defaults `$parse` fills reach a new row only, so an unnamed `createdAt` or `label` keeps its stored value; `onUpdateNow()` columns move; a hand-held repository reads the rows it was given, or `UpsertArgs.overwrites`), minus the conflict target (how the row was found), minus the primary key (where it lives) and minus the soft-delete stamp (whether the row is there at all). Moving either of the first two moves a row nobody asked to move, and every foreign key pointing at that id misses it |
|
|
758
801
|
| A soft-deleted row it lands on | stays deleted, and takes the batch's other columns. The stamped row still occupies its conflict target — that index is not partial — so `excluded."deleted_at"` would resurrect it; `$parse` fills `deletedAt: null` into every row before the plan is built, so the stamp is dropped from the set list rather than refused. `insertAll` still writes the stamp a new row carries |
|
|
759
802
|
| Conflict target | properties of a **declared** unique constraint — the primary key, a `unique()` column, an `indexes: [{ on, unique: true }]` entry, or an `invariant(name, c.unique([…]))`. Anything else is `X_INVARIANT_VIOLATED` here rather than `42P10` from the server |
|
|
760
803
|
| Tenancy | on a tenant-scoped entity `onMatch: 'update'` requires the tenant column *in the conflict target*, else `X_TENANCY_UNSCOPED`: a target that omits it matches another tenant's row and rewrites it. `'nothing'` is allowed — it writes nothing to a row it does not own |
|
|
761
804
|
| A batch that repeats itself | two rows with one conflict target under `'update'` is refused. Postgres answers that statement `ON CONFLICT DO UPDATE command cannot affect row a second time`, so it cannot pass in memory either |
|
|
762
|
-
| Uneven batches | under `'update'` every row must name the same columns: `excluded.<column>` for a row that omitted one is that column's *default*, not "leave it alone". Under `'nothing'` and under `insertAll`, an omitted column is `default` in its cell, which is what the same row means on its own |
|
|
805
|
+
| Uneven batches | under `'update'` every row must name the same columns: `excluded.<column>` for a row that omitted one is that column's *default*, not "leave it alone". Under `'nothing'` and under `insertAll`, an omitted column is `default` in its cell, which is what the same row means on its own. A property present with `undefined` is absent here as in every write: refused beside a row writing a value, and left out of the set list when no row writes one |
|
|
763
806
|
| Nulls | a null in the conflict target collides with nothing, in both drivers — a Postgres unique index is `NULLS DISTINCT` |
|
|
764
|
-
| Size | past 65535 bind parameters the batch is several statements, never one the server refuses.
|
|
807
|
+
| Size | past 65535 bind parameters the batch is several statements inside one transaction (a savepoint inside an open `withTransaction`), never one the server refuses. A failure in any chunk leaves no row of the batch, in both drivers; one chunk stays one autocommit statement, no `BEGIN`. Partial progress is yours to ask for: split the batch and call once per part |
|
|
765
808
|
| Filtered writes | `updateWhere` / `deleteWhere` above are the bulk forms of `update` and `delete` — one statement for a loop that would otherwise call either per row; there is no `updateAll` |
|
|
766
809
|
|
|
767
810
|
## Relations are the foreign keys, read twice
|
|
@@ -851,12 +894,36 @@ pattern × value — never a backtracking regex), a unique declared as `invarian
|
|
|
851
894
|
is enforced by memory exactly as an index is, and `in` takes a list or nothing (a scalar matches no rows; a list carrying
|
|
852
895
|
a `null` also matches the NULL rows, which `col = null` never does).
|
|
853
896
|
|
|
897
|
+
**A memory transaction ends as a Postgres one does**, `As of 2026-11-05` (`memory-transactor.ts`,
|
|
898
|
+
`memory-journal.ts`):
|
|
899
|
+
|
|
900
|
+
| Rule | What it means |
|
|
901
|
+
|---|---|
|
|
902
|
+
| ambient | a memory-store write inside `memoryTransactor().run` that names no `tx` joins it, as one inside `withTransaction` does |
|
|
903
|
+
| nested | a nested commit hands its undos **and its effects** up: an `onCommit` effect runs once the root commits, and never after the root rolls back |
|
|
904
|
+
| ended | a write after the root ended — a promise `work` forgot to await — is `X_DB_TRANSACTION_CLOSED`, as a statement on a finished Postgres transaction is |
|
|
905
|
+
| locks | a row or key lock taken with no `tx` inside a memory transaction is a savepoint of it, held to the root's end |
|
|
906
|
+
| observers | the row observer and the action's cache bust wait for the root commit, and see nothing of a rollback |
|
|
907
|
+
|
|
908
|
+
`memoryJournal()` answers the open memory transaction of this async chain, or `undefined` — the
|
|
909
|
+
memory twin of `@ultimat3/db`'s `currentTx()`, which `@ultimat3/action`'s commit scope reads.
|
|
910
|
+
|
|
854
911
|
`compareByKind(kind, left, right)` and `sameValueOfKind(kind, left, right)` are that rule, exported
|
|
855
912
|
(`As of 2026-10`): `-1 | 0 | 1` and a boolean for two values of one column of a declared
|
|
856
913
|
`ColumnKind`, NULL sorting largest. `@ultimat3/query`'s live matcher and in-memory source call them
|
|
857
914
|
rather than keep a comparator of their own; `compare-parity.test.ts` runs the rule against both
|
|
858
915
|
drivers.
|
|
859
916
|
|
|
917
|
+
`testDriver()` is the third, and only a test takes it (`As of 30.0`): memory until
|
|
918
|
+
`@ultimat3/db`'s `setDbClient` installs a client, `postgresDriver()` through that client while one
|
|
919
|
+
is, decided per repository call — `database()` resolves each table's repository once at module
|
|
920
|
+
load, so a driver chosen once would never follow a test's migrated database. `reset()` empties the
|
|
921
|
+
memory half only. `transactor().run` takes the side installed when it starts, and every call inside
|
|
922
|
+
it stays there: a client installed or cleared mid-transaction is `X_TEST_DRIVER_CLIENT_CHANGED`,
|
|
923
|
+
never a transaction split across two stores. The scaffolded handle's line is
|
|
924
|
+
`storeMode(env) === 'memory' ? testDriver() : postgresDriver()`; the recipe is
|
|
925
|
+
[Testing](../../wiki/Testing.md#database-state-and-what-a-run-leaves-behind).
|
|
926
|
+
|
|
860
927
|
`database()` called with no driver takes the process default, and `defaultDriver()` is that same
|
|
861
928
|
object — the one seam a test harness needs, `As of 2026-08`:
|
|
862
929
|
|
|
@@ -932,9 +999,9 @@ Nothing new to write: the relation is the `references()` the column already decl
|
|
|
932
999
|
|
|
933
1000
|
| | |
|
|
934
1001
|
|---|---|
|
|
935
|
-
| Served to | a lookup with the same scope key, the same client, and no write to
|
|
1002
|
+
| Served to | a lookup with the same scope key, the same client, and no write since — to any entity — the preload statement *is* the statement it was widened from |
|
|
936
1003
|
| Scope | the tenant predicate and `deleted_at is null` are in the preload statement, so a page's ids can never resolve rows outside the reader's own scope |
|
|
937
|
-
| A write | drops
|
|
1004
|
+
| A write | drops everything preloaded, before the statement goes out: a `countedIn` trigger or an `onDelete` action rewrites rows of an entity the statement never named. The next lookup reads its own statement; the one after a write-free stretch preloads again, so a loop that writes every iteration reads each row once, never the rest of the page |
|
|
938
1005
|
| Held | the ids, never the rows; keyed by context identity, so it dies with the request |
|
|
939
1006
|
| Declines to the old statement | no request in scope, an id no page indexed, a key that resolved to nothing |
|
|
940
1007
|
| Switched off | `postgresDriver({ jitPreload: false })`, where the driver is constructed — the one switch. Not an `app.config.ts` key: nothing reads config at the seam that builds a repository |
|
|
@@ -972,6 +1039,12 @@ deliberate, and a production process pays the one branch the observer seam costs
|
|
|
972
1039
|
one number both detectors read *is* here — `N_PLUS_ONE_THRESHOLD` (5), next to the codes whose `fix`
|
|
973
1040
|
it triggers, so a loop that fails a test and a loop that warns in dev are the same loop.
|
|
974
1041
|
|
|
1042
|
+
**Both drivers report**, `As of 2026-11-02`. `memoryRepo` sends no statement, so it reports one event
|
|
1043
|
+
per repository operation through `statementObserver()`, under the pair `postgresRepo` names
|
|
1044
|
+
(`posts.findById`) and with no SQL text. A microtask's point lookups in one request are one event, as
|
|
1045
|
+
`coalesce.ts` makes them one statement. Postgres also serves a sibling lookup off a page it already
|
|
1046
|
+
read (`jit-preload.ts`); memory has no such page, so it counts that lookup and is the stricter driver.
|
|
1047
|
+
|
|
975
1048
|
## Tenancy is a guard
|
|
976
1049
|
|
|
977
1050
|
`tenant: 'orgId'` on the entity names the column outright. Omit it and it is inferred — a
|
|
@@ -997,6 +1070,40 @@ string or a path parameter — it is `X_TENANCY_ACTOR_MISMATCH`, refused rather
|
|
|
997
1070
|
overridden, with both values in the cause. `in` on the tenant column is judged the same way: a set
|
|
998
1071
|
containing the actor's org is still a set that is not it.
|
|
999
1072
|
|
|
1073
|
+
**The database can hold the same line.** `database.rowSecurity: true` in `app.config.ts` gives
|
|
1074
|
+
every tenant table a forced Postgres policy over this same column (`x db gen "row security"`), so a
|
|
1075
|
+
raw `sql` read of another tenant's row returns nothing. `describeTenancy()` is the projection the
|
|
1076
|
+
generator reads — `{ table, column }` per tenant entity, physical names, beside `describeEntities()`
|
|
1077
|
+
rather than on it, so no schema hash moves. Rules: [Policies and authz](../../wiki/Policies-And-Authz.md#the-databases-own-guard--row-security-as-of-2026-11-06).
|
|
1078
|
+
|
|
1079
|
+
**A reference stays inside its tenant.** Both ends scoped makes `references()` a composite key:
|
|
1080
|
+
`(org_id, post_id) -> posts (org_id, id)`, so a row naming another org's post is refused by the
|
|
1081
|
+
database with `X_DB_FOREIGN_KEY_VIOLATION`, the code and the text a dangling id gets. Memory refuses
|
|
1082
|
+
a target found under another tenant and accepts one not found, because it enforces no foreign key.
|
|
1083
|
+
`crossTenant: '<reason>'` on the option is the stated escape; blank, or on an edge that was never
|
|
1084
|
+
same-tenant, is `X_INVARIANT_VIOLATED`. `x db gen` adds the `(tenant, id)` unique index the target
|
|
1085
|
+
needs and swaps the key online. [The wiki](../../wiki/Entities-And-Migrations.md#same-tenant-keys).
|
|
1086
|
+
|
|
1087
|
+
**A table with no tenant column that hangs off one with a tenant says why.** `entity('plans', {
|
|
1088
|
+
columns, shared: 'one price list for every org' })` states its rows belong to no tenant. Without
|
|
1089
|
+
it, `x verify`'s `exercise` step reports `X_ENTITY_TENANT_UNDECLARED` for each untenanted entity
|
|
1090
|
+
whose references reach, directly or through others, a tenant-columned one: a row of one tenant
|
|
1091
|
+
readable by id from another. A blank reason is `X_ENTITY_TENANT_UNDECLARED` at `entity()`, and
|
|
1092
|
+
`shared` beside a tenant column is `X_INVARIANT_VIOLATED`. A `shared` entity is not walked through.
|
|
1093
|
+
|
|
1094
|
+
**A table read whole says why.** `x verify`'s `exercise` step plans every statement the pass sent
|
|
1095
|
+
with no index but the declared ones: a sequential scan is `X_QUERY_UNINDEXED`, a limited read that
|
|
1096
|
+
sorts is `X_QUERY_SORT_UNSERVED`, each printing the `indexes: [{ on: [...] }]` line to add.
|
|
1097
|
+
`entity('plans', { columns, scan: 'a closed catalog of 8 rows' })` is the one way a bounded table
|
|
1098
|
+
opts out; blank is `X_INVARIANT_VIOLATED`. A hand-written statement that must scan is
|
|
1099
|
+
`expectedSeqScan('<why>', fn)` from `@ultimat3/db`.
|
|
1100
|
+
|
|
1101
|
+
**A read-modify-write takes a lock.** `db.posts.lock(id, (row, tx) => …)` holds the row for the
|
|
1102
|
+
callback and `db.posts.lockKey(key, (tx) => …)` holds a key of this table and tenant. A transaction
|
|
1103
|
+
each, a savepoint inside an open one, and the lock lasts to the root COMMIT. `fn` writes with
|
|
1104
|
+
`{ tx }`. A hand-written `Repo` without them answers `X_LOCK_UNSUPPORTED`.
|
|
1105
|
+
[The wiki](../../wiki/Entities-And-Migrations.md#locking-a-row-or-a-key).
|
|
1106
|
+
|
|
1000
1107
|
| Situation | Answer |
|
|
1001
1108
|
|---|---|
|
|
1002
1109
|
| actor carries an `orgId` | the plan is scoped to it, derived |
|
|
@@ -1038,7 +1145,9 @@ await crossTenant('nightly invite expiry runs for every org', async () => {
|
|
|
1038
1145
|
The scope needs the `tenancy:cross` capability on the actor (`scopes: ['tenancy:cross']`), proven
|
|
1039
1146
|
at the call and again at every plan built inside it, so an impersonated child context cannot
|
|
1040
1147
|
inherit it: `X_TENANCY_CROSS_DENIED` otherwise. A blank reason is refused — an escape with no
|
|
1041
|
-
argument is a pragma.
|
|
1148
|
+
argument is a pragma. `setCrossTenantObserver(fn | undefined)` installs the one process-wide
|
|
1149
|
+
witness told each reason once its capability is proven — `x exercise`'s tenant proof reads it to
|
|
1150
|
+
tell a job's declared sweep from a leak; an app has no use for it. There is no build-time tenancy check in `x verify`, and there cannot
|
|
1042
1151
|
usefully be one: the tenant is a request-time value, so the seam every plan is built through is
|
|
1043
1152
|
the enforcement.
|
|
1044
1153
|
|
|
@@ -1094,3 +1203,11 @@ implementation in one package instead of two. No `drizzle-orm` dependency:
|
|
|
1094
1203
|
`types.ts` declares the narrow structural column vocabulary this package consumes, so the
|
|
1095
1204
|
generated SQL stays readable and an agent can self-correct against it. `@ultimat3/cache`
|
|
1096
1205
|
invalidates by the `entity:<name>` tag.
|
|
1206
|
+
|
|
1207
|
+
## Supporting exports
|
|
1208
|
+
|
|
1209
|
+
`As of 2026-11-02`.
|
|
1210
|
+
|
|
1211
|
+
| Name | What |
|
|
1212
|
+
|---|---|
|
|
1213
|
+
| `SameTenantKey` | a `references()` whose two ends share a tenant: the physical tenant column on each side, which makes the key composite. `ReferenceDescription.sameTenant` holds it, or `null` |
|
package/package.json
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/entity",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "30.0.0",
|
|
4
4
|
"description": "A table + its domain type + invariants the database also enforces",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ultimate",
|
|
7
|
+
"bun",
|
|
8
|
+
"framework",
|
|
9
|
+
"entity",
|
|
10
|
+
"orm",
|
|
11
|
+
"postgres",
|
|
12
|
+
"schema"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/developerz-ai/ultimate/wiki/Entities-And-Migrations",
|
|
5
15
|
"license": "MIT",
|
|
6
16
|
"type": "module",
|
|
7
17
|
"repository": {
|
|
@@ -33,9 +43,9 @@
|
|
|
33
43
|
"test": "bun test"
|
|
34
44
|
},
|
|
35
45
|
"dependencies": {
|
|
36
|
-
"@ultimat3/core": "
|
|
37
|
-
"@ultimat3/db": "
|
|
38
|
-
"@ultimat3/schema": "
|
|
39
|
-
"@ultimat3/time": "
|
|
46
|
+
"@ultimat3/core": "30.0.0",
|
|
47
|
+
"@ultimat3/db": "30.0.0",
|
|
48
|
+
"@ultimat3/schema": "30.0.0",
|
|
49
|
+
"@ultimat3/time": "30.0.0"
|
|
40
50
|
}
|
|
41
51
|
}
|
package/src/append-only.ts
CHANGED
|
@@ -6,11 +6,14 @@
|
|
|
6
6
|
import { appendOnlyColumnRefused, appendOnlyRefused } from './append-only-errors';
|
|
7
7
|
import type { Repo, UpsertArgs } from './repo';
|
|
8
8
|
import type { AnyColumn } from './types';
|
|
9
|
+
import { readOnlyRepo } from './view-repo';
|
|
9
10
|
|
|
10
11
|
/** The two members the seam reads — so a driver written outside this package can pass its entity. */
|
|
11
12
|
export interface AppendOnlySource {
|
|
12
13
|
readonly $name: string;
|
|
13
14
|
readonly $appendOnly: boolean;
|
|
15
|
+
/** `entity({ view })`: every write refused. Optional so a hand-built source still passes. */
|
|
16
|
+
readonly $viewQuery?: unknown;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
/**
|
|
@@ -55,6 +58,10 @@ export const assertAppendOnlyColumns = (
|
|
|
55
58
|
* rewrite is not append-only. Changing existing rows is lifting the flag, in a migration.
|
|
56
59
|
*/
|
|
57
60
|
export const appendOnlyRepo = <Row, R extends Repo<Row>>(entity: AppendOnlySource, repo: R): R => {
|
|
61
|
+
// A view is the stricter rule and the same seam: nothing written, not even an append.
|
|
62
|
+
if (entity.$viewQuery !== undefined && entity.$viewQuery !== null) {
|
|
63
|
+
return readOnlyRepo<Row, R>(entity.$name, repo);
|
|
64
|
+
}
|
|
58
65
|
if (!entity.$appendOnly) return repo;
|
|
59
66
|
const name = entity.$name;
|
|
60
67
|
const refusing: Pick<
|