@ultimat3/entity 25.1.0 → 26.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
@@ -98,7 +98,6 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
98
98
  `MAX_GROUPS + 1`, never a truncation; absent is not `0`; NULL is one group keyed `null`; the order
99
99
  is applied after the rows are in, never in SQL; `group_value` / `group_count` are fixed aliases.
100
100
  The refusal names a groupable column of THIS entity, never `x entity explain`.
101
- The argument for each: [`docs/history/entity.md`](../../docs/history/entity.md).
102
101
  - **The codec is `@ultimat3/core`'s**, reached through `cursorFor(entity, plan, row, id)` and
103
102
  `seekFrom(entity, plan)`; **`assertSeekable` runs in `planFor`**, before a statement exists. A
104
103
  `timestamptz` sort key is refused when its `<column>$US` alias would pass 63 bytes. A cursor is bound
@@ -237,7 +236,8 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
237
236
  `TransitionTable<S>`): a terminal state is one with no outgoing moves; the move is ONE statement with
238
237
  `from` in the predicate (`pg-transition.live.test.ts`: 1 winner of 20); `X_STATE_CONFLICT` is read
239
238
  after the refusal from a tenant-scoped `findById`; no DDL beyond `enumerated()`'s CHECK; a machine
240
- column may not be nullable; `whyNot` asks unknown → terminal → legal list.
239
+ column may not be nullable; `whyNot` asks unknown → terminal → legal list. **`move.observed`
240
+ pins what a decision read** (#702, `transition-pins.ts`).
241
241
 
242
242
  ## Do not regress — sealed columns
243
243
 
@@ -286,7 +286,7 @@ Columns + invariants; the row type is derived from the columns. Tier 2.
286
286
  Each file's header states its one job. The map, by area: `types.ts` (derivation; `COLUMN_KINDS`),
287
287
  `column.ts` / `columns.ts` / `columns-data.ts` / `array-element.ts` / `enum-column.ts` /
288
288
  `column-values.ts` (builders, `columnName`, `narrowMoney`), `refuse.ts`, `expr.ts` / `invariants.ts`,
289
- `entity.ts` / `describe.ts` / `index-name.ts` / `search.ts`, `state-machine.ts` / `transition.ts`,
289
+ `entity.ts` / `describe.ts` / `index-name.ts` / `search.ts`, `state-machine.ts` / `transition*.ts`,
290
290
  `feature-errors.ts`, `view.ts` / `row-schema.ts` / `record-projection.ts` / `record-key.ts` /
291
291
  `rows-of.ts` / `record-table.ts` / `record.ts`, `entity-error.ts` / `errors.ts`, `query.ts` /
292
292
  `database.ts` / `clock.ts`, `memory-match.ts` / `like.ts` / `numeric-compare.ts` / `unique-constraints.ts` / `repo.ts` /
package/README.md CHANGED
@@ -263,7 +263,7 @@ export const accounts = entity('account', {
263
263
 
264
264
  | Override | What follows it |
265
265
  |---|---|
266
- | `entity(name, { table })` | every statement, index name and foreign key. The entity NAME stays the framework's key — the registry, the cache tag (`entity:account`), `x entities describe` and every relation are keyed by it, so renaming a table never moves a cache tag or a policy |
266
+ | `entity(name, { table })` | every statement, index name and foreign key. The entity NAME stays the framework's key — the registry, the cache tag (`entity:account`), `x entities show` and every relation are keyed by it, so renaming a table never moves a cache tag or a policy |
267
267
  | `.column(name)` | the DDL, the binding, the decoder, the predicate, the sort key, the cursor. Name it LAST in a chain — the link returns the general column, and only `uuid()` and `timestamp()` keep their own methods across it |
268
268
  | `money({ columns })` | per part, merged over `<name>_minor` / `<name>_currency` / `<name>_scale`, so a table that renamed one does not restate the other two. `scale: null` says the table has no scale column: every amount is then at the currency's own minor unit, which is what an absent scale already means |
269
269
 
@@ -593,6 +593,14 @@ const db = database({ orders });
593
593
 
594
594
  // One statement. `from` is the state you believe the row is in, and it rides in the predicate.
595
595
  const shipped = await db.orders.transition('status', id, { from: 'paid', to: 'shipped' });
596
+
597
+ // Decided about the row? Say what the decision read, and the move is conditional on it too.
598
+ const read = await db.orders.where({ id }).one();
599
+ await db.orders.transition('status', id, {
600
+ from: 'shipped',
601
+ to: 'delivered',
602
+ observed: { row: read, read: ['orgId'] },
603
+ });
596
604
  ```
597
605
 
598
606
  | Fact | Why |
@@ -607,6 +615,8 @@ const shipped = await db.orders.transition('status', id, { from: 'paid', to: 'sh
607
615
  | the CHECK comes from `enumerated()` | the machine emits no DDL of its own — one declaration of what a legal value is |
608
616
  | `memoryDriver()` answers it exactly as Postgres does | a compare-and-set over a map is the same question; unlike a `tsvector` match, there is nothing to fake |
609
617
  | an `undefined` or `null` id is `X_NOT_FOUND`, and moves no row | an absent id names no row — it is refused before the statement, where it used to drop out of the filter and move every row in the `from` state |
618
+ | `observed: { row, read }` pins what a **decision** read, in the same statement | a move decided about the row (`row.authorId === actor.id`) is conditional on that row still holding it: each `read` property that is a plain column of THIS row (a row carrying another key is another record; a keyless projection is this one) joins `id` and `from` in the predicate. A reassignment committed first matches no row; one in flight holds the row's lock, and Postgres re-checks the predicate after it commits. Either way `X_STATE_CONFLICT`, naming the column and never its value. `@ultimat3/action`'s `transition()` fills it from what its policy read (#702) |
619
+ | a `timestamptz`, `jsonb`, array, `bytea`, money or sealed column is **not** pinned | none has an `=` meaning one thing in both drivers — a `defaultNow()` column holds microseconds in Postgres and a decoded `Date` milliseconds, so a pin on it would refuse every move there and none in memory (`transition-observed.test.ts`) |
610
620
 
611
621
  What is deliberately **not** here: who may make a move, what happens on arrival, an approval chain,
612
622
  a reason code. Those differ per app — wrap `transition()` in your own function and put them there.
@@ -803,7 +813,7 @@ page.rows[0].author; // the member row, or null — always present
803
813
  | Unknown name | `X_PRELOAD_UNKNOWN_RELATION` at `preload()` itself, not a page later |
804
814
  | Shape | `belongsTo` attaches the row or `null`; `hasMany` an array — always present |
805
815
  | Statements | one extra per relation, resolved concurrently; naming one twice is one statement |
806
- | Ceiling | `preload('<relation>', { max })` — the most related rows one relation may attach to one page. Default `MAX_PRELOADED_ROWS` (10,000, the largest page a read may ask for). Past it the read is **refused** (`X_INVARIANT_VIOLATED`), never truncated, and it reads at most one row past the ceiling to know. Naming a relation twice is still one statement: a later call that states `max` replaces the earlier one, as a second `.limit()` does, and one that states none leaves it. The refusal's cause spells the call to write (`.preload('<relation>', { max: n })`); its fix is `x entities describe <entity> --json` |
816
+ | Ceiling | `preload('<relation>', { max })` — the most related rows one relation may attach to one page. Default `MAX_PRELOADED_ROWS` (10,000, the largest page a read may ask for). Past it the read is **refused** (`X_INVARIANT_VIOLATED`), never truncated, and it reads at most one row past the ceiling to know. Naming a relation twice is still one statement: a later call that states `max` replaces the earlier one, as a second `.limit()` does, and one that states none leaves it. The refusal's cause spells the call to write (`.preload('<relation>', { max: n })`); its fix is `x entities show <entity> --json` |
807
817
  | Tenancy | carried onto the related read only when the other entity's tenant column shares the name; otherwise `X_TENANCY_UNSCOPED` refuses the related read rather than guess |
808
818
  | Terminals | `page()`, `all()`, `one()` preload; `count()`, `countBy()` and `plan()` don't — none reads a row to attach one to |
809
819
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "25.1.0",
3
+ "version": "26.0.0",
4
4
  "description": "A table + its domain type + invariants the database also enforces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,9 +33,9 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "dependencies": {
36
- "@ultimat3/core": "25.1.0",
37
- "@ultimat3/db": "25.1.0",
38
- "@ultimat3/schema": "25.1.0",
39
- "@ultimat3/time": "25.1.0"
36
+ "@ultimat3/core": "26.0.0",
37
+ "@ultimat3/db": "26.0.0",
38
+ "@ultimat3/schema": "26.0.0",
39
+ "@ultimat3/time": "26.0.0"
40
40
  }
41
41
  }
package/src/aggregate.ts CHANGED
@@ -64,7 +64,7 @@ export const notAggregatable = (
64
64
  fn === 'avg' && kind === 'money'
65
65
  ? `${entityName}.sum('${property}') and ${entityName}.count() — divide at the call site, where the rounding is a decision somebody made`
66
66
  : candidates.length === 0
67
- ? `x entities describe ${entityName} --json # this entity declares no column ${fn} can be applied to`
67
+ ? `x entities show ${entityName} --json # this entity declares no column ${fn} can be applied to`
68
68
  : `${entityName}.${fn}('${candidates[0]}') # ${fn} takes one of: ${candidates.join(', ')}`,
69
69
  });
70
70
 
@@ -52,7 +52,7 @@ const ARRAY_ELEMENT_FIXES = Object.freeze<Record<RefusedElement, string>>({
52
52
  * and only a table could show.
53
53
  *
54
54
  * Not `reject()`: a declaration is repaired by an EDIT, and `reject`'s
55
- * `x entities describe column --json` is `X_DECLARATION_UNKNOWN` — no entity is named `column`, and
55
+ * `x entities show column --json` is `X_DECLARATION_UNKNOWN` — no entity is named `column`, and
56
56
  * there is no entity at all yet. So each fix is the edit that holds the list instead.
57
57
  */
58
58
  export const arrayElementRefused = (kind: RefusedElement): EntityError => {
package/src/count-by.ts CHANGED
@@ -42,7 +42,7 @@ const groupableColumns = <Row>(entity: EntityCore<Row>): readonly string[] =>
42
42
  * Not `invariantViolated`: its fix opens `x entity explain`, which describes invariants nobody
43
43
  * wrote here. What repairs this is one edit to the call — a different column, named in the message
44
44
  * because the entity is the only place the answer lives. Only when this entity offers no such
45
- * column does the fix become a command, and then it is `x entities describe`, which prints the
45
+ * column does the fix become a command, and then it is `x entities show`, which prints the
46
46
  * kinds: there is no call to suggest, since every column it declares would be refused the same way.
47
47
  */
48
48
  const notGroupable = <Row>(
@@ -57,7 +57,7 @@ const notGroupable = <Row>(
57
57
  cause: `${entity.$name}.${operation}('${property}'): ${reason}`,
58
58
  fix:
59
59
  first === undefined
60
- ? `x entities describe ${entity.$name} --json # this entity declares no column a count can be keyed by`
60
+ ? `x entities show ${entity.$name} --json # this entity declares no column a count can be keyed by`
61
61
  : `${entity.$name}.${operation}('${first}') # group by one of: ${groupableColumns(entity).join(', ')}`,
62
62
  });
63
63
  };
@@ -66,7 +66,7 @@ export const ENTITY_ERROR_TITLES: Readonly<Record<EntityErrorCode, string>> = {
66
66
  X_SEARCH_IN_MEMORY: 'the in-memory driver cannot answer a full-text match',
67
67
  X_STATE_UNDECLARED: 'that column declares no state machine',
68
68
  X_STATE_TRANSITION_ILLEGAL: 'the machine has no such transition',
69
- X_STATE_CONFLICT: 'the row is no longer in the state this transition named',
69
+ X_STATE_CONFLICT: 'the row changed after the read this transition was decided on',
70
70
  X_RECORD_KEY_MISSING: 'a row reached its record key without a primary-key value',
71
71
  X_ENTITY_SEALED_PREDICATE:
72
72
  'a sealed column is read by a predicate, an order or a rule the database evaluates',
@@ -124,14 +124,12 @@ export class EntityError extends UltimateError {
124
124
  * read is not spliced.
125
125
  */
126
126
  export const describeCommand = (entityName: string): string =>
127
- isFixShellSafe(entityName)
128
- ? `x entities describe ${entityName} --json`
129
- : 'x entities list --json';
127
+ isFixShellSafe(entityName) ? `x entities show ${entityName} --json` : 'x entities list --json';
130
128
 
131
129
  /**
132
130
  * The entity name is a VALUE, never a literal — `entity.$name`, `table`, the `name` `entity()` was
133
131
  * given. A literal is an entity that does not exist, and this fix then hands the reader
134
- * `x entities describe column --json`, which answers `X_DECLARATION_UNKNOWN` (issue #290). A
132
+ * `x entities show column --json`, which answers `X_DECLARATION_UNKNOWN` (issue #290). A
135
133
  * refusal raised before any entity exists belongs in `refuse.ts`, where the caller supplies the
136
134
  * edit; `refuse.test.ts` fails on a literal here.
137
135
  */
@@ -143,7 +141,7 @@ export const invariantViolated = (
143
141
  new EntityError({
144
142
  code: 'X_INVARIANT_VIOLATED',
145
143
  cause: `${entityName}.${invariantName}: ${message}`,
146
- fix: `x entities describe ${entityName} --json # shows the invariant and its SQL CHECK`,
144
+ fix: `x entities show ${entityName} --json # shows the invariant and its SQL CHECK`,
147
145
  });
148
146
 
149
147
  export const entityDuplicate = (name: string, existingTable: string): EntityError =>
package/src/entity.ts CHANGED
@@ -41,7 +41,7 @@ export interface EntityInit<C extends ColumnMap> {
41
41
  * cannot cover: `entity('user', { table: 'users', … })` reads and writes the table that is
42
42
  * already there, and every statement, index name and foreign key follows it.
43
43
  *
44
- * The entity NAME stays the framework's key — the registry, the cache tag, `x entities describe`
44
+ * The entity NAME stays the framework's key — the registry, the cache tag, `x entities show`
45
45
  * and every relation are keyed by it — so renaming a table never moves a cache tag or a policy.
46
46
  */
47
47
  readonly table?: string;
@@ -121,3 +121,22 @@ export const stateConflict = (
121
121
  cause: `${entityName}.${column} named "${expected}" for row ${id}, which is in "${actual}" — something moved it first`,
122
122
  fix: `re-read the row and decide again against "${actual}": ${entityName}.findById(id) — a transition names the state it expects, so a stale read is refused rather than overwritten`,
123
123
  });
124
+
125
+ /**
126
+ * The conditional update matched no row, the row IS in the named state, and a column the decision
127
+ * read no longer holds the value it read (#702). The same code as the stale state because it is the
128
+ * same refusal — the caller decided about a row that has since changed — and the same remedy: read
129
+ * again and decide again. Names the columns, never their values: those are row data.
130
+ */
131
+ export const stateRowChanged = (
132
+ entityName: string,
133
+ column: string,
134
+ id: string,
135
+ key: string,
136
+ changed: readonly string[],
137
+ ): EntityError =>
138
+ new EntityError({
139
+ code: 'X_STATE_CONFLICT',
140
+ cause: `${entityName}.${column} move for row ${id} was decided on ${changed.join(', ')}, which changed before the move landed — something rewrote the row the decision read`,
141
+ fix: `${entityName}.where({ ${key}: id }).one() # re-read the row and decide again: a transition carries what its decision read, so a stale decision is refused rather than applied`,
142
+ });
package/src/index.ts CHANGED
@@ -189,6 +189,7 @@ export {
189
189
  scopedPlan,
190
190
  } from './tenancy';
191
191
  export type { Move } from './transition';
192
+ export type { TransitionObservation } from './transition-pins';
192
193
  export type {
193
194
  AnyColumn,
194
195
  Column,
package/src/query.ts CHANGED
@@ -18,7 +18,7 @@ import type { Repo, RepoOptions, UpsertArgs } from './repo';
18
18
  import { copyRow, pickRow } from './sealed';
19
19
  import { SEARCH_PROPERTY } from './search';
20
20
  import type { Operator, Predicate, QueryPlan, SortDirection, SortKey } from './tenancy';
21
- import { transitionRow } from './transition';
21
+ import { type Move, transitionRow } from './transition';
22
22
  import type {
23
23
  ColumnMap,
24
24
  Filterable,
@@ -198,14 +198,14 @@ export interface Table<Row, C extends ColumnMap = ColumnMap>
198
198
  * of the concept and the only part of it the framework owns. Which state is terminal, what any
199
199
  * of them mean, who may make a move and what happens on arrival are the app's, every one.
200
200
  *
201
- * Tenant-scoped exactly as `updateWhere` is, because it IS one: a row in another org matches no
202
- * statement and reads back as absent, so the answer is `X_NOT_FOUND` rather than a conflict that
203
- * would confirm it exists.
201
+ * `observed` (the row a decision read, and what it read) pins those columns in the same statement
202
+ * — a row changed under the decision is `X_STATE_CONFLICT` too (#702, `transition-pins.ts`).
203
+ * Tenant-scoped as `updateWhere` is: another org's row reads back absent, `X_NOT_FOUND`.
204
204
  */
205
205
  transition<K extends keyof Row & string>(
206
206
  column: K,
207
207
  id: IdOf<Row>,
208
- move: { readonly from: Row[K] & string; readonly to: Row[K] & string },
208
+ move: Move<Row[K] & string>,
209
209
  options?: RepoOptions,
210
210
  ): Promise<Row>;
211
211
  insert(values: Insertable<C>, options?: RepoOptions): Promise<Row>;
package/src/record-key.ts CHANGED
@@ -23,7 +23,7 @@ export const recordKeyMissing = (type: string, column: string, value: unknown):
23
23
  // `renderFixShellArg` inside the safe branch is verbatim there; it is the call
24
24
  // `bun run fix-shell-arg` recognises as the screen.
25
25
  fix: isFixShellSafe(type)
26
- ? `x entities describe ${renderFixShellArg(type, 'ENTITY')} --json # lists the primary key; return the whole row (every primary-key column) from the handler that built this one`
26
+ ? `x entities show ${renderFixShellArg(type, 'ENTITY')} --json # lists the primary key; return the whole row (every primary-key column) from the handler that built this one`
27
27
  : 'x entities list --json # find this entity, then return the whole row (every primary-key column) from the handler that built this one',
28
28
  });
29
29
 
package/src/refuse.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // The two refusals raised while a SCHEMA is still being written — a column and an invariant — and
2
2
  // why neither goes through `invariantViolated`: that builder's fix is
3
- // `x entities describe <entityName> --json`, which needs an entity that exists. Passing the
4
- // literal `'column'` emitted `x entities describe column --json`, which answers
3
+ // `x entities show <entityName> --json`, which needs an entity that exists. Passing the
4
+ // literal `'column'` emitted `x entities show column --json`, which answers
5
5
  // `X_DECLARATION_UNKNOWN` — a fix line that raises a second, unrelated error (issue #290).
6
6
  //
7
7
  // So the fix is a parameter: every caller supplies the EDIT that repairs its own refusal, the
@@ -0,0 +1,87 @@
1
+ // What a transition's decision READ, turned into the equality the compare-and-set also carries
2
+ // (#702): a row whose consulted columns changed between the read and the move matches no statement.
3
+ // Split from `transition.ts` because which columns can be pinned — and which cannot, honestly — is
4
+ // its own question, answered by the column's declared kind exactly as `memory-match.ts` answers one.
5
+
6
+ import { columnFor } from './column';
7
+ import type { EntityCore } from './entity';
8
+ import { isNullish } from './is-null';
9
+ import { sameValueOfKind } from './memory-match';
10
+ import type { ColumnKind } from './types';
11
+
12
+ /**
13
+ * The evidence a decision about a move was made on: the row as it was read, and the properties the
14
+ * decision consulted. `@ultimat3/action`'s `transition()` fills both — the row from its `row`
15
+ * loader, `read` from what the policy touched — and a direct caller names its own.
16
+ */
17
+ export interface TransitionObservation {
18
+ readonly row: unknown;
19
+ readonly read: readonly string[];
20
+ }
21
+
22
+ /**
23
+ * The kinds whose `=` means one thing in both drivers for the value a READ hands back. Not the
24
+ * others, and each for a measured reason: a `timestamptz` holds microseconds in Postgres and a
25
+ * decoded `Date` milliseconds, so an equality pin on a `defaultNow()` column refuses every move
26
+ * there and none in memory; `jsonb`, `array` and `bytea` compare structurally in neither driver's
27
+ * `eq`; `money` is three physical columns. A sealed column is never pinned: its filter is refused.
28
+ */
29
+ const PINNABLE: ReadonlySet<ColumnKind> = new Set<ColumnKind>([
30
+ 'uuid',
31
+ 'text',
32
+ 'char',
33
+ 'boolean',
34
+ 'integer',
35
+ 'bigint',
36
+ 'numeric',
37
+ 'date',
38
+ ]);
39
+
40
+ /**
41
+ * The property → value pairs to add to the move's predicate. Empty — the move exactly as before —
42
+ * when there is no observation, when the observed row carries a key that is not THIS row's (a
43
+ * loader may read a parent record, and pinning its values onto this one's columns would refuse
44
+ * every move), or when nothing the decision read is a pinnable column. The key and the machine column are already in the
45
+ * predicate, so they are skipped rather than pinned twice.
46
+ */
47
+ export const pinsOf = <Row>(
48
+ entity: EntityCore<Row>,
49
+ key: string,
50
+ machine: string,
51
+ id: unknown,
52
+ observed: TransitionObservation | undefined,
53
+ ): Readonly<Record<string, unknown>> => {
54
+ const row = observed?.row;
55
+ if (observed === undefined || typeof row !== 'object' || row === null) return {};
56
+ const values = row as Readonly<Record<string, unknown>>;
57
+ // A row that NAMES another key is another record. One that names none is a projection of this
58
+ // one (`select({ authorId: true })` — the loader is handed this row's id), and is pinned.
59
+ const keyKind = columnFor(entity.$columns, key)?.$meta.kind;
60
+ if (Object.hasOwn(values, key) && !sameValueOfKind(keyKind, values[key], id)) return {};
61
+ const pins: Record<string, unknown> = {};
62
+ for (const property of observed.read) {
63
+ if (property === key || property === machine || !Object.hasOwn(values, property)) continue;
64
+ const meta = columnFor(entity.$columns, property)?.$meta;
65
+ if (meta === undefined || meta.sealed !== undefined || !PINNABLE.has(meta.kind)) continue;
66
+ // `null` and never `undefined`: a pinned NULL is `is null` in both drivers, while an
67
+ // `undefined` filter value is DROPPED by `namedColumns` — a pin that silently vanished.
68
+ pins[property] = values[property] ?? null;
69
+ }
70
+ return pins;
71
+ };
72
+
73
+ /** Which pins the row as it stands now no longer satisfies — the diagnosis, never the decision. */
74
+ export const changedPins = <Row>(
75
+ entity: EntityCore<Row>,
76
+ row: unknown,
77
+ pins: Readonly<Record<string, unknown>>,
78
+ ): readonly string[] => {
79
+ const now = typeof row === 'object' && row !== null ? (row as Record<string, unknown>) : {};
80
+ return Object.entries(pins)
81
+ .filter(([property, was]) => {
82
+ const is = now[property];
83
+ if (isNullish(was) || isNullish(is)) return isNullish(was) !== isNullish(is);
84
+ return !sameValueOfKind(columnFor(entity.$columns, property)?.$meta.kind, is, was);
85
+ })
86
+ .map(([property]) => property);
87
+ };
package/src/transition.ts CHANGED
@@ -7,16 +7,27 @@ import { columnFor } from './column';
7
7
  import type { EntityCore } from './entity';
8
8
  import { notFound } from './errors';
9
9
  import type { IllegalTransition } from './feature-errors';
10
- import { stateConflict, stateTransitionIllegal, stateUndeclared } from './feature-errors';
10
+ import {
11
+ stateConflict,
12
+ stateRowChanged,
13
+ stateTransitionIllegal,
14
+ stateUndeclared,
15
+ } from './feature-errors';
11
16
  import { singleKeyOf } from './plan';
12
17
  import type { Repo, RepoOptions } from './repo';
13
18
  import { canMove, isState, isTerminal, movesFrom, type StateMachine } from './state-machine';
19
+ import { changedPins, pinsOf, type TransitionObservation } from './transition-pins';
14
20
  import type { ColumnMap, IdOf, RowPatch } from './types';
15
21
 
16
- /** What a caller names: the state it believes the row is in, and the one it wants. */
22
+ /**
23
+ * What a caller names: the state it believes the row is in, the one it wants, and — when the move
24
+ * was DECIDED about the row — what that decision read, so the statement refuses a row that has
25
+ * changed under it (`transition-pins.ts`).
26
+ */
17
27
  export interface Move<S extends string = string> {
18
28
  readonly from: S;
19
29
  readonly to: S;
30
+ readonly observed?: TransitionObservation;
20
31
  }
21
32
 
22
33
  /** Every property whose column declares a machine — what `X_STATE_UNDECLARED` lists back. */
@@ -57,12 +68,20 @@ const diagnose = async <Row>(
57
68
  property: string,
58
69
  id: IdOf<Row>,
59
70
  move: Move,
71
+ pinned: { readonly key: string; readonly pins: Readonly<Record<string, unknown>> },
60
72
  options: RepoOptions | undefined,
61
73
  ): Promise<Error> => {
62
74
  const row = await repo.findById(id, options);
63
75
  if (row === null) return notFound(entity.$name, String(id));
64
76
  const actual = (row as Readonly<Record<string, unknown>>)[property];
65
- return stateConflict(entity.$name, property, String(id), move.from, String(actual));
77
+ if (String(actual) !== move.from) {
78
+ return stateConflict(entity.$name, property, String(id), move.from, String(actual));
79
+ }
80
+ // The state is still the one named, so a PIN refused it. A pin that reads equal again (changed
81
+ // and changed back after the statement) is still named: the statement is what decided.
82
+ const changed = changedPins(entity, row, pinned.pins);
83
+ const named = changed.length > 0 ? changed : Object.keys(pinned.pins);
84
+ return stateRowChanged(entity.$name, property, String(id), pinned.key, named);
66
85
  };
67
86
 
68
87
  const whyNot = (machine: StateMachine, move: Move): IllegalTransition => {
@@ -117,10 +136,17 @@ export const transitionRow = async <Row, C extends ColumnMap>(
117
136
  // value, so the predicate below would be the from-state alone and move every row in it.
118
137
  if (id === undefined || id === null) throw notFound(entity.$name, String(id));
119
138
  const key = singleKeyOf(entity, 'transition');
120
- const filter = { [key]: id, [property]: move.from } as unknown as RowPatch<Row>;
139
+ // The pins FIRST and the key and state after, so nothing observed can widen the address. The
140
+ // columns the decision read ride in the same statement as `from`, so a reassignment committed
141
+ // before it matches nothing, and one in flight is waited on and then re-checked by Postgres on
142
+ // the row it left behind — the window between a policy's read and the move, closed (#702).
143
+ const pins = pinsOf(entity, key, property, id, move.observed);
144
+ const filter = { ...pins, [key]: id, [property]: move.from } as unknown as RowPatch<Row>;
121
145
  const values = { [property]: move.to } as unknown as RowPatch<Row>;
122
146
  const written = await repo.updateWhere(filter, patch(values), options);
123
- if (written === 0) throw await diagnose(entity, repo, property, id, move, options);
147
+ if (written === 0) {
148
+ throw await diagnose(entity, repo, property, id, move, { key, pins }, options);
149
+ }
124
150
  const row = await repo.findById(id, options);
125
151
  if (row === null) throw notFound(entity.$name, String(id));
126
152
  return row;