@ultimat3/action 25.0.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 +4 -4
- package/README.md +11 -1
- package/package.json +8 -8
- package/src/idempotency-memory.ts +3 -2
- package/src/idempotency-postgres.ts +7 -3
- package/src/idempotency.ts +16 -3
- package/src/invoke.ts +2 -0
- package/src/sample-input.ts +35 -6
- package/src/transition-observe.ts +67 -0
- package/src/transition.ts +44 -4
- package/src/tx-scope.ts +1 -1
- package/src/type-pins.ts +5 -0
package/CLAUDE.md
CHANGED
|
@@ -120,7 +120,8 @@ Owns the `action` + `mutator` primitives and five projections. Tier 3. The MCP t
|
|
|
120
120
|
- **`transition()` is a mutator factory that decides nothing about the machine**: entity's three
|
|
121
121
|
codes propagate; `from` is REQUIRED (it is the UPDATE's predicate); `conflict: 'server-wins'` fixed;
|
|
122
122
|
`audit` off unless declared; `id` is the key `output.id` declares (`keySchemaOf`), never a fixed
|
|
123
|
-
`t.uuid
|
|
123
|
+
`t.uuid`; `row` goes to `mutator()` as declared, never loaded here (#687); **the rule's reads
|
|
124
|
+
are pinned in the move** (#702, `transition-observe.ts`: `def.row`, `def.handle` share `input`).
|
|
124
125
|
- **A lookup table is read with `Object.hasOwn`** (`IRREGULAR` in `naming.ts`, `BY_FORMAT` in
|
|
125
126
|
`sample-input.ts`) — caller- or provider-supplied keys.
|
|
126
127
|
|
|
@@ -203,7 +204,6 @@ Owns the `action` + `mutator` primitives and five projections. Tier 3. The MCP t
|
|
|
203
204
|
| `rpc` | 19,671 B (+181 B for the document's path-style stamp) |
|
|
204
205
|
| `rpc` + core's `clientFlight` | 25,954 B |
|
|
205
206
|
|
|
206
|
-
Earlier columns (pre-transport 18,097 B, +977 B net for the envelope decoder) are in git history.
|
|
207
207
|
- **`sideEffects` is `["./src/error-titles.ts"]`**, never `false` (drops its bare imports): the
|
|
208
208
|
titles ride into every barrel chunk (`problemError` decodes by code), `errors.ts`'s classes do
|
|
209
209
|
not. An http refusal needs no http module: core's decoders take its title off the body and its
|
|
@@ -222,8 +222,8 @@ Owns the `action` + `mutator` primitives and five projections. Tier 3. The MCP t
|
|
|
222
222
|
inside `withWriteOrigin(writeDigest(key))` so the page recognises its own `records` echo. A label,
|
|
223
223
|
never a gate.
|
|
224
224
|
- **Both stores FENCE a settlement on the reservation `id` AND `in-flight`**:
|
|
225
|
-
`settle(key, value, reservationId, redacted)` / `fail(key, failure, reservationId)
|
|
226
|
-
never thrown.
|
|
225
|
+
`settle(key, value, reservationId, redacted)` / `fail(key, failure, reservationId)` /
|
|
226
|
+
`release(key, reservationId)`. A fenced no-op is never thrown.
|
|
227
227
|
- **An answer rests REDACTED** (#591): `withIdempotency` passes the store `restingAnswer(value)`
|
|
228
228
|
(core's `isRedactedKey` + `isSecret` — never a second rule) and `redacted` as `settle`'s
|
|
229
229
|
REQUIRED 4th argument; both stores keep it (`x_idempotency.redacted`, memory's record field).
|
package/README.md
CHANGED
|
@@ -410,6 +410,8 @@ await moveOrder({ id, from: 'pending', to: 'paid' }, { ctx });
|
|
|
410
410
|
| `id` is the entity's **key**, read off `output.id` | a uuid key stays `t.uuid` (a malformed one is `X_INPUT_INVALID` before a database reads it); a `text()` key keeps its length and a `bigint()` key its digits pattern — never a fixed `t.uuid`, which refused every move on an entity keyed by anything else. An `output` that does not carry the key as `id` passes the key's schema as `id:`; with neither, the id is a uuid |
|
|
411
411
|
| `idempotent: true`, always | a move replayed under its `Idempotency-Key` answers the first outcome, not an `X_STATE_CONFLICT` for a move that already happened |
|
|
412
412
|
| `audit` is **off** unless the app says so | `audit: true` with no sink installed is `X_AUDIT_SINK_MISSING`, raised before the input parse — an on-by-default audit would make every `transition()` refuse until an unrelated decision was made. What the row is kept for, and for how long, is the same compliance question that kept a purge out of `postgresAuditSink` |
|
|
413
|
+
| a row-level `policy` gets its row from **`row`** | the action's own loader, handed through: `row: ({ input, ctx }) => posts(ctx).find(input.id)`, then `can<TransitionValues<S>, Post>('post:move', ({ actor, row }) => row !== null && row.authorId === actor?.id)`. Loaded once, after the input parse, before the guard and the statement; the factory reads no row itself. Omitted, the rule sees `row: null` and must fail closed |
|
|
414
|
+
| **what the rule read rides in the move** (#702) | the rule is handed a view of the loaded row that records which properties it reads; the move passes the row and that list to `Table.transition` as `observed`, and entity pins each one that is a plain column of the moved row beside `id` and `from`. An `authorId` reassigned between the read and the move — committed first, or in flight and waited on by Postgres — matches no statement: `X_STATE_CONFLICT` naming the column, and the row does not move. A column the rule never read may change freely. Not pinned: a row carrying another record's key (a loader reading a parent record; a keyless projection such as `select({ authorId: true })` is this row), a property that names no column, and a `timestamptz`, `jsonb`, array, `bytea`, money or sealed column — none has an `=` that means one thing in both drivers. Nor a column read inside a getter or method of a class-shaped row: only the accessor's name is recorded, so return a plain row from the loader |
|
|
413
415
|
| `X_STATE_TRANSITION_ILLEGAL`, `X_STATE_CONFLICT` and `X_STATE_UNDECLARED` propagate untouched | they are `@ultimat3/entity`'s. A second error class over one failure is a second path |
|
|
414
416
|
|
|
415
417
|
`table` is typed structurally (`TransitionTarget`), not imported — a real `Table` satisfies the
|
|
@@ -519,7 +521,7 @@ export function counted(inner: IdempotencyStore, onSettle: () => void): Idempote
|
|
|
519
521
|
onSettle();
|
|
520
522
|
return inner.settle(key, value, reservationId, redacted);
|
|
521
523
|
},
|
|
522
|
-
release: (key) => inner.release(key),
|
|
524
|
+
release: (key, reservationId) => inner.release(key, reservationId),
|
|
523
525
|
get: (key) => inner.get(key),
|
|
524
526
|
};
|
|
525
527
|
}
|
|
@@ -601,6 +603,14 @@ Both stores fence on it AND on `in-flight` `As of 2026-08`, the way `@ultimat3/j
|
|
|
601
603
|
so a straggler from the first attempt satisfied a status-only fence exactly and overwrote a live
|
|
602
604
|
reservation.
|
|
603
605
|
|
|
606
|
+
`release` takes it too (`As of 2026-10-07`): it is the pre-handler cleanup when `beforeRun` refuses,
|
|
607
|
+
and keyed alone it DELETED whatever record the key held — a replacement's reservation, or its
|
|
608
|
+
settled answer, so the next retry re-ran a handler that had already committed. A release that
|
|
609
|
+
matches nothing does nothing (`idempotency-release.test.ts`, both stores). **A custom store's
|
|
610
|
+
`release(key, reservationId)` must delete only the record whose id is `reservationId` and whose
|
|
611
|
+
status is `in-flight`** — one that ignores the second argument reopens the double-run window, and
|
|
612
|
+
type-checking cannot catch it: a one-parameter function still satisfies the signature.
|
|
613
|
+
|
|
604
614
|
**Inside a transaction the settlement commits with the write** (`As of 2026-10-02`, BOTH stores —
|
|
605
615
|
`idempotency-parity.test.ts` runs one set of cases over memory and Postgres). An action invoked
|
|
606
616
|
inside `withTransaction` settles with that transaction: on its own connection (Postgres), or at
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "25.
|
|
3
|
+
"version": "25.2.0",
|
|
4
4
|
"description": "The action primitive: one declaration projected to route, OpenAPI, client, MCP tool, job handle, tests",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"test": "bun test"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ultimat3/cache": "25.
|
|
39
|
-
"@ultimat3/core": "25.
|
|
40
|
-
"@ultimat3/db": "25.
|
|
41
|
-
"@ultimat3/entity": "25.
|
|
42
|
-
"@ultimat3/http": "25.
|
|
43
|
-
"@ultimat3/policy": "25.
|
|
44
|
-
"@ultimat3/schema": "25.
|
|
38
|
+
"@ultimat3/cache": "25.2.0",
|
|
39
|
+
"@ultimat3/core": "25.2.0",
|
|
40
|
+
"@ultimat3/db": "25.2.0",
|
|
41
|
+
"@ultimat3/entity": "25.2.0",
|
|
42
|
+
"@ultimat3/http": "25.2.0",
|
|
43
|
+
"@ultimat3/policy": "25.2.0",
|
|
44
|
+
"@ultimat3/schema": "25.2.0"
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -200,8 +200,9 @@ export class MemoryIdempotencyStore implements IdempotencyStore {
|
|
|
200
200
|
return existing;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
|
|
204
|
-
|
|
203
|
+
/** Fenced by `#owned`, as both settlements are: a record that is not this reservation stays. */
|
|
204
|
+
release(key: string, reservationId: string): Promise<void> {
|
|
205
|
+
if (this.#owned(key, reservationId) !== undefined) this.#records.delete(key);
|
|
205
206
|
return Promise.resolve();
|
|
206
207
|
}
|
|
207
208
|
|
|
@@ -109,7 +109,9 @@ update x_idempotency set status = 'failed', value = null, failure = $2::jsonb,
|
|
|
109
109
|
returning key
|
|
110
110
|
`;
|
|
111
111
|
|
|
112
|
-
export const SQL_IDEMPOTENCY_RELEASE = `
|
|
112
|
+
export const SQL_IDEMPOTENCY_RELEASE = `
|
|
113
|
+
delete from x_idempotency where key = $1 and id = $2::uuid and status = 'in-flight'
|
|
114
|
+
`;
|
|
113
115
|
|
|
114
116
|
export const SQL_IDEMPOTENCY_PURGE = `
|
|
115
117
|
delete from x_idempotency where created_at < now() - make_interval(secs => $1::double precision)
|
|
@@ -294,8 +296,10 @@ export function postgresIdempotencyStore(
|
|
|
294
296
|
fenced(rows, key, reservationId, 'fail');
|
|
295
297
|
},
|
|
296
298
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
+
// Fenced, and silent when it matches nothing: a release is a pre-handler cleanup, so a record
|
|
300
|
+
// that is no longer this reservation's is somebody else's to settle.
|
|
301
|
+
async release(key, reservationId): Promise<void> {
|
|
302
|
+
await exec.query(SQL_IDEMPOTENCY_RELEASE, [key, reservationId]);
|
|
299
303
|
},
|
|
300
304
|
|
|
301
305
|
get: fetch,
|
package/src/idempotency.ts
CHANGED
|
@@ -137,8 +137,13 @@ export interface IdempotencyStore {
|
|
|
137
137
|
* and re-running it is the double charge.
|
|
138
138
|
*/
|
|
139
139
|
fail?(key: string, failure: IdempotencyFailure, reservationId: string): Promise<void>;
|
|
140
|
-
/**
|
|
141
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Drop the reservation `reservationId` owns, so a retry can run. Only ever correct BEFORE the
|
|
142
|
+
* handler starts. Fenced like `settle` and `fail`: a straggler whose reservation was reclaimed
|
|
143
|
+
* past the deadline deleted the REPLACEMENT's record by key — settled, the next retry re-ran a
|
|
144
|
+
* handler that had committed. A release that matches nothing is a no-op.
|
|
145
|
+
*/
|
|
146
|
+
release(key: string, reservationId: string): Promise<void>;
|
|
142
147
|
get(key: string): Promise<IdempotencyRecord | undefined>;
|
|
143
148
|
}
|
|
144
149
|
|
|
@@ -252,7 +257,15 @@ export async function withIdempotency<T>(
|
|
|
252
257
|
try {
|
|
253
258
|
await options.beforeRun();
|
|
254
259
|
} catch (error) {
|
|
255
|
-
|
|
260
|
+
// THIS reservation's id: one reclaimed while `beforeRun` waited is no longer ours to drop.
|
|
261
|
+
// A release the store refuses never replaces the refusal: the caller is owed the 429, not a
|
|
262
|
+
// driver error. The reservation then stays in flight until the window lapses (an autocommit
|
|
263
|
+
// one is never reclaimed early), so a retry inside it is a 409 — logged, so it is findable.
|
|
264
|
+
try {
|
|
265
|
+
await store.release(key, record.id);
|
|
266
|
+
} catch (storeError) {
|
|
267
|
+
logger.error('action.idempotency.release-refused', { key, error: storeError });
|
|
268
|
+
}
|
|
256
269
|
throw error;
|
|
257
270
|
}
|
|
258
271
|
}
|
package/src/invoke.ts
CHANGED
|
@@ -274,6 +274,8 @@ async function perform(
|
|
|
274
274
|
// re-evaluate the same policy per subscriber without a query per change event. An
|
|
275
275
|
// action with no loader hands the rule `null` — unchanged, and never a silent allow,
|
|
276
276
|
// because a rule that reads `row` has to decide what `null` means.
|
|
277
|
+
// The SAME `input` object reaches `def.row` here and `def.handle` below, by reference:
|
|
278
|
+
// `transition()` keys what its rule read on it (`transition-observe.ts`, #702).
|
|
277
279
|
const row = def.row === undefined ? null : ((await def.row({ input, ctx })) ?? null);
|
|
278
280
|
guardAction(def.policy, { actor: actorOf(ctx), input, row, ctx, action: name }, surface);
|
|
279
281
|
|
package/src/sample-input.ts
CHANGED
|
@@ -99,6 +99,25 @@ function sampleObject(node: SchemaNode): Record<string, unknown> {
|
|
|
99
99
|
return sample;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The most items a sample will hold. A contract sample is a handful of values; `minItems` past
|
|
104
|
+
* this is an input the framework does not invent — `2 ** 32` made `Array.from` throw, and anything
|
|
105
|
+
* below it allocated that many samples first. Such an array is a GAP (`sampleGaps`), reported as
|
|
106
|
+
* `X_CONTRACT_DRIFT` with the instruction to pass the input, never silently under-sampled.
|
|
107
|
+
*/
|
|
108
|
+
export const SAMPLE_ITEMS_MAX = 1000;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The `minItems` an array sample owes, or `undefined` when none can be built: past the ceiling,
|
|
112
|
+
* or not a count at all. The builtin `t.array` refuses a bad bound at declaration
|
|
113
|
+
* (`X_SCHEMA_BOUNDS_INVALID`), but this IR may come from a swapped provider.
|
|
114
|
+
*/
|
|
115
|
+
function owedItems(node: SchemaNode): number | undefined {
|
|
116
|
+
const min = node.minItems;
|
|
117
|
+
if (min === undefined) return 0;
|
|
118
|
+
return Number.isSafeInteger(min) && min >= 0 && min <= SAMPLE_ITEMS_MAX ? min : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
102
121
|
function sampleFor(node: SchemaNode): unknown {
|
|
103
122
|
// A nullable field accepts `null`, and `null` is the smallest thing it accepts.
|
|
104
123
|
if (node.nullable === true) return null;
|
|
@@ -115,8 +134,13 @@ function sampleFor(node: SchemaNode): unknown {
|
|
|
115
134
|
return node.values?.[0] ?? SAMPLE_STRING;
|
|
116
135
|
case 'literal':
|
|
117
136
|
return node.literal ?? null;
|
|
137
|
+
// The fewest items the array accepts: `[]` under a `minItems` is a sample its own schema
|
|
138
|
+
// refuses. Items repeat the one item sample; a uniqueness rule would be a refinement.
|
|
118
139
|
case 'array':
|
|
119
|
-
|
|
140
|
+
// An unbuildable count samples `[]`, which the schema refuses — `sampleGaps` names why.
|
|
141
|
+
return Array.from({ length: owedItems(node) ?? 0 }, () =>
|
|
142
|
+
node.items === undefined ? null : sampleFor(node.items),
|
|
143
|
+
);
|
|
120
144
|
case 'union': {
|
|
121
145
|
const first = node.anyOf?.[0];
|
|
122
146
|
return first === undefined ? null : sampleFor(first);
|
|
@@ -174,24 +198,29 @@ function gapsIn(node: SchemaNode, path: string): string[] {
|
|
|
174
198
|
}
|
|
175
199
|
return out;
|
|
176
200
|
}
|
|
177
|
-
|
|
201
|
+
const unbuildable = node.kind === 'array' && owedItems(node) === undefined;
|
|
202
|
+
if (!unbuildable && satisfiesPattern(node, sampleFor(node))) return [];
|
|
178
203
|
return [path === '' ? ROOT_PATH : path];
|
|
179
204
|
}
|
|
180
205
|
|
|
181
206
|
/** `orderRef` -> `orderRef (must match ^ORD-\d{4}$)`, for a cause a reader can act on. */
|
|
182
207
|
export function describeSampleGap(schema: StandardSchemaV1, path: string): string {
|
|
183
|
-
const
|
|
208
|
+
const node = nodeAt(tryIntrospect(schema), path);
|
|
209
|
+
if (node?.kind === 'array' && node.minItems !== undefined && owedItems(node) === undefined) {
|
|
210
|
+
return `${path} (needs at least ${node.minItems} items)`;
|
|
211
|
+
}
|
|
212
|
+
const pattern = node?.pattern;
|
|
184
213
|
return pattern === undefined ? path : `${path} (must match ${pattern})`;
|
|
185
214
|
}
|
|
186
215
|
|
|
187
|
-
function
|
|
216
|
+
function nodeAt(node: SchemaNode | undefined, path: string): SchemaNode | undefined {
|
|
188
217
|
if (node === undefined) return undefined;
|
|
189
|
-
if (path === '' || path === ROOT_PATH) return node
|
|
218
|
+
if (path === '' || path === ROOT_PATH) return node;
|
|
190
219
|
const [head, ...rest] = path.split('.');
|
|
191
220
|
// `path` is the caller's — `describeSampleGap` is exported — so the same own-property read the
|
|
192
221
|
// format table takes: `properties['constructor']` is otherwise the `Object` function.
|
|
193
222
|
const properties = node.properties ?? {};
|
|
194
223
|
const child =
|
|
195
224
|
head === undefined || !Object.hasOwn(properties, head) ? undefined : properties[head];
|
|
196
|
-
return child === undefined ? undefined :
|
|
225
|
+
return child === undefined ? undefined : nodeAt(child, rest.join('.'));
|
|
197
226
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a `transition()`'s policy READ off the loaded row, carried from the guard to the
|
|
3
|
+
* compare-and-set (#702). The loader's row reaches the rule as a recording view; the move hands the
|
|
4
|
+
* raw row and the properties the rule touched to `Table.transition`, which pins them in its predicate.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { TransitionObservation } from '@ultimat3/entity';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Keyed by the PARSED INPUT object, which `invoke` hands to the loader and then to the handler by
|
|
11
|
+
* reference — a fresh object per invocation, so two concurrent moves of one row never share an
|
|
12
|
+
* entry, and a denied call's entry goes with its input. Module-private: nothing else can write one.
|
|
13
|
+
*/
|
|
14
|
+
const OBSERVED = new WeakMap<object, { readonly row: unknown; readonly read: Set<string> }>();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The view the rule is handed: the same row, recording every property it asks about. Anything that
|
|
18
|
+
* enumerates the row (a spread, `Object.keys`, a serialiser) records every key it enumerates — it
|
|
19
|
+
* may have looked at all of them, so all of them are pinned. Only own string keys mean anything:
|
|
20
|
+
* entity pins the ones that name a column of the moved entity and ignores the rest.
|
|
21
|
+
*/
|
|
22
|
+
const recording = (row: object, read: Set<string>): object =>
|
|
23
|
+
new Proxy(row, {
|
|
24
|
+
get(target, property) {
|
|
25
|
+
// `then` is the promise machinery asking whether the loader's answer is a thenable — the
|
|
26
|
+
// `await` in `invoke`, never the rule. The one name this view cannot attribute, so a column
|
|
27
|
+
// named `then` is never pinned.
|
|
28
|
+
if (typeof property === 'string' && property !== 'then') read.add(property);
|
|
29
|
+
// `target` as the receiver, not the proxy: a getter on a class row runs against the object
|
|
30
|
+
// that owns its private state, exactly as it would unobserved — which also means a column it
|
|
31
|
+
// reads through `this` is NOT recorded, only the getter's own name. Documented on
|
|
32
|
+
// `TransitionDef.row` as unpinned; a proxy receiver would throw on any `#private` access.
|
|
33
|
+
return Reflect.get(target, property, target);
|
|
34
|
+
},
|
|
35
|
+
has(target, property) {
|
|
36
|
+
if (typeof property === 'string') read.add(property);
|
|
37
|
+
return Reflect.has(target, property);
|
|
38
|
+
},
|
|
39
|
+
getOwnPropertyDescriptor(target, property) {
|
|
40
|
+
if (typeof property === 'string') read.add(property);
|
|
41
|
+
return Reflect.getOwnPropertyDescriptor(target, property);
|
|
42
|
+
},
|
|
43
|
+
ownKeys(target) {
|
|
44
|
+
const keys = Reflect.ownKeys(target);
|
|
45
|
+
for (const key of keys) if (typeof key === 'string') read.add(key);
|
|
46
|
+
return keys;
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** Remember the loaded row for this invocation, and hand back what the rule should see. */
|
|
51
|
+
export function observeRow<TRow>(input: unknown, row: TRow | null): TRow | null {
|
|
52
|
+
if (typeof input !== 'object' || input === null) return row;
|
|
53
|
+
const read = new Set<string>();
|
|
54
|
+
OBSERVED.set(input, { row, read });
|
|
55
|
+
// A non-object row has nothing to read off; the rule gets it unchanged. The cast is the Proxy's
|
|
56
|
+
// own: a proxy over `row` presents exactly `row`'s shape.
|
|
57
|
+
return typeof row === 'object' && row !== null ? (recording(row, read) as TRow) : row;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What the rule read for this invocation, once — `undefined` when no loader ran for it. */
|
|
61
|
+
export function takeObservation(input: unknown): TransitionObservation | undefined {
|
|
62
|
+
if (typeof input !== 'object' || input === null) return undefined;
|
|
63
|
+
const seen = OBSERVED.get(input);
|
|
64
|
+
if (seen === undefined) return undefined;
|
|
65
|
+
OBSERVED.delete(input);
|
|
66
|
+
return { row: seen.row, read: [...seen.read] };
|
|
67
|
+
}
|
package/src/transition.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { Ctx } from '@ultimat3/core';
|
|
13
|
+
import type { TransitionObservation } from '@ultimat3/entity';
|
|
13
14
|
import type {
|
|
14
15
|
InferOutput,
|
|
15
16
|
ObjectSchema,
|
|
@@ -18,8 +19,10 @@ import type {
|
|
|
18
19
|
StringSchema,
|
|
19
20
|
} from '@ultimat3/schema';
|
|
20
21
|
import { nodeOf, t } from '@ultimat3/schema';
|
|
22
|
+
import type { ActionRowArgs } from './action';
|
|
21
23
|
import { type Mutator, mutator } from './mutator';
|
|
22
24
|
import type { ActionPolicy } from './policy-gate';
|
|
25
|
+
import { observeRow, takeObservation } from './transition-observe';
|
|
23
26
|
|
|
24
27
|
/**
|
|
25
28
|
* The one method this factory calls, declared structurally: `@ultimat3/entity`'s `Table.transition`
|
|
@@ -49,7 +52,11 @@ export type TransitionInput<S extends string> = ObjectSchema<{
|
|
|
49
52
|
}>;
|
|
50
53
|
|
|
51
54
|
export interface TransitionTarget<Row, S extends string> {
|
|
52
|
-
transition(
|
|
55
|
+
transition(
|
|
56
|
+
column: string,
|
|
57
|
+
id: string,
|
|
58
|
+
move: { readonly from: S; readonly to: S; readonly observed?: TransitionObservation },
|
|
59
|
+
): Promise<Row>;
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
export interface TransitionDef<
|
|
@@ -57,6 +64,7 @@ export interface TransitionDef<
|
|
|
57
64
|
Row extends InferOutput<TOutput> & object,
|
|
58
65
|
K extends keyof Row & string,
|
|
59
66
|
S extends Row[K] & string,
|
|
67
|
+
TRow = unknown,
|
|
60
68
|
> {
|
|
61
69
|
/** The request's table — `(ctx) => posts(ctx)`, so the move is tenant-scoped like every write. */
|
|
62
70
|
readonly table: (ctx: Ctx) => TransitionTarget<Row, S>;
|
|
@@ -84,7 +92,30 @@ export interface TransitionDef<
|
|
|
84
92
|
* column's own shape.
|
|
85
93
|
*/
|
|
86
94
|
readonly id?: StringSchema;
|
|
87
|
-
readonly policy: ActionPolicy
|
|
95
|
+
readonly policy: ActionPolicy<TRow>;
|
|
96
|
+
/**
|
|
97
|
+
* The row a row-level `policy` decides about — an action's own `row` loader, handed through
|
|
98
|
+
* unchanged: loaded once, after the input parse, before the guard and before the statement. The
|
|
99
|
+
* factory reads no row itself; which columns a rule needs (an `authorId` the `output` view may not
|
|
100
|
+
* carry) is the app's. Omitted, the rule sees `row: null` — and must fail closed on it.
|
|
101
|
+
*
|
|
102
|
+
* The DECISION rides in the compare-and-set (#702). The rule is handed a view of the loaded row
|
|
103
|
+
* that records which properties it read; the move then carries the row and that list to
|
|
104
|
+
* `Table.transition` (`observed`), which pins every one that is a plain column of the moved row
|
|
105
|
+
* beside `id` and `from`. An `authorId` reassigned between the read and the move — committed
|
|
106
|
+
* first, or in flight and waited on — matches no statement: `X_STATE_CONFLICT`, naming the
|
|
107
|
+
* column, and the row does not move. A column the rule never read may change freely.
|
|
108
|
+
*
|
|
109
|
+
* What is NOT pinned, by entity's rules (`transition-pins.ts`): a row carrying a key that is not
|
|
110
|
+
* the moved row's (a loader that reads a parent record — a keyless projection IS this row), a
|
|
111
|
+
* property that names no column, and a `timestamptz`, `jsonb`, array, `bytea`, money or sealed
|
|
112
|
+
* column — none has an `=` that means one thing in both drivers. Nor, by this factory's: a column
|
|
113
|
+
* read INSIDE a getter or method of a class-shaped row (only the accessor's own name is recorded,
|
|
114
|
+
* and it runs against the unobserved row) — return a plain row from the loader. A rule that
|
|
115
|
+
* decides on any of those, or on another table's rows, decides on a read the statement cannot
|
|
116
|
+
* re-check.
|
|
117
|
+
*/
|
|
118
|
+
row?(args: ActionRowArgs<TransitionInput<S>>): TRow | null | Promise<TRow | null>;
|
|
88
119
|
/**
|
|
89
120
|
* OFF unless the app says otherwise, and deliberately not `?? true`.
|
|
90
121
|
*
|
|
@@ -110,7 +141,8 @@ export function transition<
|
|
|
110
141
|
Row extends InferOutput<TOutput> & object,
|
|
111
142
|
K extends keyof Row & string,
|
|
112
143
|
const S extends Row[K] & string,
|
|
113
|
-
|
|
144
|
+
TRow = unknown,
|
|
145
|
+
>(def: TransitionDef<TOutput, Row, K, S, TRow>): Mutator<TransitionInput<S>, TOutput> {
|
|
114
146
|
const state = t.enum(def.states);
|
|
115
147
|
// ONE cast, and it is a compiler limitation rather than an unknown value: `t.object`'s output is
|
|
116
148
|
// a mapped type over its shape, and a mapped type does not reduce while a type parameter is still
|
|
@@ -119,13 +151,19 @@ export function transition<
|
|
|
119
151
|
// reason. What arrives here has already been parsed by the schema two lines up, and nothing else
|
|
120
152
|
// can reach these two callbacks.
|
|
121
153
|
const valuesOf = (raw: unknown): TransitionValues<S> => raw as TransitionValues<S>;
|
|
122
|
-
|
|
154
|
+
const row = def.row;
|
|
155
|
+
return mutator<TransitionInput<S>, TOutput, TRow>({
|
|
123
156
|
input: t.object({ id: def.id ?? keySchemaOf(def.output), from: state, to: state }),
|
|
124
157
|
output: def.output,
|
|
125
158
|
policy: def.policy,
|
|
126
159
|
// A compare-and-set move replayed under its key answers the first outcome, never a second
|
|
127
160
|
// `X_STATE_CONFLICT` for a move that already happened.
|
|
128
161
|
idempotent: true,
|
|
162
|
+
// `.call(def, …)`, as `mutator()` hands its own on: a loader written as a method keeps `this`.
|
|
163
|
+
// The row is remembered for `server` below and the rule sees a view recording what it reads.
|
|
164
|
+
...(row === undefined
|
|
165
|
+
? {}
|
|
166
|
+
: { row: async (args) => observeRow(args.input, await row.call(def, args)) }),
|
|
129
167
|
...(def.audit === undefined ? {} : { audit: def.audit }),
|
|
130
168
|
// Never overridable: the server is the half that REFUSED the move, and a local twin that won
|
|
131
169
|
// the rebase would leave the client showing a state the database rejected.
|
|
@@ -143,9 +181,11 @@ export function transition<
|
|
|
143
181
|
// entity's and propagate as they are. A second error class over one failure is a second path.
|
|
144
182
|
server: (ctx, raw) => {
|
|
145
183
|
const input = valuesOf(raw);
|
|
184
|
+
const observed = takeObservation(raw);
|
|
146
185
|
return def.table(ctx).transition(def.column, input.id, {
|
|
147
186
|
from: input.from,
|
|
148
187
|
to: input.to,
|
|
188
|
+
...(observed === undefined ? {} : { observed }),
|
|
149
189
|
});
|
|
150
190
|
},
|
|
151
191
|
});
|
package/src/tx-scope.ts
CHANGED
|
@@ -40,7 +40,7 @@ export interface LiveTransaction extends CommitScope {
|
|
|
40
40
|
* settlement bound to a transaction that already ended is bound to nothing: it would run as its
|
|
41
41
|
* own commit while the record claimed otherwise, and that record is the one a retry may reclaim.
|
|
42
42
|
*
|
|
43
|
-
* The fragment is assembled by hand, exactly as `@ultimat3/
|
|
43
|
+
* The fragment is assembled by hand, exactly as `@ultimat3/db`'s `dbExecutor` does: the caller
|
|
44
44
|
* wrote the `$1..$n` text itself and hands over already-bound values.
|
|
45
45
|
*/
|
|
46
46
|
export function liveTransaction(): LiveTransaction | undefined {
|
package/src/type-pins.ts
CHANGED
|
@@ -92,6 +92,11 @@ export type _APreRedactionStoreIsNotAStore = Assert<
|
|
|
92
92
|
Equals<PreRedactionStore extends IdempotencyStore ? true : false, false>
|
|
93
93
|
>;
|
|
94
94
|
|
|
95
|
+
/** A release names the reservation it drops — the fence `settle` and `fail` already carry. */
|
|
96
|
+
export type _ReleaseNamesTheReservation = Assert<
|
|
97
|
+
Equals<Parameters<IdempotencyStore['release']>['length'], 2>
|
|
98
|
+
>;
|
|
99
|
+
|
|
95
100
|
/** …and `settle`'s flag is required of every CALLER, so a wrapping store cannot drop it either. */
|
|
96
101
|
export type _SettleRequiresTheRedactedFlag = Assert<
|
|
97
102
|
Equals<Parameters<IdempotencyStore['settle']>['length'], 4>
|