@ultimat3/entity 31.2.0 → 31.4.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
@@ -101,7 +101,7 @@ A rule marked `†` has its full text in [`docs/history/entity-rules.md`](../../
101
101
  - **Invariants run twice and only ONE side is rendered here**: app-side `assertInvariants`, and the
102
102
  CHECK/UNIQUE DDL is `@ultimat3/db`'s `invariant-ddl.ts` reading `$describe()`. An untranslatable predicate is `kind: 'assert'`, `sql: null`.
103
103
  **`InvariantDescription.columns` is projected** (`describe-invariant.test.ts`).
104
- - **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`.
104
+ - **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. **NULL is judged in three values in both** (`expr-verdict.ts`): an operand NULL is unknown, a rule holds unless false, `isNull`/`isNotNull` total (`invariant-null.test.ts`, 31.3). Full rule: `docs/history/entity.md`.
105
105
  - **A `matches()` pattern reaches the CHECK as the same string or is refused**
106
106
  (`pattern-portability.ts`, every entry measured; `pg-invariant-pattern.live.test.ts` asserts kept
107
107
  constructs agree and refused ones disagree).
@@ -186,7 +186,8 @@ A rule marked `†` has its full text in [`docs/history/entity-rules.md`](../../
186
186
  | Declaration | Here | The rule |
187
187
  |---|---|---|
188
188
  | `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` |
189
- | `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`) |
189
+ | `history: true` · `{ ignore }` | `history.ts`, `history-actor.ts` | `<table>_history` is a REAL registered entity (`$historyOf`), tenant column copied without its key. `ignore` (typed column names, refused otherwise) joins the counters in `$describe().history.ignore` — the trigger's, memory's (`historyIgnored`) and `history-order`'s one list. 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`) |
190
+ | `history(table, id)` | `history-read.ts`, `history-order.ts` | the history table's ordinary read (tenant scope, `inBatches`), then ONE ordering: the before→after walk (Hierholzer) across EVERY version — `at` is a transaction's start, so it only breaks ties — counted columns left out; no single walk: by `at`, the walk inside each instant. `{ limit }`: newest-first window, ties at its edge kept, numbered via `count`. `version` is a position, never stored — no schema change. `history-read.test.ts` (memory + PGlite), `.live.` |
190
191
  | `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()` |
191
192
  | all three, described | `lifecycle.ts` | each `$describe()` key ABSENT unless declared, so no other entity's hash moves |
192
193
  | the memory driver | `memory-lifecycle.ts` | mirrors both triggers through the table's raw store |
package/README.md CHANGED
@@ -812,11 +812,38 @@ Three declarations, each a trigger or a purge target `x db gen` and `purge()` co
812
812
  | Declaration | Does |
813
813
  |---|---|
814
814
  | `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`) |
815
- | `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 |
815
+ | `history: true` · `{ retain: { after }, ignore: ['likeCount', 'updatedAt'] }` | `<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. A change to `ignore`d columns alone (typed: the entity's own columns) records nothing, as a counter's does; a name that is no column is `X_INVARIANT_VIOLATED`. Its repository refuses every rewrite (`X_ENTITY_APPEND_ONLY`), the table too, except a retention sweep's delete |
816
816
  | `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 |
817
817
 
818
818
  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.
819
819
 
820
+ Reading one row's history is `history(table, id)` — the history table's own read, so tenant-scoped like any other, oldest first, each row numbered `version` from 1:
821
+
822
+ ```ts
823
+ import { database, entity, history, invariantViolated, text, uuid } from '@ultimat3/entity';
824
+
825
+ const posts = entity('posts', {
826
+ columns: { id: uuid().primaryKey(), orgId: uuid().tenant(), title: text() },
827
+ history: true,
828
+ });
829
+ const postsHistory = posts.$history;
830
+ if (postsHistory === null) throw invariantViolated('posts', 'history', 'declare history: true');
831
+ const db = database({ posts, postsHistory });
832
+
833
+ export const versionsOf = (postId: string) => history(db.postsHistory, postId);
834
+ // [{ version: 1, op: 'insert', at, actorId, requestId, before, after, … }, …]
835
+ ```
836
+
837
+ | Rule | Why |
838
+ |---|---|
839
+ | Ordered by chaining each `before` onto the previous `after`, across every version; `at` only breaks ties | `at` is the transaction's START (`now()`): a transaction that began first and committed last carries the earlier `at` and the later image. Delete-then-reinsert and A→B→A chain too; an image revisited across transactions admits more than one walk, and the earliest-`at` edge goes first. No single walk (a purged middle): by `at` (`history-order.ts`) |
840
+ | `history(table, id, { limit: n })` | the newest `n` versions, oldest first, numbered as the whole history numbers them (one `count` beside the read). Read newest-first and stopped there, every tie at the edge included; chosen by `at`, so a transaction that began before the window and committed inside it can sit outside it. `limit` is a page size: 1 to `MAX_PAGE_SIZE`, else `X_INVARIANT_VIOLATED` |
841
+ | `version` is a position in what is RETAINED | a `retain` sweep removes the oldest; version 1 is the oldest kept, not a stored number |
842
+ | Another tenant reads `[]` | it is the history entity's read (`$tenantColumn` copied from the parent); who may read it at all is the calling query's or action's policy |
843
+ | A counted column is left out of the chain | its change alone records nothing, so the next `before` differs from the previous `after` there |
844
+
845
+ `history-read.test.ts` (memory and PGlite), `history-read.live.test.ts` (a server), `history-order.test.ts`.
846
+
820
847
  ## Partitioned tables — `partitionBy`
821
848
 
822
849
  A range by one `timestamp()`/`date()` column, one partition per `interval`, bounds in UTC. `As of 2026-11-09`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "31.2.0",
3
+ "version": "31.4.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -44,9 +44,9 @@
44
44
  "test": "bun test"
45
45
  },
46
46
  "dependencies": {
47
- "@ultimat3/core": "31.2.0",
48
- "@ultimat3/db": "31.2.0",
49
- "@ultimat3/schema": "31.2.0",
50
- "@ultimat3/time": "31.2.0"
47
+ "@ultimat3/core": "31.4.0",
48
+ "@ultimat3/db": "31.4.0",
49
+ "@ultimat3/schema": "31.4.0",
50
+ "@ultimat3/time": "31.4.0"
51
51
  }
52
52
  }
package/src/adapter.ts CHANGED
@@ -29,6 +29,7 @@ export { DRIVER_CAPABILITIES } from './driver-kit-types';
29
29
  export type { DriverStoreKind } from './driver-registry';
30
30
  export { clearDriverStores, driverStores, noteDriverStore } from './driver-registry';
31
31
  export { afterSeek, projectRow, selectRows } from './driver-rows';
32
+ export { memoryLockWaiters } from './memory-lock';
32
33
  export { compareByKind, matchesPredicate, sameValueOfKind } from './memory-match';
33
34
  export type { PostgresDriverOptions } from './pg-driver';
34
35
  export {
package/src/entity.ts CHANGED
@@ -127,7 +127,7 @@ export interface EntityInit<C extends ColumnMap> {
127
127
  /** Rows older than `after` by `on` are swept by `purge({ entities: 'declared' })` (`retain.ts`). */
128
128
  readonly retain?: RetainInit<keyof C & string>;
129
129
  /** Every row change recorded into `<table>_history` by trigger (`history.ts`, `$history`). */
130
- readonly history?: HistoryInit;
130
+ readonly history?: HistoryInit<keyof C & string>;
131
131
  /**
132
132
  * Range-partitioned by a `timestamp()`/`date()` in the primary key, one partition per `interval`
133
133
  * (`partition.ts`). `x db gen` writes `partition by range` and the first partitions; a scheduled
@@ -0,0 +1,44 @@
1
+ // Single responsibility: how an invariant DECIDES a row, in SQL's three values. Each operator over a
2
+ // NULL operand answers UNKNOWN, `iff` composes unknowns, and a rule holds unless it is false — what
3
+ // a CHECK does with the same row, so the app and the database never disagree about a NULL.
4
+
5
+ import type { Expr, Resolve, Row } from './expr';
6
+ import { isNullish } from './is-null';
7
+
8
+ /** `null` is SQL's UNKNOWN: an operator over a NULL operand answers it, and a CHECK passes on it. */
9
+ export type Verdict = (row: Row) => boolean | null;
10
+
11
+ /** Each rule built here, judged in three values — what an `iff` composes, before CHECK collapses it. */
12
+ const verdicts = new WeakMap<Expr, Verdict>();
13
+
14
+ /** A rule's three-valued verdict; one built elsewhere (structurally) is two-valued. */
15
+ export const verdictOf = (rule: Expr): Verdict => verdicts.get(rule) ?? ((row) => rule.holds(row));
16
+
17
+ /**
18
+ * A rule holds unless it is FALSE — the CHECK's own reading, so a NULL the database stores is a NULL
19
+ * the app accepts. `matches(/…/)` on a nullable column refused NULL here while its CHECK stored it.
20
+ */
21
+ export const check = (
22
+ paths: readonly (readonly string[])[],
23
+ message: string,
24
+ sql: (resolve: Resolve) => string | null,
25
+ verdict: Verdict,
26
+ ): Expr => {
27
+ const built: Expr = {
28
+ kind: 'check',
29
+ paths,
30
+ message,
31
+ toSql: sql,
32
+ holds: (row) => verdict(row) !== false,
33
+ };
34
+ verdicts.set(built, verdict);
35
+ return built;
36
+ };
37
+
38
+ /** Unknown when the operand is NULL — every operator but `is [not] null`, which is total. */
39
+ export const unknownOnNull =
40
+ (read: (row: Row) => unknown, holds: (value: unknown) => boolean): Verdict =>
41
+ (row) => {
42
+ const value = read(row);
43
+ return isNullish(value) ? null : holds(value);
44
+ };
package/src/expr.ts CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { literal as sqlLiteral } from '@ultimat3/db';
16
16
  import { invariantViolated } from './errors';
17
+ import { check, unknownOnNull, verdictOf } from './expr-verdict';
17
18
  import { isNullish } from './is-null';
18
19
  import { DECIMAL_TEXT, numericOrder } from './numeric-compare';
19
20
  import { unportableConstruct } from './pattern-portability';
@@ -74,8 +75,9 @@ export interface ColumnExpr {
74
75
  * `col is null` / `col is not null`, and the only pair in this vocabulary that is TOTAL over
75
76
  * NULL in both halves: Postgres' `IS NULL` answers true or false for every input including NULL,
76
77
  * and the app side reads absent and `null` as one value (`is-null.ts`). Every other operator
77
- * here answers NULL in SQL for a NULL operand, and a CHECK PASSES on NULL — which is why these
78
- * two are what an `iff` can be built out of.
78
+ * here answers NULL (unknown) for a NULL operand in BOTH halves, and a rule passes on unknown as
79
+ * a CHECK does — so a nullable column's rule never refuses NULL. Refusing NULL is `.isNotNull()`
80
+ * (or a NOT NULL column), never a side effect of another operator.
79
81
  */
80
82
  isNull(): Expr;
81
83
  isNotNull(): Expr;
@@ -174,13 +176,6 @@ const patternSql = (pattern: RegExp): string => {
174
176
  return `${operator} ${literal(pattern.source)}`;
175
177
  };
176
178
 
177
- const check = (
178
- paths: readonly (readonly string[])[],
179
- message: string,
180
- sql: (resolve: Resolve) => string | null,
181
- holds: (row: Row) => boolean,
182
- ): Expr => ({ kind: 'check', paths, message, toSql: sql, holds });
183
-
184
179
  /**
185
180
  * A numeric operand a CHECK can spell. `NaN` and the infinities render as `col >= NaN` — a column
186
181
  * reference Postgres does not have — so they are refused on the line that wrote them.
@@ -204,6 +199,12 @@ const expr = (term: Term): ColumnExpr => {
204
199
  message: string,
205
200
  sql: (resolve: Resolve) => string | null,
206
201
  holds: (value: unknown) => boolean,
202
+ ): Expr => check([term.path], message, sql, unknownOnNull(term.read, holds));
203
+ /** `is null` / `is not null`: decided for every input, NULL included. */
204
+ const total = (
205
+ message: string,
206
+ sql: (resolve: Resolve) => string | null,
207
+ holds: (value: unknown) => boolean,
207
208
  ): Expr => check([term.path], message, sql, (row) => holds(term.read(row)));
208
209
 
209
210
  const built: ColumnExpr = {
@@ -289,10 +290,10 @@ const expr = (term: Term): ColumnExpr => {
289
290
  ),
290
291
 
291
292
  isNull: () =>
292
- one(`${term.label} is not set`, (resolve) => `${term.sql(resolve)} is null`, isNullish),
293
+ total(`${term.label} is not set`, (resolve) => `${term.sql(resolve)} is null`, isNullish),
293
294
 
294
295
  isNotNull: () =>
295
- one(
296
+ total(
296
297
  `${term.label} is set`,
297
298
  (resolve) => `${term.sql(resolve)} is not null`,
298
299
  (value) => !isNullish(value),
@@ -336,10 +337,11 @@ const sameAs = (left: Term, other: ColumnExpr): Expr => {
336
337
  (resolve) => `${left.sql(resolve)} = ${right.sql(resolve)}`,
337
338
  // Two numeric columns are equal when their NUMBERS are: `'2' = '2.00'` across a `bigint()` and
338
339
  // a `decimal()`, which `===` over the two row strings denied.
339
- (row) =>
340
- left.decimal || right.decimal
341
- ? numericOrder(true, left.read(row), right.read(row)) === 0
342
- : left.read(row) === right.read(row),
340
+ (row) => {
341
+ const [a, b] = [left.read(row), right.read(row)];
342
+ if (isNullish(a) || isNullish(b)) return null;
343
+ return left.decimal || right.decimal ? numericOrder(true, a, b) === 0 : a === b;
344
+ },
343
345
  );
344
346
  };
345
347
 
@@ -386,15 +388,12 @@ const satisfies = (predicate: RowPredicate, columns: readonly string[]): Expr =>
386
388
  * **`=` and not `is not distinct from`, decided on a measurement.** With both operands total the two
387
389
  * are identical for all four boolean pairs. They part when an operand is NULL — a predicate on a
388
390
  * nullable column — and there `=` answers NULL, which a CHECK PASSES, while `is not distinct from`
389
- * answers false, which a CHECK REFUSES. The app side reads a NULL operand as false either way, so
390
- * the total form is the one that refuses a row TypeScript ACCEPTED: `(NULL) is not distinct from
391
- * (false)` is false where `false === false` is true. That is the raw `23514` in place of
392
- * `X_INVARIANT_VIOLATED` this whole file exists against, and it is why the more permissive spelling
393
- * is the safer one. `pg-invariant-null.live.test.ts` measures both.
391
+ * answers false, which a CHECK REFUSES. The app side judges the same three values (`verdicts`), so
392
+ * `=` is the spelling both halves agree on: the row passes in each. The total form would refuse a
393
+ * row TypeScript accepted — the raw `23514` in place of `X_INVARIANT_VIOLATED` this whole file
394
+ * exists against. `pg-invariant-null.live.test.ts` measures both.
394
395
  *
395
- * So an operand that can be NULL leaves the CHECK permissive — the language's one existing
396
- * disagreement, inherited here and not widened. `isNull()`/`isNotNull()` are total, which is what
397
- * makes a rule built from them exact.
396
+ * `isNull()`/`isNotNull()` are total, which is what makes a rule built from them decide a NULL.
398
397
  */
399
398
  export const iff = (left: Expr, right: Expr): Expr => {
400
399
  for (const side of [left, right] as const) {
@@ -434,7 +433,12 @@ export const iff = (left: Expr, right: Expr): Expr => {
434
433
  const b = right.toSql(resolve);
435
434
  return a === null || b === null ? null : `(${a}) = (${b})`;
436
435
  },
437
- (row) => left.holds(row) === right.holds(row),
436
+ (row) => {
437
+ // `(a) = (b)` in three values: UNKNOWN when either side is, which the CHECK then passes.
438
+ const a = verdictOf(left)(row);
439
+ const b = verdictOf(right)(row);
440
+ return a === null || b === null ? null : a === b;
441
+ },
438
442
  );
439
443
  };
440
444
 
@@ -0,0 +1,136 @@
1
+ // Single responsibility: put one row's history rows in the order the writes happened. Each version
2
+ // is an edge from its `before` image to its `after`, and the order is the walk that uses every edge
3
+ // once (an Eulerian path: delete-then-reinsert and A→B→A hold) — across every row, because `at` is
4
+ // a transaction's START and not its write order. `at` breaks ties, and orders what no walk covers.
5
+
6
+ import type { HistoryColumns } from './history';
7
+ import type { RowOf } from './types';
8
+
9
+ /** One stored history row, as the history table reads it. */
10
+ export type HistoryRecord = RowOf<HistoryColumns>;
11
+
12
+ /**
13
+ * One version of a row: the stored history row and its 1-based place in the row's history. The
14
+ * number is a position in what is RETAINED — after a `retain` sweep, version 1 is the oldest kept.
15
+ */
16
+ export type HistoryVersion = HistoryRecord & { readonly version: number };
17
+
18
+ /** An image as a node: its columns in key order, the ones whose change records nothing left out. */
19
+ const nodeOf = (image: unknown, ignored: ReadonlySet<string>): string => {
20
+ if (typeof image !== 'object' || image === null) return 'null';
21
+ const entries = Object.entries(image as Record<string, unknown>)
22
+ .filter(([key]) => !ignored.has(key))
23
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
24
+ return JSON.stringify(entries);
25
+ };
26
+
27
+ /** Where no walk decides (a purged prefix, a hand-written row): insert, update, delete, then id. */
28
+ // A Map, never an object literal: a hand-written `op` of `constructor` read a function off one.
29
+ const OP_RANK: ReadonlyMap<string, number> = new Map([
30
+ ['insert', 0],
31
+ ['update', 1],
32
+ ['delete', 2],
33
+ ]);
34
+ const byRank = (a: HistoryRecord, b: HistoryRecord): number =>
35
+ (OP_RANK.get(a.op) ?? 1) - (OP_RANK.get(b.op) ?? 1) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
36
+
37
+ /** Hierholzer over one instant's rows from `start`; `null` when no walk uses every row once. */
38
+ function walk(
39
+ group: readonly HistoryRecord[],
40
+ start: string,
41
+ ignored: ReadonlySet<string>,
42
+ ): HistoryRecord[] | null {
43
+ const out = new Map<string, HistoryRecord[]>();
44
+ for (const row of group) {
45
+ const from = nodeOf(row.before, ignored);
46
+ out.set(from, [...(out.get(from) ?? []), row]);
47
+ }
48
+ const stack: { node: string; row: HistoryRecord | null }[] = [{ node: start, row: null }];
49
+ const path: HistoryRecord[] = [];
50
+ while (stack.length > 0) {
51
+ const top = stack[stack.length - 1];
52
+ if (top === undefined) break;
53
+ const next = out.get(top.node)?.shift();
54
+ if (next !== undefined) stack.push({ node: nodeOf(next.after, ignored), row: next });
55
+ else {
56
+ stack.pop();
57
+ if (top.row !== null) path.push(top.row);
58
+ }
59
+ }
60
+ path.reverse();
61
+ if (path.length !== group.length) return null;
62
+ let at = start;
63
+ for (const row of path) {
64
+ if (nodeOf(row.before, ignored) !== at) return null;
65
+ at = nodeOf(row.after, ignored);
66
+ }
67
+ return path;
68
+ }
69
+
70
+ /**
71
+ * Where the one walk over EVERY row starts: the image that is produced one time fewer than it is
72
+ * consumed (the first insert's `null`, or the oldest retained `before`), or — when every image
73
+ * balances, a row deleted at the end — the insert's `null`, else the earliest row's `before`. `null` when no single walk can
74
+ * use every row: a purged middle, a hand-written row.
75
+ */
76
+ function startOf(sorted: readonly HistoryRecord[], ignored: ReadonlySet<string>): string | null {
77
+ const balance = new Map<string, number>();
78
+ for (const row of sorted) {
79
+ const from = nodeOf(row.before, ignored);
80
+ const to = nodeOf(row.after, ignored);
81
+ balance.set(from, (balance.get(from) ?? 0) + 1);
82
+ balance.set(to, (balance.get(to) ?? 0) - 1);
83
+ }
84
+ const starts = [...balance].filter(([, n]) => n === 1).map(([node]) => node);
85
+ const odd = [...balance].filter(([, n]) => n !== 0 && n !== 1 && n !== -1);
86
+ const ends = [...balance].filter(([, n]) => n === -1);
87
+ if (odd.length > 0) return null;
88
+ if (starts.length === 1 && ends.length === 1) return starts[0] ?? null;
89
+ if (starts.length === 0 && ends.length === 0) {
90
+ // A circuit: every life of the row closed. It began at an insert — the NULL image — whatever
91
+ // `at` the delete carries (a transaction that began first and deleted last stamps the earlier).
92
+ const hasInsert = sorted.some((row) => nodeOf(row.before, ignored) === 'null');
93
+ return hasInsert ? 'null' : nodeOf(sorted[0]?.before, ignored);
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /** By instant, chaining only inside one: the fallback when the rows make no single walk. */
99
+ function byInstant(
100
+ sorted: readonly HistoryRecord[],
101
+ ignored: ReadonlySet<string>,
102
+ ): readonly HistoryRecord[] {
103
+ const ordered: HistoryRecord[] = [];
104
+ let current = 'null';
105
+ for (let i = 0; i < sorted.length; ) {
106
+ const instant = sorted[i]?.at.getTime();
107
+ let end = i;
108
+ while (end < sorted.length && sorted[end]?.at.getTime() === instant) end += 1;
109
+ const group = sorted.slice(i, end);
110
+ const chained = walk(group, current, ignored) ?? group;
111
+ ordered.push(...chained);
112
+ current = nodeOf(chained[chained.length - 1]?.after, ignored);
113
+ i = end;
114
+ }
115
+ return ordered;
116
+ }
117
+
118
+ /**
119
+ * `rows` in write order, numbered from 1. `ignored`: the parent's counting columns.
120
+ *
121
+ * The before→after chain decides, across EVERY row: `at` is each write's TRANSACTION start
122
+ * (`now()`), so a transaction that began first and committed last carries the earlier `at` and the
123
+ * later image. `at` orders only what the chain leaves open — which edge leaves an image first — and
124
+ * the whole order when no single walk uses every row. The limit: an image revisited across
125
+ * transactions (A→B→A→C) admits more than one walk, and the earliest-`at` edge is taken first.
126
+ */
127
+ export function orderVersions(
128
+ rows: readonly HistoryRecord[],
129
+ ignored: ReadonlySet<string> = new Set(),
130
+ ): readonly HistoryVersion[] {
131
+ const sorted = [...rows].sort((a, b) => a.at.getTime() - b.at.getTime() || byRank(a, b));
132
+ const start = sorted.length === 0 ? null : startOf(sorted, ignored);
133
+ const chained =
134
+ (start === null ? null : walk(sorted, start, ignored)) ?? byInstant(sorted, ignored);
135
+ return chained.map((row, i) => ({ ...row, version: i + 1 }));
136
+ }
@@ -0,0 +1,80 @@
1
+ // Single responsibility: `history(db.postsHistory, id)` — one row's recorded versions, oldest first,
2
+ // numbered from 1 — and `{ limit }`, the newest of them. A read of the history table like any other
3
+ // (tenant-scoped by the repository, keyset-batched), ordered by `history-order.ts`.
4
+
5
+ import { type HistoryRecord, type HistoryVersion, orderVersions } from './history-order';
6
+ import { assertFinitePageSize, MAX_PAGE_SIZE } from './plan';
7
+ import type { ReadBuilder } from './query';
8
+ import { getEntity } from './registry';
9
+
10
+ export interface HistoryReadOptions {
11
+ /** The newest `limit` versions only, read newest-first and stopped there; at most `MAX_PAGE_SIZE`. */
12
+ readonly limit?: number | undefined;
13
+ }
14
+
15
+ /** The parent's counting columns (physical names), read off its description; none if unknown. */
16
+ const ignoredOf = (historyName: string): ReadonlySet<string> => {
17
+ const parent = getEntity(historyName)?.core?.$historyOf;
18
+ const core = parent === undefined || parent === null ? undefined : getEntity(parent)?.core;
19
+ return new Set(core?.$describe().history?.ignore ?? []);
20
+ };
21
+
22
+ /**
23
+ * The newest rows by `at`, at least `limit` of them and every one sharing the boundary instant —
24
+ * a transaction's writes share `now()`, so a tie is never cut and the chain decides inside it.
25
+ */
26
+ async function newest(
27
+ table: ReadBuilder<HistoryRecord>,
28
+ id: string,
29
+ limit: number,
30
+ ): Promise<HistoryRecord[]> {
31
+ const rows: HistoryRecord[] = [];
32
+ const batches = table
33
+ .where({ rowId: id })
34
+ .orderBy('at', 'desc')
35
+ .inBatches(Math.min(MAX_PAGE_SIZE, limit + 1));
36
+ for await (const batch of batches) {
37
+ for (const row of batch) {
38
+ const boundary = rows[limit - 1];
39
+ if (boundary !== undefined && row.at.getTime() < boundary.at.getTime()) return rows;
40
+ rows.push(row);
41
+ }
42
+ }
43
+ return rows;
44
+ }
45
+
46
+ /**
47
+ * Every recorded version of the row `id`, oldest first. `table` is the history entity's table —
48
+ * `database({ posts, postsHistory: posts.$history })` — so the read is that table's: scoped to the
49
+ * acting tenant (another tenant's row reads back as `[]`), indexed on `(tenant, row_id, at)`. Who
50
+ * may read it is decided by the query or action that calls this, by its policy.
51
+ *
52
+ * `{ limit: n }`: the newest `n` versions, oldest first, each numbered as the whole history numbers
53
+ * it (one `count` beside the read). The window is chosen by `at`, every tie at its edge included:
54
+ * a transaction that BEGAN before the window and committed inside it can sit outside it.
55
+ */
56
+ export async function history(
57
+ table: ReadBuilder<HistoryRecord>,
58
+ id: string,
59
+ options: HistoryReadOptions = {},
60
+ ): Promise<readonly HistoryVersion[]> {
61
+ const ignored = ignoredOf(table.plan().entity);
62
+ const { limit } = options;
63
+ if (limit === undefined) {
64
+ const rows: HistoryRecord[] = [];
65
+ const all = table.where({ rowId: id }).orderBy('at').inBatches(MAX_PAGE_SIZE);
66
+ for await (const batch of all) rows.push(...batch);
67
+ return orderVersions(rows, ignored);
68
+ }
69
+ // A bad limit is the page refusal `.limit()` answers — `0` would otherwise read every version.
70
+ const entity = table.plan().entity;
71
+ const [window, total] = await Promise.all([
72
+ newest(table, id, assertFinitePageSize(entity, limit)),
73
+ table.where({ rowId: id }).count(),
74
+ ]);
75
+ const ordered = orderVersions(window, ignored);
76
+ const offset = total - ordered.length;
77
+ return ordered
78
+ .slice(Math.max(0, ordered.length - limit))
79
+ .map((row) => ({ ...row, version: row.version + offset }));
80
+ }
package/src/history.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // subject data walk it, and a read of it is an ordinary tenant-scoped, cursor-paged read.
4
4
 
5
5
  import { t } from '@ultimat3/schema';
6
- import { columnName, makeColumn } from './column';
6
+ import { columnName, makeColumn, moneyColumns } from './column';
7
7
  import { text, timestamp, uuid } from './columns';
8
8
  import { json } from './columns-data';
9
9
  import type { Entity } from './entity';
@@ -11,8 +11,43 @@ import { entity } from './entity';
11
11
  import { invariantViolated } from './errors';
12
12
  import type { AnyColumn, ColumnMap, RowOf } from './types';
13
13
 
14
- /** `history: true`, or with a retention window of its own. No default window, here or anywhere. */
15
- export type HistoryInit = true | { readonly retain?: { readonly after: string } };
14
+ /**
15
+ * `history: true`, or with a retention window of its own (no default window, here or anywhere)
16
+ * and the columns whose change alone records nothing — the ones that churn (`likeCount`,
17
+ * `updatedAt`). `K` is the entity's own column names: anything else is a compile error.
18
+ */
19
+ export type HistoryInit<K extends string = string> =
20
+ | true
21
+ | {
22
+ readonly retain?: { readonly after: string };
23
+ readonly ignore?: readonly K[];
24
+ };
25
+
26
+ /** Each history entity's declared `ignore`, as physical names. A map, so no `$`-member is added. */
27
+ const declaredIgnore = new WeakMap<object, readonly string[]>();
28
+
29
+ /** The physical columns `history: { ignore }` named; `[]` for none or no history. */
30
+ export const historyIgnored = (history: object | null): readonly string[] =>
31
+ history === null ? [] : (declaredIgnore.get(history) ?? []);
32
+
33
+ /** Each `ignore` name, refused unless it is one of the parent's columns, as its physical name. */
34
+ function ignoredColumns(parent: HistoryParent, init: HistoryInit): readonly string[] {
35
+ if (init === true) return [];
36
+ return (init.ignore ?? []).flatMap((property) => {
37
+ const column = Object.hasOwn(parent.columns, property) ? parent.columns[property] : undefined;
38
+ if (column === undefined) {
39
+ throw invariantViolated(
40
+ parent.name,
41
+ 'history',
42
+ `ignore names ${JSON.stringify(property)}, which is no column of ${parent.name}; its columns are ${Object.keys(parent.columns).join(', ')}`,
43
+ );
44
+ }
45
+ // Money is three physical columns and the image holds each: ignoring the base name ignores none.
46
+ if (column.$meta.kind !== 'money') return [columnName(property, column.$meta)];
47
+ const parts = moneyColumns(property, column.$meta);
48
+ return [parts.minor, parts.currency, ...(parts.scale === null ? [] : [parts.scale])];
49
+ });
50
+ }
16
51
 
17
52
  /** The parent facts the history table is built from. */
18
53
  export interface HistoryParent {
@@ -63,6 +98,7 @@ export function historyEntity(parent: HistoryParent, init: HistoryInit): History
63
98
  'history records a row by its single primary key, and this entity declares a composite one',
64
99
  );
65
100
  }
101
+ const ignore = ignoredColumns(parent, init);
66
102
  const tenant = parent.tenantColumn;
67
103
  const tenantColumn = tenant === null ? undefined : parent.columns[tenant];
68
104
  const columns = {
@@ -73,7 +109,7 @@ export function historyEntity(parent: HistoryParent, init: HistoryInit): History
73
109
  };
74
110
  const after = init === true ? undefined : init.retain?.after;
75
111
  // Typed as its fixed columns: the tenant key is a runtime name, so it cannot be in the type.
76
- return entity<ColumnMap>(`${parent.name}_history`, {
112
+ const declared = entity<ColumnMap>(`${parent.name}_history`, {
77
113
  table: `${parent.table}_history`,
78
114
  columns,
79
115
  historyOf: parent.name,
@@ -83,4 +119,6 @@ export function historyEntity(parent: HistoryParent, init: HistoryInit): History
83
119
  ],
84
120
  ...(after === undefined ? {} : { retain: { after, on: 'at' } }),
85
121
  }) as unknown as HistoryEntity;
122
+ declaredIgnore.set(declared, ignore);
123
+ return declared;
86
124
  }
package/src/index.ts CHANGED
@@ -73,6 +73,8 @@ export type { ColumnExpr, Expr, InvariantColumns, Resolve } from './expr';
73
73
  export { iff } from './expr';
74
74
  /** The two DECLARED capabilities' refusals — a third-party driver raises the same ones. */
75
75
  export type { HistoryColumns, HistoryEntity, HistoryInit } from './history';
76
+ export type { HistoryRecord, HistoryVersion } from './history-order';
77
+ export { history } from './history-read';
76
78
  export type { IndexInit } from './index-init';
77
79
  export type { Invariant, InvariantDef, InvariantKind } from './invariants';
78
80
  export { assertInvariants, invariant, MAX_ASSERTED_ROWS } from './invariants';
package/src/lifecycle.ts CHANGED
@@ -11,7 +11,7 @@ import { columnName } from './column';
11
11
  import { countersOf, describeCounter } from './counter';
12
12
  import type { EntityCore } from './entity';
13
13
  import { invariantViolated } from './errors';
14
- import { type HistoryEntity, type HistoryInit, historyEntity } from './history';
14
+ import { type HistoryEntity, type HistoryInit, historyEntity, historyIgnored } from './history';
15
15
  import type { Retain } from './retain';
16
16
  import type { ColumnMap } from './types';
17
17
 
@@ -89,7 +89,12 @@ export function describeLifecycle(
89
89
  table: history.$table,
90
90
  key: physical(core, core.$primaryKey[0] ?? ''),
91
91
  tenant: core.$tenantColumn === null ? null : physical(core, core.$tenantColumn),
92
- ignore: counters.map((counter) => physical(core, counter.column)),
92
+ ignore: [
93
+ ...new Set([
94
+ ...counters.map((counter) => physical(core, counter.column)),
95
+ ...historyIgnored(history),
96
+ ]),
97
+ ],
93
98
  ...exactColumns(core),
94
99
  },
95
100
  }),
@@ -8,6 +8,7 @@ import { columnName, moneyColumns } from './column';
8
8
  import { countersOf, countersTargeting } from './counter';
9
9
  import type { EntityCore } from './entity';
10
10
  import { SOFT_DELETE_COLUMN } from './entity';
11
+ import { historyIgnored } from './history';
11
12
  import type { MemoryTables } from './memory-same-tenant';
12
13
  import type { Tx } from './repo';
13
14
 
@@ -63,7 +64,7 @@ export function memoryLifecycle(
63
64
  if (history === null || target?.raw === undefined) return;
64
65
  const prior = before === undefined ? null : imageOf(entity, before);
65
66
  const next = after === undefined ? null : imageOf(entity, after);
66
- const counted = new Set(countingColumns(entity));
67
+ const counted = new Set([...countingColumns(entity), ...historyIgnored(history)]);
67
68
  if (prior !== null && next !== null && sameIgnoring(prior, next, counted)) return;
68
69
  const source = after ?? before;
69
70
  const ctx = tx === undefined ? undefined : tryUseContext();
@@ -43,6 +43,16 @@ const timedOut = (key: string, waitMs: number): Error =>
43
43
  message: `canceling statement: waited ${waitMs} ms for a lock another caller still holds, usually two locks taken in opposite order`,
44
44
  });
45
45
 
46
+ /** Lock requests queued behind a holder right now, across every memory driver in the process. */
47
+ let waiting = 0;
48
+
49
+ /**
50
+ * TEST SEAM: how many memory lock requests are blocked behind a holder this moment — what a
51
+ * conformance check waits on to know a contender has REACHED the lock, never a clock. Postgres'
52
+ * twin is `pg_stat_activity.wait_event_type = 'Lock'`.
53
+ */
54
+ export const memoryLockWaiters = (): number => waiting;
55
+
46
56
  export const memoryLocks = (waitMs: number = MEMORY_LOCK_WAIT_MS): MemoryLocks => {
47
57
  instances += 1;
48
58
  const instance = instances;
@@ -72,6 +82,7 @@ export const memoryLocks = (waitMs: number = MEMORY_LOCK_WAIT_MS): MemoryLocks =
72
82
  const bound = new Promise<never>((_resolve, reject) => {
73
83
  timer = setTimeout(() => reject(timedOut(name, waitMs)), waitMs);
74
84
  });
85
+ waiting += 1;
75
86
  try {
76
87
  await Promise.race([previous, bound]);
77
88
  return release;
@@ -80,6 +91,7 @@ export const memoryLocks = (waitMs: number = MEMORY_LOCK_WAIT_MS): MemoryLocks =
80
91
  release();
81
92
  throw error;
82
93
  } finally {
94
+ waiting -= 1;
83
95
  clearTimeout(timer);
84
96
  }
85
97
  };