@ultimat3/entity 2.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -29,6 +29,38 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
29
29
  `pg-driver-bulk.live.test.ts`, `pg-driver-tenancy.live.test.ts`). A method with only the first is
30
30
  unproven against Postgres itself; a method with only the second is unproven against memory. Both
31
31
  are the bar, not either one.
32
+ - **What a PREDICATE means is decided by the column's declared KIND, and `memory-match.ts` is
33
+ where that one meaning is written.** The database decides by the column's type, so a driver
34
+ deciding by the JS `typeof` of the value in hand is answering a different question — four rules,
35
+ each of them a place the two drivers used to disagree, `As of 2026-08`. **A decimal-string column
36
+ orders by its digits**: `bigint()` and `decimal()` both hand back a STRING (deliberately —
37
+ `JSON.stringify` throws on a `bigint` and a `number` loses digits past 2^53), so neither the
38
+ `number`/`number` branch nor the `bigint`/`bigint` branch fired and both fell to
39
+ `String(left) < String(right)` — `["10","100","2","9"]` against Postgres' `2, 9, 10, 100`, and a
40
+ keyset page cut where the database cuts none, since the seek compares the stored string against
41
+ a revived `BigInt`. The comparison is exact at any width (the fractions are padded and both sides
42
+ become one integer), which no `Number()` is — and `As of 2026-08` it is **`@ultimat3/core`'s
43
+ `compareDecimalText`**, not this file's, because the text arrives in more than one package while
44
+ the declared kind does not. What stays here is `DECIMAL_TEXT` (which kinds are decimal text) and
45
+ the decision to ask; core's function answers `undefined` for a pair that is not two plain
46
+ decimals, so a caller with no kinds — `@ultimat3/query`, whose `OrderKey` is a name and a
47
+ direction — deliberately never calls it: Postgres orders a `text` column of digits lexically, and
48
+ a comparator guessing "both sides look like decimals" would trade this agreement for that
49
+ disagreement. That residual gap is `@ultimat3/query`'s `shape-order.test.ts` `DECLARED_GAP`. **A `uuid` is a VALUE**: Postgres parses it and
50
+ prints it lower-cased, so `findById(UPPER)` reads the row there and answered `null` here, and
51
+ `update(UPPER)` was `X_NOT_FOUND` against a row that exists — `keyOf(kind, value)`
52
+ (`batch-read.ts`), which already carried that rule for a batched read, now spells the memory
53
+ store's key and its equality too. Text is NOT narrowed: lower-casing it would merge two rows
54
+ Postgres keeps apart. **A `LIKE` pattern uses Postgres' default escape**: `\` escapes `%`, `_`
55
+ or itself, so `like 'a\%b'` matches the literal `a%b` in both drivers rather than
56
+ `a\<anything>b` in one — and a pattern ending in the escape character is refused here as
57
+ Postgres refuses it (`22025`). A RUN of `%` is still one `.*`: twenty adjacent `.*` groups in an
58
+ anchored regex is a CPU stall on a filter value forwarded from a search box. **`in` takes a list
59
+ or nothing**, in both drivers and in `@ultimat3/query`: a scalar operand matches NO rows (it was
60
+ wrapped into a one-element list for the SQL and refused in memory — 0 rows against one driver, 1
61
+ against the other, from a call `andWhere(column, op, value: unknown)` compiles), and a list
62
+ carrying a NULL emits `(col in (…) or col is null)` — `col = null` is UNKNOWN, so the null row
63
+ the caller listed was the one row Postgres left out while memory included it.
32
64
  - **The Postgres driver is proved against a real Postgres, not only against a recording client.**
33
65
  `pg-driver.live.test.ts` runs the whole chain — `entity()` -> `$describe()` ->
34
66
  `generateMigration()` -> a live server -> `postgresDriver()` -> decoded row — and skips when no
@@ -48,7 +80,15 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
48
80
  sent, which is why `findById` keeps its signature and there is no `batch()` to opt into. The
49
81
  window closes before the statement goes out, so a lookup arriving mid-flight opens the next batch
50
82
  instead of joining ids already on the wire, and past `MAX_IDS_PER_STATEMENT` a batch becomes
51
- several whole statements rather than one Postgres refuses for its bind count. A sequential
83
+ several whole statements rather than one Postgres refuses for its bind count. **No caller of a
84
+ batch is ever left unsettled** — every promise `coalesceFindById` returns was handed out before
85
+ the flush was scheduled, so `flush` settles the whole of `waiting` in a catch of its own rather
86
+ than only the chunk that failed, and the scheduled `flush` carries a `.catch`: a rejection there
87
+ has nobody left to hand it to and an unhandled one ends the Bun process. Unsettled forever is
88
+ strictly worse than failed — a rejection is a stack trace and a hang is a request that never
89
+ answers — which is why `coalesce.test.ts` races its assertions against a deadline instead of
90
+ letting the runner time out. `jit-preload.ts` has the same property by construction, settling
91
+ with an `Answer` rather than a rejection. A sequential
52
92
  `for … of` loop shares no microtask — its `await` ends the window — which is what the sibling
53
93
  preload below is for.
54
94
  - **A page batches the loop it causes, and a preloaded row is only ever served to the statement
@@ -66,9 +106,15 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
66
106
  calls `forgetPreloaded(entity.$name)` *before* the statement, so a row a request changed is
67
107
  re-read and never served from a page read before it. **Values, not rows**: the index is keyed by
68
108
  id and holds ids, so a page early in a long request pins its keys and not its rows, and it dies
69
- with the request like every other per-ctx store here. **Declining is the old behaviour**: no
70
- request in scope, an id no page indexed, a key that resolved to nothing the caller reads the
71
- statement it always read. `MAX_IDS_PER_STATEMENT` bounds the preload exactly as it bounds a
109
+ with the request like every other per-ctx store here. **And the store itself is BOUNDED**
110
+ (`MAX_SIBLING_KEYS`, four statements' worth), `As of 2026-08`: "dies with the request" is a job's
111
+ whole attempt, `MAX_IDS_PER_STATEMENT` bounded the statement and nothing bounded the store, and
112
+ 1,000 pages x 1,000 distinct keys measured **159.3 MB retained** against a 2.7 MB control — ~2 GB
113
+ on a 12M-row `backfill()`, an OOM in the worker on the DEFAULT configuration, since `jitPreload`
114
+ defaults to true and `backfill()` names no driver option. Oldest page first, for both maps: the
115
+ key index AND the bucket, which holds rows and is therefore the worse of the two.
116
+ **Declining is the old behaviour**: no request in scope, an id no page indexed, a key that
117
+ resolved to nothing, a key the bound evicted — the caller reads the statement it always read. `MAX_IDS_PER_STATEMENT` bounds the preload exactly as it bounds a
72
118
  batch. What both share — the scope key, `keyOf`, the one `in` statement — lives in
73
119
  `batch-read.ts` so the two can never disagree about when a shared statement is legal.
74
120
  **One switch, where the driver is built**: `postgresDriver({ jitPreload: false })` /
@@ -274,7 +320,12 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
274
320
  rendered from it.** The resolved records are the source; `ColumnDescription.references` spells
275
321
  `"<table>.<column>"` out of one for the migration generator, which is in tier 1 and cannot
276
322
  import this package. Never parse that string back — it carries physical names and a traversal
277
- reads row *properties*, so the parse would be a second, lossy resolver. `references()` is a
323
+ reads row *properties*, so the parse would be a second, lossy resolver. **`onDelete` rides
324
+ beside it, on both `ColumnDescription` and `ReferenceDescription`, `As of 2026-08-19`**: the flat
325
+ string has no room for a rule and neither record had a field for one, so a declared
326
+ `{ onDelete: 'cascade' }` type-checked and reached no SQL for three majors — `@ultimat3/db` emits
327
+ it now, and it can only see what the projection carries. Read off the resolved reference, never
328
+ off `meta` a second time: a rule with no key is not a thing. `references()` is a
278
329
  method, not a field: a thunk may point at an entity two modules of an import cycle have not
279
330
  finished evaluating. `relationMap()` memoises the whole-registry derivation against
280
331
  `registryGeneration()`, which every registration bumps — a schema module imported late must
@@ -360,6 +411,16 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
360
411
  the three callers want three answers and the wrong one is invisible in the result. The soft
361
412
  delete inside `removal()` passes `false` too — both its callers read a count through
362
413
  `execute()`, so its rows were never readable by anyone.
414
+ - **Every instant the write path stamps comes from `ctx.clock`, through `entityNow()`**
415
+ (`clock.ts`, `As of 2026-08`). `defaultNow()`, `touch()`'s `onUpdateNow()`, the soft-delete stamp
416
+ in BOTH drivers and a seed's `now` each read `systemClock` directly, so a frozen test clock drove
417
+ nothing the entity layer wrote — `createdAt`, `updatedAt` and `deletedAt` were the wall clock
418
+ however the ctx was built, and a test could only assert a range where it wanted a value. The read
419
+ path still reads no clock at all, which is what makes IT drivable (`@ultimat3/query`'s CLAUDE.md
420
+ says so in as many words); this is the write half of the same property. Outside a request there
421
+ is no ctx and the system clock IS the answer — a script, a worker boot and a seed take that
422
+ branch exactly as before. Never read `systemClock` on the write path again: five sites is how the
423
+ four stamps of one write ended up able to disagree.
363
424
  - **`touch()` in `query.ts` is the ONE place `onUpdateNow()` columns are stamped**, for
364
425
  `update(id, patch)` and `updateWhere(filter, patch)` alike — a second copy is how one of them
365
426
  ends up writing a stale `updatedAt`. It returns an empty patch untouched, so whether
@@ -578,6 +639,76 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
578
639
  ±2^53 message, where the value is a `number`, a `bigint` or a digits-only string, the amount is
579
640
  the only fact that repairs the row, and `@ultimat3/realtime` renders the same value the same way
580
641
  for the same reason. Changing either means changing both.
642
+ - **A seed is replayable by construction, and `insert` is the verb that makes it so** — decided
643
+ 2026-08. `defineSeed`'s context offered `insert` and nothing else, and a plain insert meant two
644
+ different things to the two drivers: the memory repository overwrites by primary key, Postgres
645
+ raises `23505`. So a seed replayed twice passed every test in this repo and killed the SECOND
646
+ boot of any container on a durable store, which is why both tracked apps had (or needed) a
647
+ hand-written `Driver` decorator over `insert`. `SeedContext.insert` now writes one
648
+ `upsertAll(rows, { onConflict: entity.$primaryKey, onMatch: 'nothing' })` per call. Four
649
+ properties, none optional. **`'nothing'`, never `'update'`**: `upsertPlan` refuses an updating
650
+ upsert whose target omits the tenant column (`X_TENANCY_UNSCOPED`), and a tenant-scoped entity
651
+ whose only unique keys are global — `posts` in the reference app — has no legal updating target
652
+ at all; `'nothing'` also skips the uneven-batch and nothing-to-set refusals, which a fixture
653
+ graph would otherwise have to satisfy. **One statement per call**, so the per-row `insert` loop
654
+ this replaced is no longer the N+1 of its own bulk form. **The metrics are the driver's answer**,
655
+ not a count of the input: `upsertAll` under `'nothing'` resolves with the rows it actually wrote,
656
+ so `skipped` is the replay, observed. **A generated primary key the row does not name is
657
+ refused** — `uuid().primaryKey()` carries `GENERATED_UUID`, so `$parse` fills it with a fresh
658
+ uuid, the conflict target matches nothing and run five leaves five copies; it is the one
659
+ duplication no other rule in this package can see.
660
+ - **`upsert(entity, { by }, values)` is the second verb, and it exists because only the SEED AUTHOR
661
+ knows the natural key.** A seed writing into a table whose ids already exist — `banks` keyed by
662
+ `value`, `users` by `email`, `exchange_rates` by `(base, target, effective_on)` — cannot choose a
663
+ primary key, so keying replay on `$primaryKey` would be keying it on something Postgres does not
664
+ enforce. It reads first so an unchanged row can answer `'skipped'` with no statement, then writes
665
+ through ONE `on conflict … do update`: the read is for the report, never for the decision, or two
666
+ containers booting at once would race between the two. **`createdAt` is preserved on a match** —
667
+ the row handed to the update omits it, so `namedProperties` leaves it out of the `set` — because
668
+ a replay must not move when a row first arrived.
669
+ - **The environment guard is the CLI's, not `run()`'s.** `seedTiersFor(environment, requested)` is
670
+ the one table (`reference` everywhere, `dev` everywhere but production) and `x db seed` is what
671
+ refuses. `run()` stays permissive on purpose: `dummy/social-media-clone/apps/web/api/index.ts`
672
+ seeds its own production demo database from its boot code and says out loud that this is an app
673
+ decision (axiom 8) — a library refusal would break it. A seed declares its tier as DATA, the way
674
+ a `backfill()` declares its environments.
675
+ - **One resolver decides a physical name, and it is `columnName(property, meta)`** — decided
676
+ 2026-08 with `entity(name, { table })` and `.column(name)`. Before them, `snake(property)` was
677
+ called in nine places and `$table` was the entity name, so a schema this framework did not
678
+ generate could not be declared at all: adoption meant a rewrite. Every projection now reads the
679
+ resolver — the DDL (`describe.ts`), the binding and the decoder (`pg-row.ts`), the predicate and
680
+ sort resolver, the index names, the invariant SQL, the soft-delete clause in `pg-sql.ts` and
681
+ `pg-driver.ts`. **A second `snake(property)` anywhere is a statement naming a column the table
682
+ does not have**, and the first table that proves it is somebody's production database. It is
683
+ additive by construction: with no override the resolver IS `snake(property)`.
684
+ - **The entity NAME and the TABLE are different things.** The name stays the framework's key — the
685
+ registry, the cache tag (`entity:account`), `$tagFor`, every relation and every policy — and the
686
+ table is physical. Renaming a table must never move a cache tag. Index names are the TABLE's,
687
+ because an index is a physical object.
688
+ - **Money's three columns are per-part and `scale: null` is a real answer.** `money({ columns })`
689
+ merges over `<base>_minor`/`<base>_currency`/`<base>_scale` one part at a time, so a table that
690
+ renamed one does not restate the other two. `scale: null` says the table has no scale column at
691
+ all — the ordinary shape of an amount written before scale existed — and then `columnsOf`
692
+ projects TWO names, `bindValues` writes two, and `decodeRow` folds two. That last one is why
693
+ `decodeRow` branches on `$meta.kind === 'money'` and not on how many names came back: reading a
694
+ two-column amount as a non-money column handed the caller a raw minor unit where a `Money` goes.
695
+ - **A `jsonb` value is bound as TEXT and cast back, `::text::jsonb`** — and the double cast is
696
+ load-bearing, not defensive. The driver seam refuses a plain object as a parameter
697
+ (`X_SQL_UNSAFE`; `isBoundValue` takes scalars, `Date`, `Uint8Array` and arrays of those). With
698
+ `$1::jsonb` the server describes the parameter as `jsonb`, Bun's `sql` JSON-ENCODES the string it
699
+ was handed, and `{"a":1}` is stored as the JSON *string* — `jsonb_typeof` says `string`
700
+ (measured, Postgres 17.10). Pinning the parameter to `text` first makes the client send the
701
+ characters and the server parse them. An ARRAY is the other value that cannot cross as itself:
702
+ Bun serialises a JS array to `x,y`, which Postgres answers with `malformed array literal`, so
703
+ `bindValues` writes the `{…}` literal with every element quoted.
704
+ - **The wide column types were chosen from what a driver actually returns, not from what reads
705
+ well.** `int8` is a string from Bun's `sql` and a `bigint` from PGlite; `numeric` is a string
706
+ from both; `date` is a `Date` at midnight UTC from both; `bytea` is a `Buffer` from one and a
707
+ `Uint8Array` from the other. Every one of those is normalised in `$parse` to a single row type,
708
+ because a row that means two things by driver is the drift this package's two-driver split exists
709
+ to refuse. `bigint()` and `decimal()` are STRINGS for the same reason `money.minor` is a
710
+ `number`: `JSON.stringify` throws on a bigint, and a `number` loses digits exactly where a legacy
711
+ `int8` key lives.
581
712
  - Never throw a bare `Error` — use `errors.ts`.
582
713
  - Tests restore the process-global registry in `afterAll` (`clearRegistry()`): a leaked registry
583
714
  breaks an unrelated package's tests, as it did in `@ultimat3/policy`.
@@ -586,12 +717,15 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
586
717
 
587
718
  | File | Job |
588
719
  |---|---|
589
- | `types.ts` | `Column`, `RowOf`, `Insertable`, `IdOf` — the type derivation |
590
- | `column.ts` / `columns.ts` | the chain + property-key binding; the blessed builders; `narrowMoney`, the one write-side narrowing both drivers run |
720
+ | `types.ts` | `Column`, `RowOf`, `Insertable`, `IdOf` — the type derivation. `COLUMN_KINDS` is the runtime array `ColumnKind` DERIVES from (the shape core's `PRIMITIVE_KINDS` uses), so a package answering "one case per kind" reads a real list rather than spelling its own |
721
+ | `column.ts` / `columns.ts` | the chain + property-key binding; the blessed builders; `columnName`/`moneyColumns`, the ONE physical-name resolver; `narrowMoney`, the one write-side narrowing both drivers run |
722
+ | `columns-data.ts` | the wide vocabulary an existing schema needs: `json`, `decimal`, `date`, `bigint`, `bytes`, `arrayOf` |
591
723
  | `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; bind + `toSql()` DDL |
592
724
  | `entity.ts` / `describe.ts` | `entity()`, `$row`; the `EntityDescription` projection |
593
725
  | `view.ts` | `$view(keys)` — the row projection an action names as its `output` |
594
726
  | `query.ts` / `database.ts` | chainable read to a cursor page; `database()` + `Driver` |
727
+ | `clock.ts` | `entityNow()` — the ONE clock read on the write path, `ctx.clock` else the system's |
728
+ | `memory-match.ts` | what a `Predicate` means in the memory driver: compare/equal/LIKE, by the column's kind. The decimal comparison itself is `@ultimat3/core`'s `compareDecimalText` |
595
729
  | `repo.ts` / `tenancy.ts` | `Repo<T>` + `memoryDriver`'s repo, tx rollback; `QueryPlan` + `scopedPlan()` for a read and `assertRowTenant()` for a write — one actor-derived tenant guard, both halves |
596
730
  | `cross-tenant.ts` | `crossTenant(reason, fn)` — the capability-gated scope that lifts it |
597
731
  | `plan.ts` / `cursor.ts` | the plan both drivers execute; the one keyset cursor codec |
@@ -607,6 +741,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
607
741
  | `registry.ts` | duplicate detection, `describeEntities()` for the manifest, `references()` per entry |
608
742
  | `relations.ts` | `relationMap()`/`relationsFor()`/`relationNamed()` — the FKs as a named `belongsTo`/`hasMany` map |
609
743
  | `n-plus-one.ts` | a repeated statement → the error whose `fix` is the preload or bulk call that ends it |
744
+ | `seed.ts` | `defineSeed` — the replayable fixture graph: `insert` (the seed's own ids), `upsert` (a natural key), the sentinel reads, and the tier table `x db seed` refuses from |
610
745
  | `type-pins.ts` | compile-time assertions `tsc` checks — the column proxy, `Invariant` variance, the branded id |
611
746
 
612
747
  ## Commands
package/README.md CHANGED
@@ -67,8 +67,58 @@ still imports one package: `import { entity, t } from '@ultimat3/entity'`.
67
67
  | `text({ max })`, `integer()`, `boolean()`, `url()` | `text`/`integer`/`boolean` + CHECK | format is enforced by the database too |
68
68
 
69
69
  Chain: `.primaryKey()` · `.nullable()` · `.unique()` · `.default(v)` · `.defaultNow()` ·
70
- `.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()`. Physical names are
71
- derived from the property key (`orgId` → `org_id`); a name is written once, or never.
70
+ `.onUpdateNow()` · `.references(() => other.id, { onDelete })` · `.tenant()` · `.column(name)`.
71
+ Physical names are derived from the property key (`orgId` → `org_id`); a name is written once, or
72
+ never — `.column()` is the exception, and it exists for tables this framework did not create.
73
+
74
+ ## Wide columns
75
+
76
+ The vocabulary an EXISTING schema needs. The blessed set above is a decision the framework made
77
+ for a table it was going to create; these are the shapes a table already has, `As of 2026-08`.
78
+
79
+ | Builder | Emits | Row type, and why |
80
+ |---|---|---|
81
+ | `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 |
82
+ | `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 |
83
+ | `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 |
84
+ | `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 |
85
+ | `bytes()` | `bytea` | a plain `Uint8Array`, normalised: Bun's `sql` returns a `Buffer` and PGlite a `Uint8Array`, and the two do not serialise alike |
86
+ | `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 |
87
+
88
+ ## Adopting an existing table
89
+
90
+ Three overrides, and together they are what makes a schema Ultimate did not generate declarable
91
+ at all. Nothing here changes what an entity without them emits.
92
+
93
+ ```ts
94
+ import { date, entity, money, text, uuid } from '@ultimat3/entity';
95
+
96
+ export const accounts = entity('account', {
97
+ table: 'legacy_accounts',
98
+ columns: {
99
+ id: uuid().primaryKey().column('account_id'),
100
+ githubLogin: text({ max: 40 }).column('gh_login'),
101
+ balance: money({ columns: { minor: 'amount_cents', currency: 'currency', scale: null } }),
102
+ openedOn: date().column('opened_on'),
103
+ },
104
+ });
105
+ ```
106
+
107
+ | Override | What follows it |
108
+ |---|---|
109
+ | `entity(name, { table })` | every statement, index name and foreign key. The entity NAME stays the framework's key — the registry, the cache tag (`entity:account`), `x entities describe` and every relation are keyed by it, so renaming a table never moves a cache tag or a policy |
110
+ | `.column(name)` | the DDL, the binding, the decoder, the predicate, the sort key, the cursor. Name it LAST in a chain — the link returns the general column, and only `uuid()` and `timestamp()` keep their own methods across it |
111
+ | `money({ columns })` | per part, merged over `<name>_minor` / `<name>_currency` / `<name>_scale`, so a table that renamed one does not restate the other two. `scale: null` says the table has no scale column: every amount is then at the currency's own minor unit, which is what an absent scale already means |
112
+
113
+ A physical name is checked where it is written — lower-case letters, digits and underscores, at
114
+ most the 63 bytes Postgres truncates at — because it is spliced into every statement as an
115
+ identifier.
116
+
117
+ **What is not adoptable yet**, `As of 2026-08`: a `numeric` money column with no currency column
118
+ beside it (`money()` is two columns by construction — declare it `decimal()` and keep the currency
119
+ in the app), a Postgres `enum` TYPE (`enumerated()` emits a CHECK, not a `CREATE TYPE`), a
120
+ composite type, and a live query over a renamed column — `@ultimat3/realtime` rebuilds a row from
121
+ the physical names alone and would deliver `ghLogin` where the repository says `githubLogin`.
72
122
 
73
123
  ## Branded ids
74
124
 
@@ -331,6 +381,13 @@ database({ orgs, posts }, { driver: postgresDriver() }); // production
331
381
  | For | tests, `x dev` before the first migration | production |
332
382
  | Transaction | `memoryTransactor()` — undo closures | `postgresTransactor()` — real `BEGIN`/`COMMIT` |
333
383
  | `reset()` | empties every repository it built | not implemented — the rows are the app's |
384
+ | A predicate | `memory-match.ts`, by the column's declared KIND | the SQL `pg-sql.ts` compiles |
385
+
386
+ Both answer the same predicate the same way, and the kind is what decides — never the JS type of
387
+ the value: `bigint()`/`decimal()` hold decimal STRINGS and order by their digits (`2, 9, 10, 100`),
388
+ a `uuid` compares case-insensitively because Postgres compares it as a value, `\` escapes a `%` or
389
+ a `_` inside a `like`, and `in` takes a list or nothing (a scalar matches no rows; a list carrying
390
+ a `null` also matches the NULL rows, which `col = null` never does).
334
391
 
335
392
  `database()` called with no driver takes the process default, and `defaultDriver()` is that same
336
393
  object — the one seam a test harness needs, `As of 2026-08`:
@@ -514,9 +571,30 @@ the enforcement.
514
571
 
515
572
  ## Seeds
516
573
 
517
- `defineSeed('dev', async ({ insert, id }) => …)`. `id('post:tenancy')` is a UUID v5 of the label,
518
- so the same fixture graph gets the same ids on every machine. Rows go through the columns and
519
- the invariants, which makes a seed a test of the schema as well.
574
+ `defineSeed(name, build, { tier })` the fixture graph, written once and **replayed anywhere**: a
575
+ second run writes nothing and raises nothing, against Postgres as well as against memory. Rows go
576
+ through the columns and the invariants either way, which makes a seed a test of the schema as well.
577
+ `x db seed [<name>]` is what applies it.
578
+
579
+ Two write verbs, because only the author knows which key identifies a row:
580
+
581
+ | Context member | Use it when | A replay |
582
+ |---|---|---|
583
+ | `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` |
584
+ | `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 |
585
+ | `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 |
586
+ | `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 |
587
+ | `id`, `now`, `environment`, `tier`, `dryRun`, `metrics` | — | `now` is one instant per run; `metrics` is `{ inserted, updated, skipped }` and `run()` returns it |
588
+
589
+ Rules worth knowing before the first seed: `upsert` never overwrites `createdAt` (`preserve` names
590
+ other columns to spare); `upsert` on a tenant-scoped entity needs the tenant column inside `by`,
591
+ exactly as `upsertAll` does, while `insert` needs nothing because `do nothing` writes nothing to a
592
+ row it does not own; and `insert` refuses a row that leaves a `uuid().primaryKey()` unnamed —
593
+ `$parse` would fill it with a fresh uuid and every replay would insert one more copy.
594
+
595
+ `tier` is `'dev'` (the default, fixture data) or `'reference'` (data the app is wrong without,
596
+ which ships to production). The refusal is the CLI's, never `run()`'s: an app that seeds its own
597
+ database from its boot code has decided to, and a library that overruled that would break it.
520
598
 
521
599
  ## Errors
522
600
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "2.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,8 +31,9 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "2.0.0",
35
- "@ultimat3/db": "2.0.0",
36
- "@ultimat3/schema": "2.0.0"
34
+ "@ultimat3/core": "4.0.0",
35
+ "@ultimat3/db": "4.0.0",
36
+ "@ultimat3/schema": "4.0.0",
37
+ "@ultimat3/time": "4.0.0"
37
38
  }
38
39
  }
package/src/bulk-write.ts CHANGED
@@ -5,10 +5,12 @@
5
5
  // reads the same answer rather than a second copy of the rule.
6
6
 
7
7
  import { keyOf } from './batch-read';
8
+ import { columnFor } from './column';
8
9
  import { valueAt } from './cursor';
9
10
  import { type EntityCore, SOFT_DELETE_COLUMN } from './entity';
10
11
  import { EntityError, invariantViolated } from './errors';
11
12
  import { columnsOf } from './pg-row';
13
+ import type { RowPatch } from './types';
12
14
 
13
15
  /**
14
16
  * Postgres binds at most 65535 parameters in one statement and a multi-row insert spends
@@ -36,7 +38,7 @@ const owns = (row: unknown, property: string): boolean =>
36
38
  */
37
39
  export const namedProperties = <Row>(
38
40
  entity: EntityCore<Row>,
39
- rows: readonly Partial<Row>[],
41
+ rows: readonly RowPatch<Row>[],
40
42
  ): readonly string[] =>
41
43
  Object.keys(entity.$columns).filter((property) => rows.some((row) => owns(row, property)));
42
44
 
@@ -46,7 +48,7 @@ export const insertColumns = <Row>(
46
48
  properties: readonly string[],
47
49
  ): readonly string[] =>
48
50
  properties.flatMap((property) => {
49
- const column = entity.$columns[property];
51
+ const column = columnFor(entity.$columns, property);
50
52
  return column === undefined ? [] : columnsOf(property, column);
51
53
  });
52
54
 
@@ -180,13 +182,13 @@ const sameColumns = (left: readonly string[], right: readonly string[]): boolean
180
182
  */
181
183
  export const upsertPlan = <Row>(
182
184
  entity: EntityCore<Row>,
183
- rows: readonly Partial<Row>[],
185
+ rows: readonly RowPatch<Row>[],
184
186
  onConflict: readonly string[],
185
187
  onMatch: 'update' | 'nothing',
186
188
  ): UpsertPlan => {
187
189
  if (onConflict.length === 0) throw noConflictTarget(entity.$name);
188
190
  for (const property of onConflict) {
189
- if (entity.$columns[property] === undefined) {
191
+ if (columnFor(entity.$columns, property) === undefined) {
190
192
  throw invariantViolated(
191
193
  entity.$name,
192
194
  'upsertAll',
@@ -252,10 +254,10 @@ const cellKey = (kind: string, value: unknown): string | undefined => {
252
254
  export const conflictKeyOf = <Row>(
253
255
  entity: EntityCore<Row>,
254
256
  on: readonly string[],
255
- row: Partial<Row>,
257
+ row: RowPatch<Row>,
256
258
  ): string | undefined => {
257
259
  const cells = on.map((property) =>
258
- cellKey(entity.$columns[property]?.$meta.kind ?? '', valueAt(row, property)),
260
+ cellKey(columnFor(entity.$columns, property)?.$meta.kind ?? '', valueAt(row, property)),
259
261
  );
260
262
  return cells.some((cell) => cell === undefined) ? undefined : JSON.stringify(cells);
261
263
  };
@@ -270,7 +272,7 @@ export const conflictKeyOf = <Row>(
270
272
  export const conflictKeys = <Row>(
271
273
  entity: EntityCore<Row>,
272
274
  plan: UpsertPlan,
273
- rows: readonly Partial<Row>[],
275
+ rows: readonly RowPatch<Row>[],
274
276
  ): readonly (string | undefined)[] => {
275
277
  const keys = rows.map((row) => conflictKeyOf(entity, plan.on, row));
276
278
  if (plan.set.length === 0) return keys;
package/src/clock.ts ADDED
@@ -0,0 +1,18 @@
1
+ // Single responsibility: the instant the entity layer WRITES. One reader of the clock, so
2
+ // `defaultNow()`, `onUpdateNow()`, the soft-delete stamp and a seed's `now` are one value from one
3
+ // source — and a second `systemClock.now()` anywhere on the write path is a timestamp no test can
4
+ // drive and no two of these four can agree with.
5
+
6
+ import { systemClock, tryUseContext } from '@ultimat3/core';
7
+
8
+ /**
9
+ * `ctx.clock`, and the system clock outside every request.
10
+ *
11
+ * The READ path reads no clock at all, which is what makes it drivable (`@ultimat3/query`'s
12
+ * CLAUDE.md says so in as many words). The write path read `systemClock` directly at five sites, so
13
+ * a frozen test clock drove nothing the entity layer stamped: `createdAt`, `updatedAt` and
14
+ * `deletedAt` all came from the wall clock however the ctx was built, and a test asserting on one
15
+ * had to assert a range instead of a value. Outside a request there is no ctx and the system clock
16
+ * IS the answer — a script, a worker boot and a seed all take that branch, exactly as before.
17
+ */
18
+ export const entityNow = (): Date => (tryUseContext()?.clock ?? systemClock).now();
package/src/coalesce.ts CHANGED
@@ -81,7 +81,10 @@ const openBatch = (
81
81
  // mid-flight opens the next batch instead of joining ids already on the wire.
82
82
  queueMicrotask(() => {
83
83
  if (batches.get(key) === batch) batches.delete(key);
84
- void flush(batch);
84
+ // `flush` settles every caller itself, so a rejection escaping it has nobody left to hand it
85
+ // to — and an unhandled rejection ends the Bun process, which turns one bad batch into the
86
+ // whole node. Nothing is swallowed here that a caller was not already given.
87
+ void flush(batch).catch(() => undefined);
85
88
  });
86
89
  return batch;
87
90
  };
@@ -89,23 +92,34 @@ const openBatch = (
89
92
  const flush = async (batch: Batch): Promise<void> => {
90
93
  const waiting = [...batch.pending.values()];
91
94
  batch.pending.clear();
92
- // One statement at a time: a batch wide enough to split must not take the pool with it.
93
- for (const chunk of statementChunks(waiting)) {
94
- try {
95
- const answers = await batch.load(chunk.map((entry) => entry.id));
96
- for (const entry of chunk) {
97
- const answer = answers.get(entry.key);
98
- // An id the statement did not answer for is a row that is not there — `findById`'s `null`,
99
- // never a rejection, and never another caller's row.
100
- if (answer === undefined) entry.settle(null);
101
- else if ('error' in answer) entry.fail(answer.error);
102
- else entry.settle(answer.row);
95
+ try {
96
+ // One statement at a time: a batch wide enough to split must not take the pool with it.
97
+ for (const chunk of statementChunks(waiting)) {
98
+ try {
99
+ const answers = await batch.load(chunk.map((entry) => entry.id));
100
+ for (const entry of chunk) {
101
+ const answer = answers.get(entry.key);
102
+ // An id the statement did not answer for is a row that is not there — `findById`'s
103
+ // `null`, never a rejection, and never another caller's row.
104
+ if (answer === undefined) entry.settle(null);
105
+ else if ('error' in answer) entry.fail(answer.error);
106
+ else entry.settle(answer.row);
107
+ }
108
+ } catch (error) {
109
+ // The statement failed, so everyone in it gets the failure the single statement would
110
+ // have handed them. Every one was returned to a caller, so none goes unhandled.
111
+ for (const entry of chunk) entry.fail(error);
103
112
  }
104
- } catch (error) {
105
- // The statement failed, so everyone in it gets the failure the single statement would have
106
- // handed them. Every one of these promises was returned to a caller, so none goes unhandled.
107
- for (const entry of chunk) entry.fail(error);
108
113
  }
114
+ } catch (error) {
115
+ // Every promise in `waiting` was handed to a caller before this batch was ever scheduled, so
116
+ // anything escaping the loop above — `statementChunks` itself, or whatever a later edit puts
117
+ // beside it — would leave them awaiting a row that can no longer arrive. Unsettled forever is
118
+ // strictly worse than failed: a rejection is a stack trace, a hang is a request that never
119
+ // answers. Failing an entry an earlier chunk already settled is a no-op, so this is safe over
120
+ // the whole list. `jit-preload.ts` gets the same property by construction, settling with an
121
+ // `Answer` rather than a rejection; this path has a real promise per caller and cannot.
122
+ for (const entry of waiting) entry.fail(error);
109
123
  }
110
124
  };
111
125
 
package/src/column.ts CHANGED
@@ -7,11 +7,66 @@
7
7
  // physical name even though two schema modules import each other in a cycle.
8
8
 
9
9
  import { invariantViolated } from './errors';
10
- import type { AnyColumn, Column, ColumnDefault, ColumnMeta, TimestampColumn } from './types';
10
+ import type {
11
+ AnyColumn,
12
+ Column,
13
+ ColumnDefault,
14
+ ColumnMap,
15
+ ColumnMeta,
16
+ MoneyColumnNames,
17
+ TimestampColumn,
18
+ } from './types';
19
+
20
+ /**
21
+ * The column a NAME resolves to, or `undefined` — the ONE read of a column map by a name that came
22
+ * from outside it.
23
+ *
24
+ * `columns[property]` alone walks `Object.prototype`: an app's column map is a plain object
25
+ * literal, so `columns['constructor']` answers the `Object` FUNCTION, every `=== undefined` guard
26
+ * downstream passes, and the next `.$meta.kind` is a bare `TypeError` where the caller was owed
27
+ * `X_INVARIANT_VIOLATED` naming the columns that do exist. The name is caller data on every path
28
+ * that reaches here — a predicate column, a sort key, an `onConflict` target, a `select` list — so
29
+ * the discriminator lives in one place. Same read `tenancy.ts` already does for the tenant column.
30
+ */
31
+ export const columnFor = (columns: ColumnMap, property: string): AnyColumn | undefined =>
32
+ Object.hasOwn(columns, property) ? columns[property] : undefined;
11
33
 
12
34
  export const snake = (value: string): string =>
13
35
  value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
14
36
 
37
+ /**
38
+ * The physical column, decided in ONE place: what `.column()` declared, else `snake(property)`.
39
+ *
40
+ * Every projection reads it here — the DDL, the binding, the decoder, the invariant resolver, the
41
+ * index names — because a second `snake(property)` anywhere is a statement naming a column the
42
+ * table does not have, and the first table that proves it is somebody's production database.
43
+ */
44
+ export const columnName = (property: string, meta: ColumnMeta): string =>
45
+ meta.name ?? snake(property);
46
+
47
+ /** Money's three physical columns, resolved. `scale: null` is a table that has no scale column. */
48
+ export interface MoneyColumns {
49
+ readonly minor: string;
50
+ readonly currency: string;
51
+ readonly scale: string | null;
52
+ }
53
+
54
+ /**
55
+ * Per part, merged over the `<base>_minor` / `<base>_currency` / `<base>_scale` defaults — so a
56
+ * table that renamed one of the three does not have to restate the other two, and `.column()`
57
+ * moves the base for all of them at once.
58
+ */
59
+ export const moneyColumns = (property: string, meta: ColumnMeta): MoneyColumns => {
60
+ const base = columnName(property, meta);
61
+ const declared: MoneyColumnNames = meta.parts ?? {};
62
+ return {
63
+ minor: declared.minor ?? `${base}_minor`,
64
+ currency: declared.currency ?? `${base}_currency`,
65
+ // `undefined` takes the default; `null` is the caller saying the column is not there at all.
66
+ scale: declared.scale === undefined ? `${base}_scale` : declared.scale,
67
+ };
68
+ };
69
+
15
70
  export const GENERATED_UUID: ColumnDefault = { kind: 'generated', by: 'uuid-v7' };
16
71
  export const GENERATED_NOW: ColumnDefault = { kind: 'generated', by: 'now' };
17
72
 
@@ -49,7 +104,7 @@ export const bindColumn = (column: AnyColumn, table: string, property: string):
49
104
  'build a new column instead of sharing one between entities',
50
105
  );
51
106
  }
52
- const binding: Binding = { table, property, name: snake(property) };
107
+ const binding: Binding = { table, property, name: columnName(property, column.$meta) };
53
108
  bindings.set(column, binding);
54
109
  return binding;
55
110
  };
@@ -135,8 +190,30 @@ export const makeColumn = <T, Optional extends boolean>(
135
190
  ),
136
191
 
137
192
  default: (value) => makeColumn<T, true>({ ...meta, default: literal(value) }, parse, true),
193
+
194
+ column: (name) =>
195
+ makeColumn<T, Optional>({ ...meta, name: assertColumnName(name) }, parse, optional),
138
196
  });
139
197
 
198
+ /**
199
+ * A physical name is spliced into DDL and into every statement as a quoted identifier, so it is
200
+ * checked where it is written rather than trusted there: an empty name produces `""`, and a name
201
+ * carrying a quote or a newline is the one value in a column declaration that could close the
202
+ * identifier. `[a-z_][a-z0-9_$]*`, which is what an unquoted Postgres identifier may be, and the
203
+ * bound is the same 63 bytes the server truncates at — a longer one silently addresses a
204
+ * different column.
205
+ */
206
+ export const assertColumnName = (name: string): string => {
207
+ if (!/^[a-z_][a-z0-9_$]*$/.test(name) || name.length > 63) {
208
+ throw invariantViolated(
209
+ 'column',
210
+ 'column-name',
211
+ `"${name}" is not a physical column name: lower-case letters, digits and underscores, at most 63 of them`,
212
+ );
213
+ }
214
+ return name;
215
+ };
216
+
140
217
  export const column = <T>(
141
218
  kind: ColumnMeta['kind'],
142
219
  parse: (value: unknown) => T,
@@ -155,4 +232,7 @@ export const makeTimestamp = <Optional extends boolean>(
155
232
  ...makeColumn<Date, Optional>(meta, parse, optional),
156
233
  defaultNow: () => makeTimestamp({ ...meta, default: GENERATED_NOW }, parse, true),
157
234
  onUpdateNow: () => makeTimestamp({ ...meta, onUpdate: GENERATED_NOW }, parse, optional),
235
+ // Overridden so `timestamp().column('created').defaultNow()` still has `defaultNow` — the
236
+ // general link returns the general column, and a builder with methods of its own keeps them.
237
+ column: (name) => makeTimestamp({ ...meta, name: assertColumnName(name) }, parse, optional),
158
238
  });