@ultimat3/entity 25.1.0 → 25.2.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
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/entity",
3
- "version": "25.1.0",
3
+ "version": "25.2.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": "25.2.0",
37
+ "@ultimat3/db": "25.2.0",
38
+ "@ultimat3/schema": "25.2.0",
39
+ "@ultimat3/time": "25.2.0"
40
40
  }
41
41
  }
@@ -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',
@@ -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>;
@@ -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;