@ultimat3/entity 20.2.1 → 22.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.
@@ -13,7 +13,8 @@
13
13
  // the replicator — `@ultimat3/realtime`'s `selectChangeFeed` still decides, and this is never in
14
14
  // that decision.
15
15
 
16
- import { expectedQueryLoop } from '@ultimat3/db';
16
+ import { currentWriteOrigin } from '@ultimat3/core';
17
+ import { currentTx, expectedQueryLoop } from '@ultimat3/db';
17
18
  import type { EntityCore } from './entity';
18
19
  import { MAX_PAGE_SIZE } from './plan';
19
20
  import type { Repo, RepoOptions, UpsertArgs } from './repo';
@@ -32,6 +33,13 @@ export interface RowChange {
32
33
  readonly op: RowChangeOp;
33
34
  readonly before: Readonly<Record<string, unknown>> | null;
34
35
  readonly after: Readonly<Record<string, unknown>> | null;
36
+ /**
37
+ * The keyed write this change belongs to — `@ultimat3/core`'s `currentWriteOrigin()` at the
38
+ * moment the repository wrote, i.e. the digest of the idempotency key the request arrived with.
39
+ * The WAL decoder reads the same fact off the transaction's opening message; absent both ways
40
+ * for a write no keyed request made.
41
+ */
42
+ readonly write?: string;
35
43
  }
36
44
 
37
45
  /**
@@ -176,12 +184,43 @@ const beforeAllOf = async <Row>(
176
184
  export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Repo<Row> {
177
185
  const name = entity.$name;
178
186
 
179
- const emit = (op: RowChangeOp, before: unknown, after: unknown): void => {
180
- installed?.onChange({ entity: name, op, before: asRecord(before), after: asRecord(after) });
187
+ /**
188
+ * Reported at COMMIT. The write may sit in a transaction — the caller's `options.tx`, or the
189
+ * ambient `withTransaction` a Postgres repository joins — and a change reported before COMMIT
190
+ * put a row in a live query that a rollback then erased (plan 101, 06 m). Outside a transaction
191
+ * the write is already durable, so it is reported at once. The write origin is read NOW: it is
192
+ * the request's, and the commit callback may run outside that request's scope.
193
+ */
194
+ const afterCommit = (options: RepoOptions | undefined, report: () => void): void => {
195
+ const tx: { onCommit?(effect: () => void): void } | undefined = options?.tx ?? currentTx();
196
+ if (tx?.onCommit === undefined) report();
197
+ else tx.onCommit(report);
181
198
  };
182
199
 
183
- const bulk = (op: 'delete' | 'update', rows: number): void => {
184
- if (rows > 0) installed?.onBulk?.({ entity: name, op, rows });
200
+ const emit = (
201
+ op: RowChangeOp,
202
+ before: unknown,
203
+ after: unknown,
204
+ options: RepoOptions | undefined,
205
+ ): void => {
206
+ const observer = installed;
207
+ if (observer === null) return;
208
+ const write = currentWriteOrigin();
209
+ const change: RowChange = {
210
+ entity: name,
211
+ op,
212
+ before: asRecord(before),
213
+ after: asRecord(after),
214
+ ...(write === undefined ? {} : { write }),
215
+ };
216
+ afterCommit(options, () => observer.onChange(change));
217
+ };
218
+
219
+ const bulk = (op: 'delete' | 'update', rows: number, options: RepoOptions | undefined): void => {
220
+ const observer = installed;
221
+ if (rows > 0 && observer?.onBulk !== undefined) {
222
+ afterCommit(options, () => observer.onBulk?.({ entity: name, op, rows }));
223
+ }
185
224
  };
186
225
 
187
226
  // Spread first, exactly as `examples/dummy`'s own capturing driver does: a repository may carry
@@ -192,13 +231,13 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
192
231
 
193
232
  insert: async (values: Row, options?: RepoOptions): Promise<Row> => {
194
233
  const stored = await repo.insert(values, options);
195
- emit('insert', null, stored);
234
+ emit('insert', null, stored, options);
196
235
  return stored;
197
236
  },
198
237
 
199
238
  insertAll: async (rows: readonly Row[], options?: RepoOptions): Promise<readonly Row[]> => {
200
239
  const stored = await repo.insertAll(rows, options);
201
- for (const row of stored) emit('insert', null, row);
240
+ for (const row of stored) emit('insert', null, row, options);
202
241
  return stored;
203
242
  },
204
243
 
@@ -217,7 +256,7 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
217
256
  for (const row of stored) {
218
257
  const id = idOf(row);
219
258
  const previous = id === undefined ? null : (before.get(id) ?? null);
220
- emit(previous === null ? 'insert' : 'update', previous, row);
259
+ emit(previous === null ? 'insert' : 'update', previous, row, args);
221
260
  }
222
261
  return stored;
223
262
  },
@@ -226,7 +265,7 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
226
265
  if (installed === null) return await repo.update(id, patch, options);
227
266
  const before = await beforeOf(entity, repo, id);
228
267
  const after = await repo.update(id, patch, options);
229
- emit('update', before, after);
268
+ emit('update', before, after, options);
230
269
  return after;
231
270
  },
232
271
 
@@ -234,12 +273,12 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
234
273
  if (installed === null) return await repo.delete(id, options);
235
274
  const before = await beforeOf(entity, repo, id);
236
275
  await repo.delete(id, options);
237
- emit('delete', before, null);
276
+ emit('delete', before, null, options);
238
277
  },
239
278
 
240
279
  deleteWhere: async (filter: RowPatch<Row>, options?: RepoOptions): Promise<number> => {
241
280
  const rows = await repo.deleteWhere(filter, options);
242
- bulk('delete', rows);
281
+ bulk('delete', rows, options);
243
282
  return rows;
244
283
  },
245
284
 
@@ -249,7 +288,7 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
249
288
  options?: RepoOptions,
250
289
  ): Promise<number> => {
251
290
  const rows = await repo.updateWhere(filter, patch, options);
252
- bulk('update', rows);
291
+ bulk('update', rows, options);
253
292
  return rows;
254
293
  },
255
294
  };
@@ -0,0 +1,63 @@
1
+ // `entity.$schema` — the whole row as a `t`-compatible schema, branded with the entity's record
2
+ // projection. The brand is what lets `rowsOf` find a row inside any output shape, so it has to
3
+ // survive every wrapper a row can be put in: containers keep the child node by reference, and the
4
+ // five methods that COPY a node are re-branded here rather than in `@ultimat3/schema`.
5
+
6
+ import { renderThrowable } from '@ultimat3/core';
7
+ import { fail, makeSchema, pass, type Schema, type SchemaNode } from '@ultimat3/schema';
8
+ import {
9
+ brandNode,
10
+ createProjection,
11
+ type ProjectionIdentity,
12
+ type RecordProjection,
13
+ } from './record-projection';
14
+ import type { AnyColumn } from './types';
15
+ import { columnNode } from './view';
16
+
17
+ /**
18
+ * Re-brands whatever a copying method returns. `nullable()`, `optional()`, `default()`,
19
+ * `describe()` and `refine()` each build a fresh node by spreading this one, and a spread drops a
20
+ * non-enumerable symbol — so without this, `t.nullable(posts.$schema)` would be a row nobody
21
+ * recognises. `refine()` keeps the brand because it keeps the shape; `.pick()` is not here
22
+ * because an entity row schema has none, and a `t.object` that does builds an unbranded node.
23
+ */
24
+ const branded = <In, Out>(
25
+ schema: Schema<In, Out>,
26
+ projection: RecordProjection,
27
+ ): Schema<In, Out> => {
28
+ brandNode(schema.node, projection);
29
+ return {
30
+ ...schema,
31
+ optional: () => branded(schema.optional(), projection),
32
+ nullable: () => branded(schema.nullable(), projection),
33
+ default: (value) => branded(schema.default(value), projection),
34
+ describe: (description) => branded(schema.describe(description), projection),
35
+ refine: (refinement) => branded(schema.refine(refinement), projection),
36
+ };
37
+ };
38
+
39
+ /**
40
+ * The row schema for one entity. Validation IS `$parse` — defaults filled, every column parsed —
41
+ * so `$schema` and `$parse` can never disagree about what a row is. A refusal is rendered with
42
+ * `renderThrowable`, never `instanceof`/`String()`: a column parser is app-reachable and may throw
43
+ * anything, and a second `TypeError` out of a validator is the one outcome a validator may not have.
44
+ */
45
+ export const rowSchema = <Row>(
46
+ identity: ProjectionIdentity,
47
+ columns: readonly (readonly [string, AnyColumn])[],
48
+ parse: (value: unknown) => Row,
49
+ ): Schema<unknown, Row> => {
50
+ const node: SchemaNode = {
51
+ kind: 'object',
52
+ properties: Object.fromEntries(columns.map(([key, column]) => [key, columnNode(column)])),
53
+ };
54
+ const projection = createProjection(identity, node);
55
+ const schema = makeSchema<unknown, Row>(node, (value, path) => {
56
+ try {
57
+ return pass(parse(value));
58
+ } catch (error) {
59
+ return fail(path, renderThrowable(error));
60
+ }
61
+ });
62
+ return branded(schema, projection);
63
+ };
package/src/rows-of.ts ADDED
@@ -0,0 +1,132 @@
1
+ // Which values in an output are entity rows — read off the output SCHEMA, never declared beside it
2
+ // (axiom 2). `hasEntityRows` answers statically and is memoised per node; `rowsOf` walks only the
3
+ // branches that can hold a row, so an output with none costs one WeakMap read per call.
4
+
5
+ import type { Row } from '@ultimat3/core';
6
+ import { isSchemaNode, nodeOf, type SchemaNode } from '@ultimat3/schema';
7
+ import { projectionOf, type RecordProjection } from './record-projection';
8
+
9
+ /** Children in the IR that can hold a value: object fields, array items, record values, arms. */
10
+ const childrenOf = (node: SchemaNode): readonly SchemaNode[] => [
11
+ ...Object.values(node.properties ?? {}),
12
+ ...(node.items === undefined ? [] : [node.items]),
13
+ ...(node.valueNode === undefined ? [] : [node.valueNode]),
14
+ ...(node.anyOf ?? []),
15
+ ];
16
+
17
+ const holdsRows = new WeakMap<SchemaNode, boolean>();
18
+
19
+ const containsBrand = (node: SchemaNode): boolean => {
20
+ const known = holdsRows.get(node);
21
+ if (known !== undefined) return known;
22
+ // Seeded `false` before recursing, so a node reachable from itself ends rather than overflows.
23
+ holdsRows.set(node, false);
24
+ const answer = projectionOf(node) !== undefined || childrenOf(node).some(containsBrand);
25
+ holdsRows.set(node, answer);
26
+ return answer;
27
+ };
28
+
29
+ /** A schema object (anything carrying `node`) or a bare node; anything else has no rows. */
30
+ const rootOf = (schema: unknown): SchemaNode | undefined =>
31
+ nodeOf(schema) ?? (isSchemaNode(schema) ? schema : undefined);
32
+
33
+ /** Does this output schema reference any entity row, at any depth? Static — no value needed. */
34
+ export const hasEntityRows = (schema: unknown): boolean => {
35
+ const root = rootOf(schema);
36
+ return root !== undefined && containsBrand(root);
37
+ };
38
+
39
+ /**
40
+ * Every entity projection an output schema can carry, one per record type, in first-seen order.
41
+ * Static, like `hasEntityRows`: a branded node is a row and its columns are never walked. What a
42
+ * caller needing the ENTITIES rather than the rows reads — `@ultimat3/action`'s mutator clock check.
43
+ */
44
+ export const projectionsIn = (schema: unknown): readonly RecordProjection[] => {
45
+ const root = rootOf(schema);
46
+ if (root === undefined) return [];
47
+ const found = new Map<string, RecordProjection>();
48
+ const seen = new Set<SchemaNode>();
49
+ const visit = (node: SchemaNode): void => {
50
+ if (seen.has(node) || !containsBrand(node)) return;
51
+ seen.add(node);
52
+ const projection = projectionOf(node);
53
+ if (projection !== undefined) {
54
+ if (!found.has(projection.type)) found.set(projection.type, projection);
55
+ return;
56
+ }
57
+ for (const child of childrenOf(node)) visit(child);
58
+ };
59
+ visit(root);
60
+ return [...found.values()];
61
+ };
62
+
63
+ const isObject = (value: unknown): value is Readonly<Record<string, unknown>> =>
64
+ typeof value === 'object' && value !== null && !Array.isArray(value);
65
+
66
+ /**
67
+ * Inside a union the IR cannot say which arm a value took, so a branded arm claims the value only
68
+ * when it carries every column of that row as an OWN key — `$parse` writes every column, `null`
69
+ * included, so a real row always does. Outside a union the output schema already validated the
70
+ * value, and it is taken as the row it was declared to be.
71
+ */
72
+ const fits = (projection: RecordProjection, value: Readonly<Record<string, unknown>>): boolean =>
73
+ Object.keys(projection.schema.properties ?? {}).every((column) => Object.hasOwn(value, column));
74
+
75
+ type Found = Map<string, Map<string, Row>>;
76
+
77
+ const collect = (found: Found, projection: RecordProjection, row: Row): void => {
78
+ const byKey = found.get(projection.type) ?? new Map<string, Row>();
79
+ found.set(projection.type, byKey);
80
+ const key = projection.key(row);
81
+ // First sighting wins: one record shown twice in a response is one record, not two writes.
82
+ if (!byKey.has(key)) byKey.set(key, row);
83
+ };
84
+
85
+ const walk = (node: SchemaNode, value: unknown, found: Found, inUnion: boolean): void => {
86
+ if (value === null || value === undefined || !containsBrand(node)) return;
87
+ const projection = projectionOf(node);
88
+ if (projection !== undefined) {
89
+ // A row's own columns are never walked: a column node carries no brand, and a `json()` value
90
+ // shaped like a row is data, not a record.
91
+ if (isObject(value) && (!inUnion || fits(projection, value))) collect(found, projection, value);
92
+ return;
93
+ }
94
+ if (node.kind === 'array' && node.items !== undefined && Array.isArray(value)) {
95
+ for (const item of value) walk(node.items, item, found, inUnion);
96
+ } else if (node.kind === 'object' && node.properties !== undefined && isObject(value)) {
97
+ for (const [key, child] of Object.entries(node.properties)) {
98
+ if (Object.hasOwn(value, key)) walk(child, value[key], found, inUnion);
99
+ }
100
+ } else if (node.kind === 'record' && node.valueNode !== undefined && isObject(value)) {
101
+ for (const entry of Object.values(value)) walk(node.valueNode, entry, found, inUnion);
102
+ } else if (node.kind === 'union') {
103
+ for (const arm of node.anyOf ?? []) walk(arm, value, found, true);
104
+ }
105
+ };
106
+
107
+ /** Keyed by record key, null-prototype: a key is data, and `'__proto__'` must stay a key. */
108
+ export type RecordsByKey = Readonly<Record<string, Row>>;
109
+
110
+ /** Keyed by record type, then by record key — the shape the record envelope carries on the wire. */
111
+ export type RecordsByType = Readonly<Record<string, RecordsByKey>>;
112
+
113
+ const nullProto = <V>(entries: Iterable<readonly [string, V]>): Readonly<Record<string, V>> => {
114
+ const out = Object.create(null) as Record<string, V>;
115
+ for (const [key, value] of entries) out[key] = value;
116
+ return out;
117
+ };
118
+
119
+ /**
120
+ * Every entity row in `value`, grouped by record type and keyed by record key — `{}` when the
121
+ * schema references none. The KEY travels because the browser cannot compute it: it would have to
122
+ * import the app's `entity()` declarations. The rows are the SAME objects the handler returned,
123
+ * one per key; a row with no key is `X_RECORD_KEY_MISSING`, because a keyless record would
124
+ * overwrite every other keyless one.
125
+ */
126
+ export const rowsOf = (schema: unknown, value: unknown): RecordsByType => {
127
+ const root = rootOf(schema);
128
+ if (root === undefined || !containsBrand(root)) return nullProto([]);
129
+ const found: Found = new Map();
130
+ walk(root, value, found, false);
131
+ return nullProto([...found].map(([type, byKey]) => [type, nullProto(byKey)] as const));
132
+ };
package/src/seed.ts CHANGED
@@ -291,8 +291,14 @@ export const defineSeed = (
291
291
  const found = await repo.findMany({ where: equalityPredicates(where), limit: 1 });
292
292
  const stored = found.rows[0];
293
293
  const preserve: readonly string[] = key.preserve ?? [CREATED_AT_COLUMN];
294
+ // Only what the CALLER named, and never a key the table generates: `$parse` fills a
295
+ // fresh uuid and a `defaultNow()` into `row`, which no stored row can equal — so a
296
+ // re-run reported every row `'updated'` and never once `'skipped'`.
294
297
  const compared = Object.keys(row as Record<string, unknown>).filter(
295
- (property) => !preserve.includes(property),
298
+ (property) =>
299
+ !preserve.includes(property) &&
300
+ Object.hasOwn(values, property) &&
301
+ (!entity.$primaryKey.includes(property) || key.by.includes(property as never)),
296
302
  );
297
303
  if (
298
304
  stored !== undefined &&
package/src/transition.ts CHANGED
@@ -8,6 +8,7 @@ import type { EntityCore } from './entity';
8
8
  import { notFound } from './errors';
9
9
  import type { IllegalTransition } from './feature-errors';
10
10
  import { stateConflict, stateTransitionIllegal, stateUndeclared } from './feature-errors';
11
+ import { singleKeyOf } from './plan';
11
12
  import type { Repo, RepoOptions } from './repo';
12
13
  import { canMove, isState, isTerminal, movesFrom, type StateMachine } from './state-machine';
13
14
  import type { ColumnMap, IdOf, RowPatch } from './types';
@@ -109,7 +110,11 @@ export const transitionRow = async <Row, C extends ColumnMap>(
109
110
  // an UNRESOLVED `Row`, so it never reduces and no object literal is ever assignable to it — the
110
111
  // same reason `expr.ts` and `@ultimat3/query`'s `paginate` spell theirs the same way. The column
111
112
  // name came from `machineFor`, which resolved it against the entity, so the shape is a real one.
112
- const filter = { id, [property]: move.from } as unknown as RowPatch<Row>;
113
+ // Keyed on the entity's OWN primary key, never the literal `id`: a `code`-keyed entity has no
114
+ // `id` column, so the filter matched nothing in memory (a false `X_STATE_CONFLICT`) and named a
115
+ // column Postgres does not have (`X_INVARIANT_VIOLATED`).
116
+ const key = singleKeyOf(entity, 'transition');
117
+ const filter = { [key]: id, [property]: move.from } as unknown as RowPatch<Row>;
113
118
  const values = { [property]: move.to } as unknown as RowPatch<Row>;
114
119
  const written = await repo.updateWhere(filter, patch(values), options);
115
120
  if (written === 0) throw await diagnose(entity, repo, property, id, move, options);
@@ -0,0 +1,75 @@
1
+ // A keyed write names itself in the write-ahead log. The Postgres driver opens the transaction a
2
+ // keyed request's write lands in with `pg_logical_emit_message(true, WRITE_ORIGIN_WAL_PREFIX,
3
+ // <digest>)`, and `@ultimat3/realtime`'s replication stream names every change after it with that
4
+ // digest — which is how a `records` frame produced by the replicator, in another process, still
5
+ // tells the page that wrote it that this is its own echo. The in-process row observer reads the
6
+ // same fact off the request scope and needs none of this.
7
+
8
+ import { currentWriteOrigin, WRITE_ORIGIN_WAL_PREFIX } from '@ultimat3/core';
9
+ import { currentTx, type DbClient, type DbTx, db, sql, withTransaction } from '@ultimat3/db';
10
+
11
+ /** Transactions this process already opened with the message: one per transaction is enough. */
12
+ const tagged = new WeakSet<DbTx>();
13
+
14
+ /**
15
+ * Whether this database lets the app's role emit the message. Asked once per process and
16
+ * remembered only once answered: a role without `EXECUTE` (a managed service may revoke it) must
17
+ * cost the page its echo match, never the write — so the answer is read BEFORE the first emit
18
+ * rather than learned from a failed one, which would already have aborted the caller's transaction.
19
+ */
20
+ let emits: Promise<boolean> | undefined;
21
+
22
+ /**
23
+ * By name, across overloads: Postgres 17 added a fourth parameter (`flush`, defaulted), so a probe
24
+ * naming the three-argument signature answered "no such function" there while the call below works
25
+ * on every version from 14 up. Measured on 17-alpine.
26
+ */
27
+ const CAN_EMIT = sql`select exists (select 1 from pg_catalog.pg_proc where proname = 'pg_logical_emit_message' and has_function_privilege(oid, 'execute')) as "ok"`;
28
+
29
+ function canEmit(client: DbClient): Promise<boolean> {
30
+ if (emits === undefined) {
31
+ const asked = client.one<{ ok: unknown }>(CAN_EMIT).then((row) => row?.ok === true);
32
+ emits = asked;
33
+ // A probe that FAILED answered nothing: the next write asks again.
34
+ asked.catch(() => {
35
+ if (emits === asked) emits = undefined;
36
+ });
37
+ }
38
+ return emits.catch(() => false);
39
+ }
40
+
41
+ const emit = (client: DbClient, digest: string): Promise<unknown> =>
42
+ client.query(
43
+ sql`select pg_logical_emit_message(true, ${WRITE_ORIGIN_WAL_PREFIX}::text, ${digest}::text)`,
44
+ );
45
+
46
+ /**
47
+ * Send `write` so the WAL names the keyed write it belongs to. Outside a keyed request, or on a
48
+ * repository pinned to its own client (which may not join a transaction — `X_REPO_CLIENT_PINNED`),
49
+ * it is `write()` unchanged. Inside an open transaction the message goes first, once. Outside one,
50
+ * the write is wrapped in a transaction of its own, because a message in another transaction names
51
+ * nothing: three more round trips per keyed write, and only for a write a page is waiting on.
52
+ */
53
+ export async function taggedWrite<T>(pinned: boolean, write: () => Promise<T>): Promise<T> {
54
+ const digest = currentWriteOrigin();
55
+ if (digest === undefined || pinned) return write();
56
+ const open = currentTx();
57
+ if (!(await canEmit(open ?? db()))) return write();
58
+ if (open !== undefined) {
59
+ if (!tagged.has(open)) {
60
+ await emit(open, digest);
61
+ tagged.add(open);
62
+ }
63
+ return write();
64
+ }
65
+ return withTransaction(async (tx) => {
66
+ await emit(tx, digest);
67
+ tagged.add(tx);
68
+ return write();
69
+ });
70
+ }
71
+
72
+ /** Forget the capability answer. Tests only: a process asks one database once. */
73
+ export function resetWriteTag(): void {
74
+ emits = undefined;
75
+ }