@ultimat3/entity 21.0.0 → 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.
@@ -0,0 +1,132 @@
1
+ // The aggregate reads compiled to SQL: a grouped count, one aggregate over one column, the
2
+ // distinct currencies a money column holds, and the planner's row estimate. Split from `pg-sql.ts`,
3
+ // which compiles the row reads they share their predicates with — `conditions()` is imported, never
4
+ // restated, so an aggregate counts exactly the rows the matching read would return.
5
+
6
+ import { identifier, type SqlFragment, sql } from '@ultimat3/db';
7
+ import type { AggregateFn } from './aggregate';
8
+ import { AVG_SCALE } from './aggregate';
9
+ import { kindOf } from './cursor';
10
+ import type { EntityCore } from './entity';
11
+ import { columnRef, conditions, type ReadShape } from './pg-sql';
12
+ import type { QueryPlan } from './tenancy';
13
+
14
+ /** What a grouped count comes back as. Both names are fixed, so neither can be a column's. */
15
+ export interface GroupRow {
16
+ readonly group_value: unknown;
17
+ readonly group_count: unknown;
18
+ }
19
+
20
+ /**
21
+ * The grouped count: one row per distinct value of one column, over exactly the rows
22
+ * `countStatement` would have counted — the same predicates, the same soft-delete filter, one
23
+ * `group by` more. `limit` bounds the groups, not the rows, which is what turns a whole-table
24
+ * breakdown into a refusal instead of a result set nobody sized.
25
+ *
26
+ * Both output names are aliases and fixed, so they cannot collide with each other whatever the
27
+ * table declares: an entity is free to have a column called `count`, and the un-aliased form would
28
+ * then return two outputs of one name.
29
+ */
30
+ export const countByStatement = <Row>(
31
+ entity: EntityCore<Row>,
32
+ plan: QueryPlan,
33
+ shape: ReadShape,
34
+ column: string,
35
+ limit: number,
36
+ ): SqlFragment => {
37
+ const grouped = columnRef(entity, column);
38
+ return sql`select ${grouped} as group_value, count(*) as group_count from ${identifier(
39
+ entity.$table,
40
+ )} where ${conditions(entity, plan, shape)} group by ${grouped} limit ${limit}`;
41
+ };
42
+
43
+ /**
44
+ * One aggregate over exactly the rows `countStatement` would have counted — the same predicates,
45
+ * the same soft-delete filter, one function more. Four outputs and always the same four names, so
46
+ * neither driver reads a column an entity could also have declared:
47
+ *
48
+ * - `agg_value` — the aggregate itself, as TEXT. `::text` and never a float: `sum(bigint)` is a
49
+ * `numeric` Bun would hand back as a string anyway, and pinning it makes `integer` behave the
50
+ * same. `aggregate.ts` re-parses it by the column's kind.
51
+ * - `agg_count` — how many non-null values went in, which is what tells `null` ("no rows") from a
52
+ * legitimate zero, and what `avg` divides by.
53
+ *
54
+ * `avg` is `round(avg(...), AVG_SCALE)` rather than the server's own scale, because the in-memory
55
+ * driver has to reach the same digits and "whatever numeric division gives you" is not a rule two
56
+ * implementations can share.
57
+ */
58
+ export const aggregateStatement = <Row>(
59
+ entity: EntityCore<Row>,
60
+ plan: QueryPlan,
61
+ shape: ReadShape,
62
+ fn: AggregateFn,
63
+ column: string,
64
+ ): SqlFragment => {
65
+ const target = columnRef(entity, column);
66
+ const extreme = fn === 'min' ? sql`min(${target})` : sql`max(${target})`;
67
+ const value =
68
+ fn === 'sum'
69
+ ? sql`sum(${target})`
70
+ : fn === 'avg'
71
+ ? sql`round(avg(${target}), ${AVG_SCALE})`
72
+ : // An instant crosses as EPOCH MILLISECONDS, never as the session's own text: `::text`
73
+ // prints in the session's zone, and `new Date` on that answered NaN for an offset with
74
+ // seconds (a pre-1937 LMT) and 1999 for year 0099. Not `at time zone 'UTC'` either —
75
+ // that is an offsetless `timestamp`, which JS reads as LOCAL time.
76
+ kindOf(entity, column) === 'timestamptz'
77
+ ? sql`(extract(epoch from ${extreme}) * 1000)`
78
+ : extreme;
79
+ return sql`select ${value}::text as agg_value, count(${target}) as agg_count from ${identifier(
80
+ entity.$table,
81
+ )} where ${conditions(entity, plan, shape)}`;
82
+ };
83
+
84
+ /** What an aggregate comes back as. Both names are fixed, so neither can be a column's. */
85
+ export interface AggregateRow {
86
+ readonly agg_value: unknown;
87
+ readonly agg_count: unknown;
88
+ }
89
+
90
+ /**
91
+ * The distinct currencies among the rows an aggregate is about to cover. A separate statement
92
+ * rather than a clever one: `sum(minor)` over two currencies is a number in neither, and the only
93
+ * honest answer is to refuse — which needs the list, not a boolean.
94
+ *
95
+ * Bounded at three, because the refusal names them and a caller with three already knows.
96
+ */
97
+ export const currenciesStatement = <Row>(
98
+ entity: EntityCore<Row>,
99
+ plan: QueryPlan,
100
+ shape: ReadShape,
101
+ currencyColumn: string,
102
+ scaleColumn: string | null,
103
+ ): SqlFragment => {
104
+ const currency = identifier(currencyColumn);
105
+ // The SCALE is half of what makes two amounts incomparable and it is the half with no symptom:
106
+ // `{ minor: 5, currency: 'USD' }` is five cents and the same row at `scale: 6` is five millionths
107
+ // of a dollar. A table with no scale column has one unit per currency by construction.
108
+ const scale = scaleColumn === null ? sql`null` : identifier(scaleColumn);
109
+ return sql`select distinct ${currency} as group_value, ${scale} as group_scale from ${identifier(
110
+ entity.$table,
111
+ )} where ${conditions(entity, plan, shape)} and ${currency} is not null limit 3`;
112
+ };
113
+
114
+ /** One `(currency, scale)` pair the rows an aggregate covers actually use. */
115
+ export interface MoneyUnitRow {
116
+ readonly group_value: unknown;
117
+ readonly group_scale: unknown;
118
+ }
119
+
120
+ /**
121
+ * The planner's own row estimate for a table — `reltuples`, which is what `ANALYZE` last wrote and
122
+ * what every query plan in the database is already costed against. `count(*)` walks every visible
123
+ * row (MVCC gives no shortcut), so on a large table it is the read that exceeds a web role's
124
+ * `statement_timeout`, and no index can make it cheaper: the `fix:` on that timeout tells an author
125
+ * to add one, and following it changes nothing.
126
+ *
127
+ * `to_regclass` rather than a name comparison, so a search_path change cannot silently answer for a
128
+ * different schema's table of the same name — and `-1` is what Postgres 14+ stores for a table that
129
+ * has never been analysed, which is an answer, not an estimate.
130
+ */
131
+ export const estimateStatement = (table: string): SqlFragment =>
132
+ sql`select reltuples::bigint as estimate from pg_class where oid = to_regclass(${table})`;
package/src/pg-sql.ts CHANGED
@@ -4,8 +4,6 @@
4
4
  // declared. That is the whole reason this file exists instead of a template literal per method.
5
5
 
6
6
  import { identifier, join, raw, type SqlFragment, sql } from '@ultimat3/db';
7
- import type { AggregateFn } from './aggregate';
8
- import { AVG_SCALE } from './aggregate';
9
7
  import { columnFor } from './column';
10
8
  import { isNullableKey, kindOf } from './cursor';
11
9
  import type { EntityCore } from './entity';
@@ -26,7 +24,7 @@ export interface ReadShape {
26
24
  readonly seek?: readonly unknown[] | undefined;
27
25
  }
28
26
 
29
- const columnRef = <Row>(entity: EntityCore<Row>, path: string): SqlFragment =>
27
+ export const columnRef = <Row>(entity: EntityCore<Row>, path: string): SqlFragment =>
30
28
  identifier(physicalName(entity, path));
31
29
 
32
30
  /**
@@ -379,118 +377,3 @@ export const countStatement = <Row>(
379
377
  shape: ReadShape,
380
378
  ): SqlFragment =>
381
379
  sql`select count(*) as count from ${identifier(entity.$table)} where ${conditions(entity, plan, shape)}`;
382
-
383
- /** What a grouped count comes back as. Both names are fixed, so neither can be a column's. */
384
- export interface GroupRow {
385
- readonly group_value: unknown;
386
- readonly group_count: unknown;
387
- }
388
-
389
- /**
390
- * The grouped count: one row per distinct value of one column, over exactly the rows
391
- * `countStatement` would have counted — the same predicates, the same soft-delete filter, one
392
- * `group by` more. `limit` bounds the groups, not the rows, which is what turns a whole-table
393
- * breakdown into a refusal instead of a result set nobody sized.
394
- *
395
- * Both output names are aliases and fixed, so they cannot collide with each other whatever the
396
- * table declares: an entity is free to have a column called `count`, and the un-aliased form would
397
- * then return two outputs of one name.
398
- */
399
- export const countByStatement = <Row>(
400
- entity: EntityCore<Row>,
401
- plan: QueryPlan,
402
- shape: ReadShape,
403
- column: string,
404
- limit: number,
405
- ): SqlFragment => {
406
- const grouped = columnRef(entity, column);
407
- return sql`select ${grouped} as group_value, count(*) as group_count from ${identifier(
408
- entity.$table,
409
- )} where ${conditions(entity, plan, shape)} group by ${grouped} limit ${limit}`;
410
- };
411
-
412
- /**
413
- * One aggregate over exactly the rows `countStatement` would have counted — the same predicates,
414
- * the same soft-delete filter, one function more. Four outputs and always the same four names, so
415
- * neither driver reads a column an entity could also have declared:
416
- *
417
- * - `agg_value` — the aggregate itself, as TEXT. `::text` and never a float: `sum(bigint)` is a
418
- * `numeric` Bun would hand back as a string anyway, and pinning it makes `integer` behave the
419
- * same. `aggregate.ts` re-parses it by the column's kind.
420
- * - `agg_count` — how many non-null values went in, which is what tells `null` ("no rows") from a
421
- * legitimate zero, and what `avg` divides by.
422
- *
423
- * `avg` is `round(avg(...), AVG_SCALE)` rather than the server's own scale, because the in-memory
424
- * driver has to reach the same digits and "whatever numeric division gives you" is not a rule two
425
- * implementations can share.
426
- */
427
- export const aggregateStatement = <Row>(
428
- entity: EntityCore<Row>,
429
- plan: QueryPlan,
430
- shape: ReadShape,
431
- fn: AggregateFn,
432
- column: string,
433
- ): SqlFragment => {
434
- const target = columnRef(entity, column);
435
- const value =
436
- fn === 'sum'
437
- ? sql`sum(${target})`
438
- : fn === 'avg'
439
- ? sql`round(avg(${target}), ${AVG_SCALE})`
440
- : fn === 'min'
441
- ? sql`min(${target})`
442
- : sql`max(${target})`;
443
- return sql`select ${value}::text as agg_value, count(${target}) as agg_count from ${identifier(
444
- entity.$table,
445
- )} where ${conditions(entity, plan, shape)}`;
446
- };
447
-
448
- /** What an aggregate comes back as. Both names are fixed, so neither can be a column's. */
449
- export interface AggregateRow {
450
- readonly agg_value: unknown;
451
- readonly agg_count: unknown;
452
- }
453
-
454
- /**
455
- * The distinct currencies among the rows an aggregate is about to cover. A separate statement
456
- * rather than a clever one: `sum(minor)` over two currencies is a number in neither, and the only
457
- * honest answer is to refuse — which needs the list, not a boolean.
458
- *
459
- * Bounded at three, because the refusal names them and a caller with three already knows.
460
- */
461
- export const currenciesStatement = <Row>(
462
- entity: EntityCore<Row>,
463
- plan: QueryPlan,
464
- shape: ReadShape,
465
- currencyColumn: string,
466
- scaleColumn: string | null,
467
- ): SqlFragment => {
468
- const currency = identifier(currencyColumn);
469
- // The SCALE is half of what makes two amounts incomparable and it is the half with no symptom:
470
- // `{ minor: 5, currency: 'USD' }` is five cents and the same row at `scale: 6` is five millionths
471
- // of a dollar. A table with no scale column has one unit per currency by construction.
472
- const scale = scaleColumn === null ? sql`null` : identifier(scaleColumn);
473
- return sql`select distinct ${currency} as group_value, ${scale} as group_scale from ${identifier(
474
- entity.$table,
475
- )} where ${conditions(entity, plan, shape)} and ${currency} is not null limit 3`;
476
- };
477
-
478
- /** One `(currency, scale)` pair the rows an aggregate covers actually use. */
479
- export interface MoneyUnitRow {
480
- readonly group_value: unknown;
481
- readonly group_scale: unknown;
482
- }
483
-
484
- /**
485
- * The planner's own row estimate for a table — `reltuples`, which is what `ANALYZE` last wrote and
486
- * what every query plan in the database is already costed against. `count(*)` walks every visible
487
- * row (MVCC gives no shortcut), so on a large table it is the read that exceeds a web role's
488
- * `statement_timeout`, and no index can make it cheaper: the `fix:` on that timeout tells an author
489
- * to add one, and following it changes nothing.
490
- *
491
- * `to_regclass` rather than a name comparison, so a search_path change cannot silently answer for a
492
- * different schema's table of the same name — and `-1` is what Postgres 14+ stores for a table that
493
- * has never been analysed, which is an answer, not an estimate.
494
- */
495
- export const estimateStatement = (table: string): SqlFragment =>
496
- sql`select reltuples::bigint as estimate from pg_class where oid = to_regclass(${table})`;
@@ -0,0 +1,23 @@
1
+ // The Postgres half of `Transactor`: one real transaction per `run`, the `Tx` a token. Split from
2
+ // `pg-driver.ts`, which is the repositories — the two meet only through `db()`, never an import.
3
+
4
+ import { type TransactionOptions, withTransaction } from '@ultimat3/db';
5
+ import type { Transactor } from './repo';
6
+
7
+ /**
8
+ * A real Postgres transaction behind the same `Transactor` the in-memory one implements. The
9
+ * `Tx` handed to the callback is a token: repositories find the transaction through `db()`, so
10
+ * nothing has to thread a connection through the call stack.
11
+ */
12
+ export const postgresTransactor = (options: TransactionOptions = {}): Transactor => ({
13
+ run: (work) =>
14
+ withTransaction(
15
+ (tx) =>
16
+ work({
17
+ id: tx.id,
18
+ onRollback: (undo: () => void) => tx.onRollback(undo),
19
+ onCommit: (effect: () => void) => tx.onCommit(effect),
20
+ }),
21
+ options,
22
+ ),
23
+ });
package/src/registry.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // than a silent last-one-wins.
5
5
 
6
6
  import type { IndexMethod } from '@ultimat3/db';
7
+ import type { EntityCore } from './entity';
7
8
  import { entityDuplicate } from './entity-error';
8
9
  import type { InvariantKind } from './invariants';
9
10
  import type { RecordProjection } from './record-projection';
@@ -133,6 +134,8 @@ export interface RegistryEntry {
133
134
  readonly persist?: boolean;
134
135
  /** The entity's client projection — what a changefeed's table maps to on the client. */
135
136
  readonly projection?: RecordProjection;
137
+ /** The entity itself, for `entityForTable` — what decodes a raw row of this table. */
138
+ readonly core?: EntityCore<unknown>;
136
139
  describe(): EntityDescription;
137
140
  /**
138
141
  * The foreign keys this entity declares, resolved. This is how a relation reaches query time:
@@ -157,6 +160,16 @@ export const registerEntity = <E extends RegistryEntry>(entry: E): E => {
157
160
 
158
161
  export const getEntity = (name: string): RegistryEntry | undefined => entities.get(name);
159
162
 
163
+ /**
164
+ * The entity declared on a PHYSICAL table, or `undefined`. What a change feed has in hand is a
165
+ * table name and raw columns; with this and `decodeRow` it gets the row the app declared, money
166
+ * and all — where `@ultimat3/realtime` had to guess money columns from their names.
167
+ */
168
+ export const entityForTable = (table: string): EntityCore<unknown> | undefined => {
169
+ for (const entry of entities.values()) if (entry.tableName === table) return entry.core;
170
+ return undefined;
171
+ };
172
+
160
173
  export const entityNames = (): readonly string[] => [...entities.keys()].sort();
161
174
 
162
175
  /**
package/src/repo.ts CHANGED
@@ -15,6 +15,11 @@ export interface Tx {
15
15
  readonly id: string;
16
16
  /** Registered by drivers so a failed transaction can undo in-memory effects. */
17
17
  onRollback(undo: () => void): void;
18
+ /**
19
+ * Run once the transaction COMMITS, never on rollback. Optional so a hand-written `Tx` still
20
+ * satisfies the type; without it, work registered here runs immediately (the pre-22 behaviour).
21
+ */
22
+ onCommit?(effect: () => void): void;
18
23
  }
19
24
 
20
25
  export interface RepoOptions {
@@ -14,7 +14,7 @@
14
14
  // that decision.
15
15
 
16
16
  import { currentWriteOrigin } from '@ultimat3/core';
17
- import { expectedQueryLoop } from '@ultimat3/db';
17
+ import { currentTx, expectedQueryLoop } from '@ultimat3/db';
18
18
  import type { EntityCore } from './entity';
19
19
  import { MAX_PAGE_SIZE } from './plan';
20
20
  import type { Repo, RepoOptions, UpsertArgs } from './repo';
@@ -184,20 +184,43 @@ const beforeAllOf = async <Row>(
184
184
  export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Repo<Row> {
185
185
  const name = entity.$name;
186
186
 
187
- const emit = (op: RowChangeOp, before: unknown, after: unknown): void => {
188
- if (installed === null) return;
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);
198
+ };
199
+
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;
189
208
  const write = currentWriteOrigin();
190
- installed.onChange({
209
+ const change: RowChange = {
191
210
  entity: name,
192
211
  op,
193
212
  before: asRecord(before),
194
213
  after: asRecord(after),
195
214
  ...(write === undefined ? {} : { write }),
196
- });
215
+ };
216
+ afterCommit(options, () => observer.onChange(change));
197
217
  };
198
218
 
199
- const bulk = (op: 'delete' | 'update', rows: number): void => {
200
- if (rows > 0) installed?.onBulk?.({ entity: name, op, rows });
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
+ }
201
224
  };
202
225
 
203
226
  // Spread first, exactly as `examples/dummy`'s own capturing driver does: a repository may carry
@@ -208,13 +231,13 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
208
231
 
209
232
  insert: async (values: Row, options?: RepoOptions): Promise<Row> => {
210
233
  const stored = await repo.insert(values, options);
211
- emit('insert', null, stored);
234
+ emit('insert', null, stored, options);
212
235
  return stored;
213
236
  },
214
237
 
215
238
  insertAll: async (rows: readonly Row[], options?: RepoOptions): Promise<readonly Row[]> => {
216
239
  const stored = await repo.insertAll(rows, options);
217
- for (const row of stored) emit('insert', null, row);
240
+ for (const row of stored) emit('insert', null, row, options);
218
241
  return stored;
219
242
  },
220
243
 
@@ -233,7 +256,7 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
233
256
  for (const row of stored) {
234
257
  const id = idOf(row);
235
258
  const previous = id === undefined ? null : (before.get(id) ?? null);
236
- emit(previous === null ? 'insert' : 'update', previous, row);
259
+ emit(previous === null ? 'insert' : 'update', previous, row, args);
237
260
  }
238
261
  return stored;
239
262
  },
@@ -242,7 +265,7 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
242
265
  if (installed === null) return await repo.update(id, patch, options);
243
266
  const before = await beforeOf(entity, repo, id);
244
267
  const after = await repo.update(id, patch, options);
245
- emit('update', before, after);
268
+ emit('update', before, after, options);
246
269
  return after;
247
270
  },
248
271
 
@@ -250,12 +273,12 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
250
273
  if (installed === null) return await repo.delete(id, options);
251
274
  const before = await beforeOf(entity, repo, id);
252
275
  await repo.delete(id, options);
253
- emit('delete', before, null);
276
+ emit('delete', before, null, options);
254
277
  },
255
278
 
256
279
  deleteWhere: async (filter: RowPatch<Row>, options?: RepoOptions): Promise<number> => {
257
280
  const rows = await repo.deleteWhere(filter, options);
258
- bulk('delete', rows);
281
+ bulk('delete', rows, options);
259
282
  return rows;
260
283
  },
261
284
 
@@ -265,7 +288,7 @@ export function observedRepo<Row>(entity: EntityCore<Row>, repo: Repo<Row>): Rep
265
288
  options?: RepoOptions,
266
289
  ): Promise<number> => {
267
290
  const rows = await repo.updateWhere(filter, patch, options);
268
- bulk('update', rows);
291
+ bulk('update', rows, options);
269
292
  return rows;
270
293
  },
271
294
  };
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);