@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 +3 -3
- package/README.md +12 -2
- package/package.json +5 -5
- package/src/aggregate.ts +1 -1
- package/src/array-element.ts +1 -1
- package/src/count-by.ts +2 -2
- package/src/entity-error.ts +4 -6
- package/src/entity.ts +1 -1
- package/src/feature-errors.ts +19 -0
- package/src/index.ts +1 -0
- package/src/query.ts +5 -5
- package/src/record-key.ts +1 -1
- package/src/refuse.ts +2 -2
- package/src/transition-pins.ts +87 -0
- package/src/transition.ts +31 -5
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
|
|
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
|
|
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
|
|
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": "
|
|
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": "
|
|
37
|
-
"@ultimat3/db": "
|
|
38
|
-
"@ultimat3/schema": "
|
|
39
|
-
"@ultimat3/time": "
|
|
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
|
|
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
|
|
package/src/array-element.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
};
|
package/src/entity-error.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/src/feature-errors.ts
CHANGED
|
@@ -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
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
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
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:
|
|
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
|
|
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
|
|
4
|
-
// literal `'column'` emitted `x entities
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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;
|