@ultimat3/entity 3.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
@@ -74,9 +106,15 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
74
106
  calls `forgetPreloaded(entity.$name)` *before* the statement, so a row a request changed is
75
107
  re-read and never served from a page read before it. **Values, not rows**: the index is keyed by
76
108
  id and holds ids, so a page early in a long request pins its keys and not its rows, and it dies
77
- with the request like every other per-ctx store here. **Declining is the old behaviour**: no
78
- request in scope, an id no page indexed, a key that resolved to nothing the caller reads the
79
- 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
80
118
  batch. What both share — the scope key, `keyOf`, the one `in` statement — lives in
81
119
  `batch-read.ts` so the two can never disagree about when a shared statement is legal.
82
120
  **One switch, where the driver is built**: `postgresDriver({ jitPreload: false })` /
@@ -282,7 +320,12 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
282
320
  rendered from it.** The resolved records are the source; `ColumnDescription.references` spells
283
321
  `"<table>.<column>"` out of one for the migration generator, which is in tier 1 and cannot
284
322
  import this package. Never parse that string back — it carries physical names and a traversal
285
- 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
286
329
  method, not a field: a thunk may point at an entity two modules of an import cycle have not
287
330
  finished evaluating. `relationMap()` memoises the whole-registry derivation against
288
331
  `registryGeneration()`, which every registration bumps — a schema module imported late must
@@ -368,6 +411,16 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
368
411
  the three callers want three answers and the wrong one is invisible in the result. The soft
369
412
  delete inside `removal()` passes `false` too — both its callers read a count through
370
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.
371
424
  - **`touch()` in `query.ts` is the ONE place `onUpdateNow()` columns are stamped**, for
372
425
  `update(id, patch)` and `updateWhere(filter, patch)` alike — a second copy is how one of them
373
426
  ends up writing a stale `updatedAt`. It returns an empty patch untouched, so whether
@@ -664,13 +717,15 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
664
717
 
665
718
  | File | Job |
666
719
  |---|---|
667
- | `types.ts` | `Column`, `RowOf`, `Insertable`, `IdOf` — the type derivation |
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 |
668
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 |
669
722
  | `columns-data.ts` | the wide vocabulary an existing schema needs: `json`, `decimal`, `date`, `bigint`, `bytes`, `arrayOf` |
670
723
  | `expr.ts` / `invariants.ts` | the `invariants: (c) => …` rule language; bind + `toSql()` DDL |
671
724
  | `entity.ts` / `describe.ts` | `entity()`, `$row`; the `EntityDescription` projection |
672
725
  | `view.ts` | `$view(keys)` — the row projection an action names as its `output` |
673
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` |
674
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 |
675
730
  | `cross-tenant.ts` | `crossTenant(reason, fn)` — the capability-gated scope that lifts it |
676
731
  | `plan.ts` / `cursor.ts` | the plan both drivers execute; the one keyset cursor codec |
package/README.md CHANGED
@@ -381,6 +381,13 @@ database({ orgs, posts }, { driver: postgresDriver() }); // production
381
381
  | For | tests, `x dev` before the first migration | production |
382
382
  | Transaction | `memoryTransactor()` — undo closures | `postgresTransactor()` — real `BEGIN`/`COMMIT` |
383
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).
384
391
 
385
392
  `database()` called with no driver takes the process default, and `defaultDriver()` is that same
386
393
  object — the one seam a test harness needs, `As of 2026-08`:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "3.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,9 +31,9 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "3.0.0",
35
- "@ultimat3/db": "3.0.0",
36
- "@ultimat3/schema": "3.0.0",
37
- "@ultimat3/time": "3.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"
38
38
  }
39
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/column.ts CHANGED
@@ -11,11 +11,26 @@ import type {
11
11
  AnyColumn,
12
12
  Column,
13
13
  ColumnDefault,
14
+ ColumnMap,
14
15
  ColumnMeta,
15
16
  MoneyColumnNames,
16
17
  TimestampColumn,
17
18
  } from './types';
18
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;
33
+
19
34
  export const snake = (value: string): string =>
20
35
  value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
21
36
 
package/src/count-by.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // a `countBy` against Postgres means; a rule added to one driver alone is the drift this file
5
5
  // exists to prevent.
6
6
 
7
+ import { columnFor } from './column';
7
8
  import type { EntityCore } from './entity';
8
9
  import { EntityError } from './errors';
9
10
  import type { AnyColumn, ColumnKind } from './types';
@@ -86,7 +87,7 @@ export const groupColumnOf = <Row>(
86
87
  property: string,
87
88
  operation: string,
88
89
  ): AnyColumn => {
89
- const column = entity.$columns[property];
90
+ const column = columnFor(entity.$columns, property);
90
91
  if (column === undefined) {
91
92
  throw notGroupable(
92
93
  entity,
package/src/cursor.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  // to still exist, and a row deleted between two pages would silently restart pagination.
8
8
 
9
9
  import { CursorInvalidError, decodeCursor, encodeCursor } from '@ultimat3/core';
10
+ import { columnFor } from './column';
10
11
  import type { EntityCore } from './entity';
11
12
  import { invariantViolated } from './errors';
12
13
  import type { QueryPlan } from './tenancy';
@@ -28,7 +29,7 @@ const partsOf = (path: string): { readonly property: string; readonly part?: str
28
29
  };
29
30
 
30
31
  const columnAt = <Row>(entity: EntityCore<Row>, path: string): AnyColumn => {
31
- const column = entity.$columns[partsOf(path).property];
32
+ const column = columnFor(entity.$columns, partsOf(path).property);
32
33
  if (column === undefined) {
33
34
  throw invariantViolated(entity.$name, 'orderBy', `no column "${path}"`);
34
35
  }
@@ -51,13 +52,33 @@ const kindAt = <Row>(entity: EntityCore<Row>, path: string): ColumnKind => {
51
52
  `${path} is money: order by ${path}.minor or ${path}.currency`,
52
53
  );
53
54
  }
54
- const money = kind === 'money' ? MONEY_PARTS[part] : undefined;
55
+ // `MONEY_PARTS[part]` alone answers a FUNCTION for `orderBy('price.toString')` not
56
+ // `undefined` — so the refusal below never fired and `assertSeekable` minted a cursor for it.
57
+ const money =
58
+ kind === 'money' && Object.hasOwn(MONEY_PARTS, part) ? MONEY_PARTS[part] : undefined;
55
59
  if (money === undefined) {
56
60
  throw invariantViolated(entity.$name, 'orderBy', `${path} names no column part`);
57
61
  }
58
62
  return money;
59
63
  };
60
64
 
65
+ /**
66
+ * The kind a PATH holds, or `undefined` when it names none — the non-throwing half of `kindAt`.
67
+ *
68
+ * The in-memory driver asks this about a predicate column and a sort key, both of which are caller
69
+ * data: an unknown name compares as text there exactly as it always did, rather than turning a
70
+ * filter into a refusal the Postgres driver does not make. It is what lets a comparison be decided
71
+ * by the column's DECLARED kind — which is what Postgres decides by — instead of by the JS type of
72
+ * whichever value is in hand.
73
+ */
74
+ export const kindOf = <Row>(entity: EntityCore<Row>, path: string): ColumnKind | undefined => {
75
+ const { property, part } = partsOf(path);
76
+ const kind = columnFor(entity.$columns, property)?.$meta.kind;
77
+ if (kind === undefined) return undefined;
78
+ if (part === undefined) return kind === 'money' ? undefined : kind;
79
+ return kind === 'money' && Object.hasOwn(MONEY_PARTS, part) ? MONEY_PARTS[part] : undefined;
80
+ };
81
+
61
82
  export const valueAt = (row: unknown, path: string): unknown => {
62
83
  const { property, part } = partsOf(path);
63
84
  const record = typeof row === 'object' && row !== null ? (row as Record<string, unknown>) : {};
package/src/describe.ts CHANGED
@@ -48,6 +48,7 @@ export const describeReferences = (
48
48
  targetEntity: target.table,
49
49
  targetProperty: target.property,
50
50
  targetColumn: target.name,
51
+ onDelete: meta.onDelete ?? null,
51
52
  },
52
53
  ];
53
54
  });
@@ -90,6 +91,7 @@ const describeColumn = <Row>(
90
91
  unique: false,
91
92
  hasDefault: false,
92
93
  references: null,
94
+ onDelete: null,
93
95
  };
94
96
  return [
95
97
  {
@@ -142,6 +144,9 @@ const describeColumn = <Row>(
142
144
  // traversal reads can never disagree about what a `references()` points at.
143
145
  references:
144
146
  reference === undefined ? null : `${reference.targetEntity}.${reference.targetColumn}`,
147
+ // Off the resolved reference, never off `meta` again: a rule with no key is not a thing, and
148
+ // reading the option twice is two places for the pair to disagree.
149
+ onDelete: reference?.onDelete ?? null,
145
150
  },
146
151
  ];
147
152
  };
package/src/entity.ts CHANGED
@@ -3,8 +3,8 @@
3
3
  // (the typed db handle, migrations, cache tags, the admin UI, the manifest) is projected from
4
4
  // this one call.
5
5
 
6
- import { systemClock } from '@ultimat3/core';
7
6
  import { describeValue, type StandardSchemaV1 } from '@ultimat3/schema';
7
+ import { entityNow } from './clock';
8
8
  import { assertColumnName, bindColumn, columnName, moneyColumns } from './column';
9
9
  import { newId } from './columns';
10
10
  import { describeEntity, describeReferences } from './describe';
@@ -115,7 +115,7 @@ const defaultValue = (meta: ColumnMeta): unknown => {
115
115
  const declared = meta.default;
116
116
  if (declared === undefined) return undefined;
117
117
  if (declared.kind === 'value') return declared.value;
118
- return declared.by === 'uuid-v7' ? newId() : systemClock.now();
118
+ return declared.by === 'uuid-v7' ? newId() : entityNow();
119
119
  };
120
120
 
121
121
  export const entity = <const C extends ColumnMap>(
package/src/index.ts CHANGED
@@ -101,6 +101,7 @@ export {
101
101
  export type { EntityRelations, Relation, RelationKind, RelationMap } from './relations';
102
102
  export { relationMap, relationNamed, relationsFor, relationsOf } from './relations';
103
103
  export type {
104
+ FindByIdOptions,
104
105
  FindManyArgs,
105
106
  MemoryRepo,
106
107
  Page,
@@ -152,9 +153,11 @@ export type {
152
153
  OnDelete,
153
154
  ReferenceOptions,
154
155
  RowOf,
156
+ RowPatch,
155
157
  TimestampColumn,
156
158
  TypeOf,
157
159
  UuidColumn,
158
160
  } from './types';
161
+ export { COLUMN_KINDS } from './types';
159
162
  // `viewFor` stays internal: a view is reached through the entity, as `posts.$view([...])`.
160
163
  export type { EntityView } from './view';
@@ -5,7 +5,9 @@
5
5
  //
6
6
  // The trigger carries an id, not a row, so what a page leaves behind is an index of its foreign
7
7
  // key VALUES rather than a map keyed by row identity: an id is a thing that can be looked up in
8
- // it, and it holds values, so it pins no rows for the request's lifetime.
8
+ // it, and a page therefore costs its keys rather than its rows. That was true PER PAGE and false
9
+ // across them until `MAX_SIBLING_KEYS` — the store outlives every page and dies with the ctx,
10
+ // which for a job is the whole attempt, so both maps here are bounded and evict the oldest page.
9
11
  //
10
12
  // The scope guard is a security boundary, not a tuning knob. A preloaded row is served only to a
11
13
  // lookup with the same scope key, the same client and no write since — anything else reads the
@@ -14,7 +16,15 @@
14
16
  import type { Ctx } from '@ultimat3/core';
15
17
  import { tryUseContext } from '@ultimat3/core';
16
18
  import type { DbClient } from '@ultimat3/db';
17
- import { type Answer, keyOf, type PointRead, readByIds, statementChunks } from './batch-read';
19
+ import {
20
+ type Answer,
21
+ keyOf,
22
+ MAX_IDS_PER_STATEMENT,
23
+ type PointRead,
24
+ readByIds,
25
+ statementChunks,
26
+ } from './batch-read';
27
+ import { columnFor } from './column';
18
28
  import type { EntityCore } from './entity';
19
29
 
20
30
  /** The rows one page's worth of foreign keys resolved to, under one scope. */
@@ -53,10 +63,54 @@ const storeFor = (ctx: Ctx): Store => {
53
63
  return created;
54
64
  };
55
65
 
66
+ /**
67
+ * How many id keys ONE edge may hold, and how many rows one bucket may keep — a few pages' worth,
68
+ * the way `MAX_IDS_PER_STATEMENT` bounds a statement.
69
+ *
70
+ * `MAX_IDS_PER_STATEMENT` bounded the statement and nothing bounded the STORE: every page merged
71
+ * its keys in and the store died only with the ctx, which for a job is the whole attempt. Measured
72
+ * at 1,000 pages x 1,000 rows with distinct foreign keys, rows dropped after each call and
73
+ * `Bun.gc(true)` either side: **159.3 MB retained**, against 2.7 MB with the tagging off — so a
74
+ * 12M-row `backfill()` retains ~2 GB and OOMs the worker on the DEFAULT configuration, since
75
+ * `jitPreload` defaults to true and `backfill()` names no driver option.
76
+ *
77
+ * Four statements' worth. The keys of one page are filed contiguously, so the survivors are the
78
+ * newest pages' and the arrays every evicted key referenced go with them — which is what makes the
79
+ * bound a bound on bytes and not only on entries. Past it a lookup DECLINES, and declining is the
80
+ * old behaviour everywhere else in this file: the caller reads the statement it always read.
81
+ */
82
+ export const MAX_SIBLING_KEYS = MAX_IDS_PER_STATEMENT * 4;
83
+
84
+ /**
85
+ * Newest wins, oldest goes. A `Map` iterates in insertion order, so its first key is the oldest
86
+ * page's — and the page a sequential `for … of` loop is walking is the newest one, which is the
87
+ * only page this store exists to answer for. Re-filed rather than overwritten, so a key a later
88
+ * page carries again moves to the newest end instead of ageing out under it.
89
+ */
90
+ const remember = <V>(index: Map<string, V>, key: string, value: V, cap: number): void => {
91
+ index.delete(key);
92
+ index.set(key, value);
93
+ while (index.size > cap) {
94
+ const oldest = index.keys().next();
95
+ if (oldest.done === true) return;
96
+ index.delete(oldest.value);
97
+ }
98
+ };
99
+
56
100
  /** Both ends of the edge: a key pointing at another column of the same entity is another edge. */
57
101
  const siblingKey = (targetEntity: string, targetProperty: string): string =>
58
102
  JSON.stringify([targetEntity, targetProperty]);
59
103
 
104
+ /** TEST SEAM: id keys this request is holding, across every edge. A bound nothing can observe is a
105
+ * bound nothing can pin, and `MAX_SIBLING_KEYS` is the number this answers against. */
106
+ export const siblingKeysHeld = (ctx: Ctx): number => {
107
+ const store = requests.get(ctx);
108
+ if (store === undefined) return 0;
109
+ let held = 0;
110
+ for (const index of store.siblings.values()) held += index.size;
111
+ return held;
112
+ };
113
+
60
114
  const writesTo = (store: Store, entity: string): number => store.writes.get(entity) ?? 0;
61
115
 
62
116
  /**
@@ -80,7 +134,7 @@ export const tagSiblings = <Row>(entity: EntityCore<Row>, rows: readonly Row[]):
80
134
  for (const reference of references) {
81
135
  // The declaring column's own kind: a foreign key mirrors the key it points at, and a value is
82
136
  // filed here exactly as `findById` will spell it when it comes looking.
83
- const kind = entity.$columns[reference.property]?.$meta.kind;
137
+ const kind = columnFor(entity.$columns, reference.property)?.$meta.kind;
84
138
  if (kind === undefined) continue;
85
139
  const ids: unknown[] = [];
86
140
  const keys = new Set<string>();
@@ -96,7 +150,7 @@ export const tagSiblings = <Row>(entity: EntityCore<Row>, rows: readonly Row[]):
96
150
  if (ids.length === 0) continue;
97
151
  const at = siblingKey(reference.targetEntity, reference.targetProperty);
98
152
  const index = store.siblings.get(at) ?? new Map<string, readonly unknown[]>();
99
- for (const key of keys) index.set(key, ids);
153
+ for (const key of keys) remember(index, key, ids, MAX_SIBLING_KEYS);
100
154
  store.siblings.set(at, index);
101
155
  }
102
156
  };
@@ -165,12 +219,14 @@ const preload = <Row>(read: PointRead<Row>, bucket: Bucket, ids: readonly unknow
165
219
  if (bucket.rows.has(at)) continue;
166
220
  // The executor runs synchronously, so `settle` is assigned before the promise is stored.
167
221
  let settle!: (answer: Answer) => void;
168
- bucket.rows.set(
169
- at,
170
- new Promise<Answer>((resolve) => {
171
- settle = resolve;
172
- }),
173
- );
222
+ const answer = new Promise<Answer>((resolve) => {
223
+ settle = resolve;
224
+ });
225
+ // Bounded for the reason the sibling index is: a bucket holds ROWS, so a long request that
226
+ // preloads page after page retains every row it ever resolved. An evicted entry still settles
227
+ // — `fill` holds its own settler — and a lookup that no longer finds one reads its own
228
+ // statement, which is what it would have read had no page indexed the id at all.
229
+ remember(bucket.rows, at, answer, MAX_SIBLING_KEYS);
174
230
  settlers.set(at, settle);
175
231
  wanted.push(id);
176
232
  }