@ultimat3/entity 4.0.0 → 5.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
@@ -709,6 +709,24 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
709
709
  to refuse. `bigint()` and `decimal()` are STRINGS for the same reason `money.minor` is a
710
710
  `number`: `JSON.stringify` throws on a bigint, and a `number` loses digits exactly where a legacy
711
711
  `int8` key lives.
712
+ - **`setRowObserver` reports committed row changes, above the driver, so memory and Postgres report
713
+ the same thing.** It exists because a change feed needs a SOURCE and only production has one:
714
+ `@ultimat3/realtime` decodes the write-ahead log, PGlite has no walsender and the memory driver has
715
+ no log at all — so `InMemoryChangeFeed`, which that package calls "the blessed development and
716
+ test feed", had nothing upstream of it. That is what left `@ultimat3/testing`'s `subscribe` fixture
717
+ with no driver. Rules, none optional. **One observer per process**, exactly like `@ultimat3/db`'s
718
+ `setStatementObserver`, and it hands back what it replaced so a nested harness restores rather than
719
+ clears. **Applied by `database()`**, not by a driver, so an app opts in by installing an observer
720
+ and never by choosing a different repository — the rows under test are the rows the app reads.
721
+ **With none installed it is one comparison per write**, which is why the guard is the first line of
722
+ every method rather than a flag read at wrap time. **`before` is read only when the primary key IS
723
+ `id`** — on a composite key `findById` cannot name a row, and an `id` column that is not the key
724
+ would read a DIFFERENT row than the write touched; `null` there is what logical replication reports
725
+ without `REPLICA IDENTITY FULL`, and a consumer already handles it. **A filtered write is `onBulk`,
726
+ never silence**: `deleteWhere`/`updateWhere` name a filter and not rows, and reading the matches
727
+ first would turn one statement into two and change what the code under test issues — so a count is
728
+ reported and a consumer re-reads. It is NOT a second change-feed path: `selectChangeFeed` still
729
+ decides what a real node reads, and this is never in that decision.
712
730
  - Never throw a bare `Error` — use `errors.ts`.
713
731
  - Tests restore the process-global registry in `afterAll` (`clearRegistry()`): a leaked registry
714
732
  breaks an unrelated package's tests, as it did in `@ultimat3/policy`.
@@ -738,6 +756,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
738
756
  | `jit-preload.ts` | a page's foreign key values → one `in` statement for the whole `for … of` loop |
739
757
  | `preload.ts` | the relation `preload()` names → one related-rows statement → attached to the page |
740
758
  | `pg-sql.ts` / `pg-row.ts` | plan → parameterised SQL; physical row ⇄ entity row (money is three columns) |
759
+ | `row-observer.ts` | `setRowObserver` — committed row changes, above the driver, for a change feed that has no log to read |
741
760
  | `registry.ts` | duplicate detection, `describeEntities()` for the manifest, `references()` per entry |
742
761
  | `relations.ts` | `relationMap()`/`relationsFor()`/`relationNamed()` — the FKs as a named `belongsTo`/`hasMany` map |
743
762
  | `n-plus-one.ts` | a repeated statement → the error whose `fix` is the preload or bulk call that ends it |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "4.0.0",
3
+ "version": "5.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": "4.0.0",
35
- "@ultimat3/db": "4.0.0",
36
- "@ultimat3/schema": "4.0.0",
37
- "@ultimat3/time": "4.0.0"
34
+ "@ultimat3/core": "5.0.0",
35
+ "@ultimat3/db": "5.0.0",
36
+ "@ultimat3/schema": "5.0.0",
37
+ "@ultimat3/time": "5.0.0"
38
38
  }
39
39
  }
package/src/database.ts CHANGED
@@ -7,6 +7,7 @@ import type { Table } from './query';
7
7
  import { tableFor } from './query';
8
8
  import type { Repo } from './repo';
9
9
  import { memoryRepo } from './repo';
10
+ import { observedRepo } from './row-observer';
10
11
 
11
12
  export type EntitySet = Readonly<Record<string, EntityCore>>;
12
13
 
@@ -88,7 +89,11 @@ export const database = <E extends EntitySet>(
88
89
  };
89
90
  const tables: Record<string, unknown> = {};
90
91
  for (const [key, entity] of Object.entries(entities)) {
91
- tables[key] = tableFor(entity, driver.repo(entity), related);
92
+ // Wrapped here rather than in a driver, so a committed row change is reported the same whether
93
+ // rows live in memory or in Postgres — `setRowObserver` is the seam, and with none installed the
94
+ // wrapper is one comparison per write. `related` below stays unwrapped on purpose: a relation
95
+ // preload is a READ, and a change feed has nothing to say about one.
96
+ tables[key] = tableFor(entity, observedRepo(entity, driver.repo(entity)), related);
92
97
  }
93
98
  // Built key by key from `entities`, so each table is the one `Database<E>` names.
94
99
  return tables as Database<E>;
package/src/index.ts CHANGED
@@ -112,6 +112,8 @@ export type {
112
112
  UpsertArgs,
113
113
  } from './repo';
114
114
  export { memoryRepo, memoryTransactor } from './repo';
115
+ export type { RowBulkChange, RowChange, RowChangeOp, RowObserver } from './row-observer';
116
+ export { observedRepo, rowObserver, setRowObserver } from './row-observer';
115
117
  export type {
116
118
  Seed,
117
119
  SeedContext,
@@ -0,0 +1,208 @@
1
+ // Committed row changes, as the framework's own repositories saw them. One seam, above the driver,
2
+ // so it reports the same changes whether rows live in memory or in Postgres.
3
+ //
4
+ // It exists because a change feed needs a source and only one of the two has one. Production
5
+ // decodes the write-ahead log (`@ultimat3/realtime`'s `PgLogicalReplicationFeed`); PGlite has no
6
+ // walsender and the memory driver has no log at all, so `InMemoryChangeFeed` — which that package
7
+ // calls "the blessed development and test feed" — had nothing upstream of it. That is what left
8
+ // `@ultimat3/testing`'s `subscribe` fixture with no driver: a live query with no changes flowing
9
+ // into it is a snapshot, and a snapshot is not what those tests assert.
10
+ //
11
+ // NOT a second change-feed path. This reports what a repository wrote; the replicator reports what
12
+ // the server committed, including writes this process never made. A node with a replicator uses
13
+ // the replicator — `@ultimat3/realtime`'s `selectChangeFeed` still decides, and this is never in
14
+ // that decision.
15
+
16
+ import type { EntityCore } from './entity';
17
+ import type { Repo, RepoOptions, UpsertArgs } from './repo';
18
+ import type { IdOf, RowPatch } from './types';
19
+
20
+ export type RowChangeOp = 'insert' | 'update' | 'delete';
21
+
22
+ /**
23
+ * One committed row change. `before`/`after` are whole rows, exactly as logical replication reports
24
+ * them with `REPLICA IDENTITY FULL` — a consumer diffs them itself rather than trusting a patch it
25
+ * did not compile.
26
+ */
27
+ export interface RowChange {
28
+ /** Entity name as declared, never a table name — the vocabulary a matcher's dependency set uses. */
29
+ readonly entity: string;
30
+ readonly op: RowChangeOp;
31
+ readonly before: Readonly<Record<string, unknown>> | null;
32
+ readonly after: Readonly<Record<string, unknown>> | null;
33
+ }
34
+
35
+ /**
36
+ * A write this seam cannot itemise: `deleteWhere` and `updateWhere` name a filter, not a row, and
37
+ * reading the matching rows first would turn one statement into two and change what the code under
38
+ * test issues. Reported as a bulk fact so a consumer can re-read rather than be told nothing —
39
+ * silence is the one answer that diverges silently, which is the failure `invalidate()` exists for.
40
+ */
41
+ export interface RowBulkChange {
42
+ readonly entity: string;
43
+ readonly op: 'delete' | 'update';
44
+ readonly rows: number;
45
+ }
46
+
47
+ export interface RowObserver {
48
+ onChange(change: RowChange): void;
49
+ /** Optional: an observer that re-reads everything on any change has nothing to do here. */
50
+ onBulk?(change: RowBulkChange): void;
51
+ }
52
+
53
+ let installed: RowObserver | null = null;
54
+
55
+ /**
56
+ * Install the process's row observer, or clear it with `null`. One per process, exactly like
57
+ * `@ultimat3/db`'s `setStatementObserver` — a second observer would be a second consumer disagreeing
58
+ * about what a write is, and the caller that wants two composes them itself.
59
+ *
60
+ * Returns the observer that was installed, so a harness restores rather than clears: `bun test`
61
+ * shares one process across files, and a fixture that cleared unconditionally would take an outer
62
+ * harness's observer with it.
63
+ */
64
+ export function setRowObserver(next: RowObserver | null): RowObserver | null {
65
+ const previous = installed;
66
+ installed = next;
67
+ return previous;
68
+ }
69
+
70
+ export const rowObserver = (): RowObserver | null => installed;
71
+
72
+ /**
73
+ * The reads a change needs and a plain write does not. `before` costs one `findById` per update and
74
+ * per delete — paid ONLY while an observer is installed, which is why the guard is the first line of
75
+ * every method below rather than a flag read once at wrap time. With no observer this wrapper is one
76
+ * comparison and a delegation, on a path that already awaits a database.
77
+ */
78
+ const idOf = (value: unknown): string | undefined => {
79
+ const id = (value as { readonly id?: unknown } | null)?.id;
80
+ return typeof id === 'string' ? id : undefined;
81
+ };
82
+
83
+ const asRecord = (row: unknown): Readonly<Record<string, unknown>> | null =>
84
+ typeof row === 'object' && row !== null ? (row as Readonly<Record<string, unknown>>) : null;
85
+
86
+ /**
87
+ * Whether `findById(row.id)` is the right lookup for this entity at all. It is exactly when the
88
+ * primary key IS `id` — on a composite key `findById` cannot name a row, and an `id` column that is
89
+ * not the key would read a DIFFERENT row than the write touched. Answering `null` there beats
90
+ * answering confidently with somebody else's row.
91
+ */
92
+ const readsById = (entity: EntityCore<unknown>): boolean =>
93
+ entity.$primaryKey.length === 1 && entity.$primaryKey[0] === 'id';
94
+
95
+ /**
96
+ * The `before` row, or `null` when this process could not read one.
97
+ *
98
+ * `null` is not a lie: it is exactly what logical replication reports for a table whose
99
+ * `REPLICA IDENTITY` is not `FULL`. A consumer that diffs before against after then produces a
100
+ * patch carrying every column instead of only the changed ones — wider, never wrong.
101
+ *
102
+ * The `catch` is a FLOOR, not a case: no write in this repo is known to succeed while its own
103
+ * `findById` refuses, because every guard a read applies — tenancy, soft delete — the write applies
104
+ * too. It stays because the cost of being wrong is asymmetric: an observer is a diagnostic, and a
105
+ * diagnostic that turns a working write into a failing one is worse than the gap it was closing.
106
+ */
107
+ const beforeOf = async <Row>(
108
+ entity: EntityCore<Row>,
109
+ repo: Repo<Row>,
110
+ id: IdOf<Row>,
111
+ ): Promise<Row | null> => {
112
+ if (!readsById(entity as EntityCore<unknown>)) return null;
113
+ try {
114
+ return await repo.findById(id);
115
+ } catch {
116
+ return null;
117
+ }
118
+ };
119
+
120
+ /**
121
+ * Wrap one repository so its writes are reported. Applied by `database()` to every table it builds,
122
+ * so an app opts in by installing an observer and never by choosing a different repository — the
123
+ * rows under test are the rows the app reads, which is the whole reason `defaultDriver()` is
124
+ * exported at all.
125
+ */
126
+ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Repo<Row> {
127
+ const name = entity.$name;
128
+
129
+ const emit = (op: RowChangeOp, before: unknown, after: unknown): void => {
130
+ installed?.onChange({ entity: name, op, before: asRecord(before), after: asRecord(after) });
131
+ };
132
+
133
+ const bulk = (op: 'delete' | 'update', rows: number): void => {
134
+ if (rows > 0) installed?.onBulk?.({ entity: name, op, rows });
135
+ };
136
+
137
+ // Spread first, exactly as `examples/dummy`'s own capturing driver does: a repository may carry
138
+ // members `Repo` does not name — `memoryRepo`'s `reset()` is one — and a wrapper that listed only
139
+ // the interface would silently drop them.
140
+ return {
141
+ ...repo,
142
+
143
+ insert: async (values: Row, options?: RepoOptions): Promise<Row> => {
144
+ const stored = await repo.insert(values, options);
145
+ emit('insert', null, stored);
146
+ return stored;
147
+ },
148
+
149
+ insertAll: async (rows: readonly Row[], options?: RepoOptions): Promise<readonly Row[]> => {
150
+ const stored = await repo.insertAll(rows, options);
151
+ for (const row of stored) emit('insert', null, row);
152
+ return stored;
153
+ },
154
+
155
+ /**
156
+ * `before` is read per row and only for rows that carry an id, because a collision is what
157
+ * separates an insert from an update here and nothing else in the result says which happened.
158
+ * Under `onMatch: 'nothing'` a row already stored is absent from the result — so it wrote
159
+ * nothing, and reporting a change for it would be reporting a write that did not occur.
160
+ */
161
+ upsertAll: async (rows: readonly Row[], args: UpsertArgs<Row>): Promise<readonly Row[]> => {
162
+ if (installed === null) return await repo.upsertAll(rows, args);
163
+ const before = new Map<string, unknown>();
164
+ for (const row of rows) {
165
+ const id = idOf(row);
166
+ if (id !== undefined) before.set(id, await beforeOf(entity, repo, id as IdOf<Row>));
167
+ }
168
+ const stored = await repo.upsertAll(rows, args);
169
+ for (const row of stored) {
170
+ const id = idOf(row);
171
+ const previous = id === undefined ? null : (before.get(id) ?? null);
172
+ emit(previous === null ? 'insert' : 'update', previous, row);
173
+ }
174
+ return stored;
175
+ },
176
+
177
+ update: async (id, patch: RowPatch<Row>, options?: RepoOptions): Promise<Row> => {
178
+ if (installed === null) return await repo.update(id, patch, options);
179
+ const before = await beforeOf(entity, repo, id);
180
+ const after = await repo.update(id, patch, options);
181
+ emit('update', before, after);
182
+ return after;
183
+ },
184
+
185
+ delete: async (id, options?: RepoOptions): Promise<void> => {
186
+ if (installed === null) return await repo.delete(id, options);
187
+ const before = await beforeOf(entity, repo, id);
188
+ await repo.delete(id, options);
189
+ emit('delete', before, null);
190
+ },
191
+
192
+ deleteWhere: async (filter: RowPatch<Row>, options?: RepoOptions): Promise<number> => {
193
+ const rows = await repo.deleteWhere(filter, options);
194
+ bulk('delete', rows);
195
+ return rows;
196
+ },
197
+
198
+ updateWhere: async (
199
+ filter: RowPatch<Row>,
200
+ patch: RowPatch<Row>,
201
+ options?: RepoOptions,
202
+ ): Promise<number> => {
203
+ const rows = await repo.updateWhere(filter, patch, options);
204
+ bulk('update', rows);
205
+ return rows;
206
+ },
207
+ };
208
+ }