@ultimat3/action 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 +2 -2
- package/README.md +4 -3
- package/package.json +8 -8
- package/src/action.ts +1 -1
- package/src/client.ts +1 -1
- package/src/contract-test.ts +4 -4
- package/src/errors-idempotency.ts +1 -1
- package/src/errors.ts +4 -4
- package/src/invoke.ts +2 -0
- package/src/mutator.ts +1 -1
- package/src/transition-observe.ts +67 -0
- package/src/transition.ts +28 -8
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`; `row` goes to `mutator()` as declared, never loaded here (#687)
|
|
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
|
package/README.md
CHANGED
|
@@ -256,7 +256,7 @@ Under the default style:
|
|
|
256
256
|
| `checkout` (single word) | `POST /api/checkouts/invoke` | `checkout` |
|
|
257
257
|
|
|
258
258
|
**One name, three surfaces** — `openapi.json`'s `x-ultimate.mcpTool`,
|
|
259
|
-
`describeAction().mcp.tool` (what `x actions
|
|
259
|
+
`describeAction().mcp.tool` (what `x actions show --json`, `x actions list --json`, the
|
|
260
260
|
`actions.describe` dev MCP tool and the `/_x` Routes panel show) and the catalog `@ultimat3/mcp`
|
|
261
261
|
serves. It was two until 2026-08: a `toToolName()` here snake_cased the published ones to
|
|
262
262
|
`publish_post` while the server answered only `publishPost`, so an agent that read the published
|
|
@@ -410,7 +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
|
|
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 |
|
|
414
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 |
|
|
415
416
|
|
|
416
417
|
`table` is typed structurally (`TransitionTarget`), not imported — a real `Table` satisfies the
|
|
@@ -838,7 +839,7 @@ never a pass — the assertion says which code got in the way and names `input:`
|
|
|
838
839
|
| `X_ACTION_POLICY_MISSING` | registration without `policy:` | add `policy: can('…')` |
|
|
839
840
|
| `X_RATE_LIMIT_INVALID` | `rateLimit:` with a non-positive or non-finite half — `windowMs: 0` refills infinitely. Owned by `@ultimat3/http`, which owns the conversion | make both positive, or delete the block |
|
|
840
841
|
| `X_ACTION_DEPRECATION_INVALID` | `deprecated:` with a `since`/`sunset` that is not a date | use an ISO-8601 instant |
|
|
841
|
-
| `X_INPUT_INVALID` | input failed the Standard Schema. Carries the rejections **twice**: the flattened line in `cause`, and the structured list in `meta.issues` — one value rendered two ways, `As of 2026-08-24` | `x actions
|
|
842
|
+
| `X_INPUT_INVALID` | input failed the Standard Schema. Carries the rejections **twice**: the flattened line in `cause`, and the structured list in `meta.issues` — one value rendered two ways, `As of 2026-08-24` | `x actions show <name> --json` |
|
|
842
843
|
| `X_IDEMPOTENCY_CONFLICT` | key reused with a new payload / still in flight | new key, or retry later |
|
|
843
844
|
| `X_IDEMPOTENCY_KEY_INVALID` | `Idempotency-Key:` sent blank (`Headers.get()` answers `''`, not `null`) or past 255 characters | send one unique value per request, or omit the header |
|
|
844
845
|
| `X_IDEMPOTENCY_NOT_SHARED` | `configureIdempotency({ scope: 'shared' })` over a per-process (or scope-less) store | install `postgresIdempotencyStore({ executor, origin, reclaimAfterMs })` at boot |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/action",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "26.0.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": "
|
|
39
|
-
"@ultimat3/core": "
|
|
40
|
-
"@ultimat3/db": "
|
|
41
|
-
"@ultimat3/entity": "
|
|
42
|
-
"@ultimat3/http": "
|
|
43
|
-
"@ultimat3/policy": "
|
|
44
|
-
"@ultimat3/schema": "
|
|
38
|
+
"@ultimat3/cache": "26.0.0",
|
|
39
|
+
"@ultimat3/core": "26.0.0",
|
|
40
|
+
"@ultimat3/db": "26.0.0",
|
|
41
|
+
"@ultimat3/entity": "26.0.0",
|
|
42
|
+
"@ultimat3/http": "26.0.0",
|
|
43
|
+
"@ultimat3/policy": "26.0.0",
|
|
44
|
+
"@ultimat3/schema": "26.0.0"
|
|
45
45
|
}
|
|
46
46
|
}
|
package/src/action.ts
CHANGED
|
@@ -348,7 +348,7 @@ export function describeAction(target: AnyAction): ActionDescriptor {
|
|
|
348
348
|
// a first honest `expose: false` read as a withdrawn capability and demand a major bump.
|
|
349
349
|
expose: isMcpExposed(mcp),
|
|
350
350
|
// The export name verbatim, which is the only name `@ultimat3/mcp` will answer a
|
|
351
|
-
// `tools/call` for. A derived one made every descriptor reader — `x actions
|
|
351
|
+
// `tools/call` for. A derived one made every descriptor reader — `x actions show
|
|
352
352
|
// --json`, the `actions.describe` dev tool, the `/_x` panel — name a tool no surface
|
|
353
353
|
// serves. `x.manifest.json` is not among them: it copies `expose` and `description` only.
|
|
354
354
|
tool: name,
|
package/src/client.ts
CHANGED
|
@@ -224,7 +224,7 @@ function toUltimateError(
|
|
|
224
224
|
issues: issuesFromWire(body['issues']),
|
|
225
225
|
meta: metaFromWire(body['meta']),
|
|
226
226
|
cause: stringOr(body['cause'] ?? body['detail'], `${name} failed with ${status}`),
|
|
227
|
-
fix: stringOr(body['fix'], `x actions
|
|
227
|
+
fix: stringOr(body['fix'], `x actions show ${name} --json`),
|
|
228
228
|
// RFC-9457's `type` IS a documentation URI, so a server that sends no `docs` extension has
|
|
229
229
|
// still offered one. Both travel, in preference order: `??` picked `docs` on presence alone,
|
|
230
230
|
// so a `javascript:` one hid a perfectly good `type` behind it. `remoteDocs` takes the first
|
package/src/contract-test.ts
CHANGED
|
@@ -100,7 +100,7 @@ function assertDocumented(target: AnyAction, name: string): void {
|
|
|
100
100
|
/**
|
|
101
101
|
* The refusal when the published document does not name this action at its route. `name` is
|
|
102
102
|
* whatever `.named()` was handed — nothing validates its alphabet — so it is screened where it
|
|
103
|
-
* enters the `x actions
|
|
103
|
+
* enters the `x actions show` command (security audit of plan 101 sweep 1c).
|
|
104
104
|
*/
|
|
105
105
|
export function documentedDrift(
|
|
106
106
|
path: string,
|
|
@@ -113,7 +113,7 @@ export function documentedDrift(
|
|
|
113
113
|
: `OpenAPI document serves ${path} as ${owner}, so ${name} is not in the published contract`,
|
|
114
114
|
owner === undefined
|
|
115
115
|
? 'x verify --json # the contract suite is a step of it'
|
|
116
|
-
: `x actions
|
|
116
|
+
: `x actions show ${renderFixShellArg(name, '<action>')} --json # then rename it, or pin its own route with http: { path } in the ${name} definition`,
|
|
117
117
|
);
|
|
118
118
|
}
|
|
119
119
|
|
|
@@ -138,7 +138,7 @@ function assertSampleable(target: AnyAction, name: string): void {
|
|
|
138
138
|
const described = gaps.map((path) => describeSampleGap(target.input, path)).join(', ');
|
|
139
139
|
throw new ContractDriftError(
|
|
140
140
|
`${name}: no value can be synthesized for ${described}, so the denial would be unproven`,
|
|
141
|
-
`contractTestsFor(${name}, { input: { … } }) # x actions
|
|
141
|
+
`contractTestsFor(${name}, { input: { … } }) # x actions show ${name} --json prints the schema`,
|
|
142
142
|
);
|
|
143
143
|
}
|
|
144
144
|
|
|
@@ -196,7 +196,7 @@ async function expectDenied(
|
|
|
196
196
|
if (error.code !== 'X_INPUT_INVALID') throw error;
|
|
197
197
|
throw new ContractDriftError(
|
|
198
198
|
`${name} failed with ${error.code} before its policy decided, so the denial is unproven`,
|
|
199
|
-
`pass \`input:\` to contractTestsFor(${name}) — x actions
|
|
199
|
+
`pass \`input:\` to contractTestsFor(${name}) — x actions show ${name} --json prints the schema`,
|
|
200
200
|
);
|
|
201
201
|
}
|
|
202
202
|
throw new ContractDriftError(
|
|
@@ -185,7 +185,7 @@ export class IdempotentReplayRedactedError extends UltimateError {
|
|
|
185
185
|
super({
|
|
186
186
|
code: 'X_IDEMPOTENT_REPLAY_REDACTED',
|
|
187
187
|
cause: `the answer stored for idempotency key "${key}" had a secret-named field redacted when it settled, so it cannot be replayed faithfully`,
|
|
188
|
-
fix: "x actions
|
|
188
|
+
fix: "x actions show '<action>' --json # the call already ran and showed its secret once — read its effect with a query; a new Idempotency-Key runs it again (a second credential)",
|
|
189
189
|
meta: { key, replayed: true },
|
|
190
190
|
});
|
|
191
191
|
}
|
package/src/errors.ts
CHANGED
|
@@ -187,9 +187,9 @@ export class InputInvalidError extends UltimateError {
|
|
|
187
187
|
super({
|
|
188
188
|
code: 'X_INPUT_INVALID',
|
|
189
189
|
cause: `input for ${subject} "${name}" failed validation: ${detail}`,
|
|
190
|
-
fix: `x actions
|
|
190
|
+
fix: `x actions show ${renderFixShellArg(name, '<action-name>')} --json # prints the expected input schema`,
|
|
191
191
|
// A remote caller holds the published schema — the MCP `inputSchema`, the OpenAPI request
|
|
192
|
-
// body — and cannot run `x actions
|
|
192
|
+
// body — and cannot run `x actions show`.
|
|
193
193
|
callerFix:
|
|
194
194
|
'correct the fields named in cause to match the published input schema (the tool inputSchema, or the OpenAPI request body) and send again — the same input is refused the same way',
|
|
195
195
|
...(issues === undefined ? {} : { meta: { issues } }),
|
|
@@ -208,7 +208,7 @@ export class OutputInvalidError extends UltimateError {
|
|
|
208
208
|
super({
|
|
209
209
|
code: 'X_OUTPUT_INVALID',
|
|
210
210
|
cause: `action "${name}" returned a value its output schema rejects: ${detail}`,
|
|
211
|
-
fix: `x actions
|
|
211
|
+
fix: `x actions show ${renderFixShellArg(name, '<action-name>')} --json # compare the handler's return against \`output:\``,
|
|
212
212
|
});
|
|
213
213
|
}
|
|
214
214
|
}
|
|
@@ -353,7 +353,7 @@ export class RpcFailedError extends UltimateError {
|
|
|
353
353
|
super({
|
|
354
354
|
code: 'X_RPC_FAILED',
|
|
355
355
|
cause: `${name} returned HTTP ${status} without a problem+json body`,
|
|
356
|
-
fix: `check the gateway in front of the app, then: x actions
|
|
356
|
+
fix: `check the gateway in front of the app, then: x actions show ${renderFixShellArg(name, '<action-name>')} --json`,
|
|
357
357
|
retry: retryForStatus('X_RPC_FAILED', status),
|
|
358
358
|
});
|
|
359
359
|
}
|
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/mutator.ts
CHANGED
|
@@ -226,7 +226,7 @@ function wrap<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>
|
|
|
226
226
|
return self;
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
/** The descriptor's name for a policy — the manifest and `x actions
|
|
229
|
+
/** The descriptor's name for a policy — the manifest and `x actions show` print this. */
|
|
230
230
|
function strategyOf(conflict: ConflictPolicy): MutatorDescriptor['conflict'] {
|
|
231
231
|
return typeof conflict === 'string' ? conflict : conflict.kind;
|
|
232
232
|
}
|
|
@@ -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,
|
|
@@ -21,6 +22,7 @@ import { nodeOf, t } from '@ultimat3/schema';
|
|
|
21
22
|
import type { ActionRowArgs } from './action';
|
|
22
23
|
import { type Mutator, mutator } from './mutator';
|
|
23
24
|
import type { ActionPolicy } from './policy-gate';
|
|
25
|
+
import { observeRow, takeObservation } from './transition-observe';
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* The one method this factory calls, declared structurally: `@ultimat3/entity`'s `Table.transition`
|
|
@@ -50,7 +52,11 @@ export type TransitionInput<S extends string> = ObjectSchema<{
|
|
|
50
52
|
}>;
|
|
51
53
|
|
|
52
54
|
export interface TransitionTarget<Row, S extends string> {
|
|
53
|
-
transition(
|
|
55
|
+
transition(
|
|
56
|
+
column: string,
|
|
57
|
+
id: string,
|
|
58
|
+
move: { readonly from: S; readonly to: S; readonly observed?: TransitionObservation },
|
|
59
|
+
): Promise<Row>;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
export interface TransitionDef<
|
|
@@ -93,12 +99,21 @@ export interface TransitionDef<
|
|
|
93
99
|
* factory reads no row itself; which columns a rule needs (an `authorId` the `output` view may not
|
|
94
100
|
* carry) is the app's. Omitted, the rule sees `row: null` — and must fail closed on it.
|
|
95
101
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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.
|
|
102
117
|
*/
|
|
103
118
|
row?(args: ActionRowArgs<TransitionInput<S>>): TRow | null | Promise<TRow | null>;
|
|
104
119
|
/**
|
|
@@ -145,7 +160,10 @@ export function transition<
|
|
|
145
160
|
// `X_STATE_CONFLICT` for a move that already happened.
|
|
146
161
|
idempotent: true,
|
|
147
162
|
// `.call(def, …)`, as `mutator()` hands its own on: a loader written as a method keeps `this`.
|
|
148
|
-
|
|
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)) }),
|
|
149
167
|
...(def.audit === undefined ? {} : { audit: def.audit }),
|
|
150
168
|
// Never overridable: the server is the half that REFUSED the move, and a local twin that won
|
|
151
169
|
// the rebase would leave the client showing a state the database rejected.
|
|
@@ -163,9 +181,11 @@ export function transition<
|
|
|
163
181
|
// entity's and propagate as they are. A second error class over one failure is a second path.
|
|
164
182
|
server: (ctx, raw) => {
|
|
165
183
|
const input = valuesOf(raw);
|
|
184
|
+
const observed = takeObservation(raw);
|
|
166
185
|
return def.table(ctx).transition(def.column, input.id, {
|
|
167
186
|
from: input.from,
|
|
168
187
|
to: input.to,
|
|
188
|
+
...(observed === undefined ? {} : { observed }),
|
|
169
189
|
});
|
|
170
190
|
},
|
|
171
191
|
});
|