@ultimat3/action 20.2.1 → 22.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 +169 -546
- package/README.md +61 -12
- package/package.json +7 -6
- package/src/client.ts +59 -85
- package/src/deprecation.ts +0 -5
- package/src/errors.ts +34 -2
- package/src/http.ts +67 -37
- package/src/index.ts +1 -4
- package/src/invoke.ts +4 -1
- package/src/mutator-clock.ts +22 -0
- package/src/mutator.ts +76 -59
- package/src/naming.ts +9 -54
- package/src/record-wire.ts +32 -0
- package/src/transition.ts +3 -3
- package/src/type-pins.ts +16 -0
- package/src/wire-headers.ts +5 -2
package/src/mutator.ts
CHANGED
|
@@ -5,18 +5,22 @@
|
|
|
5
5
|
* contract tests for free, and its authz is the same single evaluation.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type { Ctx } from '@ultimat3/core';
|
|
9
|
-
import { assertNever } from '@ultimat3/core';
|
|
8
|
+
import type { ConflictPolicy, Ctx, Row } from '@ultimat3/core';
|
|
10
9
|
import type { InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
11
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
Action,
|
|
12
|
+
ActionCache,
|
|
13
|
+
ActionDef,
|
|
14
|
+
ActionDescriptor,
|
|
15
|
+
ActionMcp,
|
|
16
|
+
ActionRateLimit,
|
|
17
|
+
ActionRowArgs,
|
|
18
|
+
} from './action';
|
|
12
19
|
import { action, isAction } from './action';
|
|
20
|
+
import type { Deprecation } from './deprecation';
|
|
21
|
+
import { assertConflictClock } from './mutator-clock';
|
|
13
22
|
import type { ActionPolicy } from './policy-gate';
|
|
14
23
|
|
|
15
|
-
/** Minimum shape of a locally-stored row: an id the local twin can address. */
|
|
16
|
-
export interface LocalRow {
|
|
17
|
-
readonly id: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
24
|
/**
|
|
21
25
|
* Augmented by the app so `tx.posts` is typed:
|
|
22
26
|
*
|
|
@@ -33,43 +37,69 @@ export interface LocalTables {
|
|
|
33
37
|
|
|
34
38
|
export type LocalTableName = Exclude<keyof LocalTables, '~ultimate'>;
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
/**
|
|
41
|
+
* One table as a mutator's `local` half sees it — the SAME shape as `@ultimat3/realtime`'s store tx
|
|
42
|
+
* (`record-tx.ts`), so a twin typed against this runs against the page's record store unchanged.
|
|
43
|
+
* Rows are addressed by KEY, never by a column: the browser holds no entity schema and cannot know
|
|
44
|
+
* a primary key, so an optimistic insert names the key its server twin will answer under.
|
|
45
|
+
*/
|
|
46
|
+
export interface LocalTable<TRow extends object = Row> {
|
|
47
|
+
get(key: string): TRow | undefined;
|
|
48
|
+
all(): readonly TRow[];
|
|
49
|
+
insert(key: string, row: TRow): void;
|
|
50
|
+
/** Merged over what the table holds; an `undefined` field leaves the column alone. */
|
|
51
|
+
upsert(key: string, row: TRow): void;
|
|
52
|
+
/** Changed fields only — or a function returning them. A no-op for a key the table does not hold. */
|
|
53
|
+
update(key: string, patch: Partial<TRow> | ((row: TRow) => Partial<TRow>)): void;
|
|
54
|
+
delete(key: string): void;
|
|
40
55
|
}
|
|
41
56
|
|
|
42
57
|
/**
|
|
43
|
-
* The client-side write surface a mutator's `local()` gets.
|
|
44
|
-
*
|
|
58
|
+
* The client-side write surface a mutator's `local()` gets. `@ultimat3/realtime` implements it over
|
|
59
|
+
* the page's record store; tests implement it over a Map.
|
|
45
60
|
*/
|
|
46
61
|
export type LocalTx = {
|
|
47
|
-
readonly [K in LocalTableName]: LocalTable<Extract<LocalTables[K],
|
|
62
|
+
readonly [K in LocalTableName]: LocalTable<Extract<LocalTables[K], object>>;
|
|
48
63
|
} & {
|
|
49
64
|
/** Escape hatch for generated code that only knows the table name as a string. */
|
|
50
|
-
table<TRow extends
|
|
65
|
+
table<TRow extends object = Row>(name: string): LocalTable<TRow>;
|
|
51
66
|
};
|
|
52
67
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
68
|
+
/**
|
|
69
|
+
* `conflict: custom(merge)` — core's row-shaped `ConflictPolicy`, built. `merge` receives the local
|
|
70
|
+
* ROW and the server ROW, because the client store is row-shaped: the output-shaped variant this
|
|
71
|
+
* replaced was dropped silently by realtime's rebase, which only ever had rows to hand it.
|
|
72
|
+
*
|
|
73
|
+
* `TRow` is the app's declared row shape, a caller-side annotation only — at rebase the resolver
|
|
74
|
+
* hands over whatever the store holds for that record, which is the entity's row.
|
|
75
|
+
*/
|
|
76
|
+
export function custom<TRow extends object = Row>(
|
|
77
|
+
merge: (local: TRow, server: TRow) => TRow,
|
|
78
|
+
): ConflictPolicy {
|
|
79
|
+
// The one widening in the vocabulary: core's policy is over `Row`, the app's merge over its row.
|
|
80
|
+
return { kind: 'custom', merge: merge as unknown as (local: Row, server: Row) => Row };
|
|
64
81
|
}
|
|
65
82
|
|
|
66
|
-
export interface MutatorDef<
|
|
83
|
+
export interface MutatorDef<
|
|
84
|
+
TInput extends StandardSchemaV1,
|
|
85
|
+
TOutput extends StandardSchemaV1,
|
|
86
|
+
TRow = unknown,
|
|
87
|
+
> {
|
|
67
88
|
readonly input: TInput;
|
|
68
89
|
readonly output: TOutput;
|
|
69
|
-
readonly policy: ActionPolicy
|
|
90
|
+
readonly policy: ActionPolicy<TRow>;
|
|
70
91
|
readonly cache?: ActionCache;
|
|
71
92
|
readonly mcp?: ActionMcp;
|
|
93
|
+
/** Same key, same meaning as an action's — a mutator IS an action. */
|
|
94
|
+
readonly rateLimit?: ActionRateLimit;
|
|
95
|
+
/** Same key, same meaning as an action's: `Deprecation`/`Sunset` on every response. */
|
|
96
|
+
readonly deprecated?: Deprecation;
|
|
72
97
|
readonly idempotent?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* The row a row-level `policy` decides about — an action's `row`, and dropped on the way into
|
|
100
|
+
* the action until 2026-09-23, so such a policy received `row === null` and denied every call.
|
|
101
|
+
*/
|
|
102
|
+
row?(args: ActionRowArgs<TInput>): TRow | null | Promise<TRow | null>;
|
|
73
103
|
/**
|
|
74
104
|
* Record every attempt through the installed `AuditSink`. Same key, same meaning as an
|
|
75
105
|
* action's — a mutator IS an action, so it inherits the seam rather than getting a second one.
|
|
@@ -84,7 +114,7 @@ export interface MutatorDef<TInput extends StandardSchemaV1, TOutput extends Sta
|
|
|
84
114
|
ctx: Ctx,
|
|
85
115
|
input: InferOutput<TInput>,
|
|
86
116
|
): Promise<InferOutput<TOutput>> | InferOutput<TOutput>;
|
|
87
|
-
readonly conflict:
|
|
117
|
+
readonly conflict: ConflictPolicy;
|
|
88
118
|
}
|
|
89
119
|
|
|
90
120
|
export type MutatorDescriptor = Omit<ActionDescriptor, 'kind'> & {
|
|
@@ -102,7 +132,7 @@ export interface Mutator<
|
|
|
102
132
|
* Renaming it here would silently turn every mutator back into a plain action downstream.
|
|
103
133
|
*/
|
|
104
134
|
readonly isMutator: true;
|
|
105
|
-
readonly conflict:
|
|
135
|
+
readonly conflict: ConflictPolicy;
|
|
106
136
|
/**
|
|
107
137
|
* Applied on the client before the server round trip, and replayed on every
|
|
108
138
|
* rebase — so it must stay a pure function of `(tx, input)`: no I/O, no clock,
|
|
@@ -120,19 +150,27 @@ export interface Mutator<
|
|
|
120
150
|
named(name: string): Mutator<TInput, TOutput>;
|
|
121
151
|
}
|
|
122
152
|
|
|
123
|
-
export function mutator<
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
153
|
+
export function mutator<
|
|
154
|
+
TInput extends StandardSchemaV1,
|
|
155
|
+
TOutput extends StandardSchemaV1,
|
|
156
|
+
TRow = unknown,
|
|
157
|
+
>(def: MutatorDef<TInput, TOutput, TRow>): Mutator<TInput, TOutput> {
|
|
158
|
+
const row = def.row;
|
|
159
|
+
const actionDef: ActionDef<TInput, TOutput, TRow> = {
|
|
127
160
|
input: def.input,
|
|
128
161
|
output: def.output,
|
|
129
162
|
policy: def.policy,
|
|
130
163
|
...(def.cache === undefined ? {} : { cache: def.cache }),
|
|
131
164
|
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
165
|
+
...(def.rateLimit === undefined ? {} : { rateLimit: def.rateLimit }),
|
|
166
|
+
...(def.deprecated === undefined ? {} : { deprecated: def.deprecated }),
|
|
167
|
+
...(row === undefined ? {} : { row: (args: ActionRowArgs<TInput>) => row.call(def, args) }),
|
|
132
168
|
...(def.idempotent === undefined ? {} : { idempotent: def.idempotent }),
|
|
133
169
|
...(def.audit === undefined ? {} : { audit: def.audit }),
|
|
134
170
|
handle: ({ input, ctx }) => def.server(ctx, input),
|
|
135
171
|
};
|
|
172
|
+
// Before the action is built: a policy that cannot do what it says is refused at declaration.
|
|
173
|
+
if (def.conflict === 'last-write-wins') assertConflictClock(def.output);
|
|
136
174
|
return wrap(def, action(actionDef));
|
|
137
175
|
}
|
|
138
176
|
|
|
@@ -174,28 +212,7 @@ function wrap<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>
|
|
|
174
212
|
return self;
|
|
175
213
|
}
|
|
176
214
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
return typeof conflict === 'string' ? conflict : conflict.strategy;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Rebase decision for @ultimat3/realtime: which value survives when the local
|
|
185
|
-
* twin and the server disagree.
|
|
186
|
-
*/
|
|
187
|
-
export function resolveConflict<TOutput>(
|
|
188
|
-
conflict: Conflict<TOutput>,
|
|
189
|
-
local: TOutput,
|
|
190
|
-
server: TOutput,
|
|
191
|
-
): TOutput {
|
|
192
|
-
if (typeof conflict !== 'string') return conflict.merge(local, server);
|
|
193
|
-
switch (conflict) {
|
|
194
|
-
case 'server-wins':
|
|
195
|
-
return server;
|
|
196
|
-
case 'last-write-wins':
|
|
197
|
-
return local;
|
|
198
|
-
default:
|
|
199
|
-
return assertNever(conflict);
|
|
200
|
-
}
|
|
215
|
+
/** The descriptor's name for a policy — the manifest and `x actions describe` print this. */
|
|
216
|
+
function strategyOf(conflict: ConflictPolicy): MutatorDescriptor['conflict'] {
|
|
217
|
+
return typeof conflict === 'string' ? conflict : conflict.kind;
|
|
201
218
|
}
|
package/src/naming.ts
CHANGED
|
@@ -5,70 +5,25 @@
|
|
|
5
5
|
* derived by nothing — it is the export name verbatim.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
person: 'people',
|
|
11
|
-
child: 'children',
|
|
12
|
-
man: 'men',
|
|
13
|
-
woman: 'women',
|
|
14
|
-
datum: 'data',
|
|
15
|
-
index: 'indexes',
|
|
16
|
-
entry: 'entries',
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export interface ActionPath {
|
|
20
|
-
/** First camelCase word, kebab-cased. `publishPost` -> `publish`. */
|
|
21
|
-
readonly verb: string;
|
|
22
|
-
/** Remaining words, last one pluralized, kebab-cased. `publishPost` -> `posts`. */
|
|
23
|
-
readonly resource: string;
|
|
24
|
-
/** `POST /api/<resource>/<verb>`. */
|
|
25
|
-
readonly path: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** camelCase / PascalCase / SCREAMING_SNAKE -> lowercase words. */
|
|
29
|
-
export function splitWords(name: string): string[] {
|
|
30
|
-
return name
|
|
31
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
32
|
-
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
|
|
33
|
-
.split(/[\s_-]+/)
|
|
34
|
-
.filter((word) => word.length > 0)
|
|
35
|
-
.map((word) => word.toLowerCase());
|
|
36
|
-
}
|
|
8
|
+
import type { ActionRoute } from '@ultimat3/core';
|
|
9
|
+
import { actionRoute, splitWords } from '@ultimat3/core';
|
|
37
10
|
|
|
38
11
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
12
|
+
* The path rule is `@ultimat3/core`'s `client-paths.ts` — the typed client, the route and the
|
|
13
|
+
* spec derive one URL from one function. Re-exported by name so `@ultimat3/action`'s public
|
|
14
|
+
* `derivePath` / `pluralize` / `splitWords` / `ActionPath` keep resolving; never re-declared here.
|
|
41
15
|
*/
|
|
42
|
-
export
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// which keeps `toString` out of reach and lets `constructor` straight through. `pluralize` is
|
|
46
|
-
// public API returning `string`, and `derivePath` publishes what it answers as the action's
|
|
47
|
-
// HTTP path, its OpenAPI `paths` key and its `tags` entry.
|
|
48
|
-
if (Object.hasOwn(IRREGULAR, word)) return IRREGULAR[word] ?? word;
|
|
49
|
-
if (word.endsWith('s')) return word;
|
|
50
|
-
if (/(x|z|ch|sh)$/.test(word)) return `${word}es`;
|
|
51
|
-
if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`;
|
|
52
|
-
return `${word}s`;
|
|
53
|
-
}
|
|
16
|
+
export { pluralize, splitWords } from '@ultimat3/core';
|
|
17
|
+
|
|
18
|
+
export type ActionPath = ActionRoute;
|
|
54
19
|
|
|
55
20
|
/**
|
|
56
21
|
* `publishPost` -> POST /api/posts/publish
|
|
57
22
|
* `updateUserProfile` -> POST /api/user-profiles/update
|
|
58
|
-
* `likePost` -> POST /api/posts/like
|
|
59
23
|
* `checkout` -> POST /api/checkouts/invoke (single-word fallback)
|
|
60
24
|
*/
|
|
61
25
|
export function derivePath(name: string): ActionPath {
|
|
62
|
-
|
|
63
|
-
const head = words[0] ?? 'invoke';
|
|
64
|
-
if (words.length < 2) {
|
|
65
|
-
const resource = pluralize(head);
|
|
66
|
-
return { verb: 'invoke', resource, path: `/api/${resource}/invoke` };
|
|
67
|
-
}
|
|
68
|
-
const nouns = words.slice(1);
|
|
69
|
-
const last = nouns[nouns.length - 1] ?? head;
|
|
70
|
-
const resource = [...nouns.slice(0, -1), pluralize(last)].join('-');
|
|
71
|
-
return { verb: head, resource, path: `/api/${resource}/${head}` };
|
|
26
|
+
return actionRoute(name);
|
|
72
27
|
}
|
|
73
28
|
|
|
74
29
|
// There is deliberately no `toToolName`. An MCP tool name is the export name verbatim — the one
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record envelope, on the action's HTTP projection only. Derived from the output schema — an
|
|
3
|
+
* action whose output references an entity row answers `{ data, records }` under
|
|
4
|
+
* `x-ultimate-records: 1`; every other action's body is byte-identical to what it always was.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { encodeRecordEnvelope, RECORDS_HEADER } from '@ultimat3/core';
|
|
8
|
+
import { hasEntityRows, rowsOf } from '@ultimat3/entity';
|
|
9
|
+
import { json } from '@ultimat3/http';
|
|
10
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
11
|
+
|
|
12
|
+
/** The header value that says "this body is an envelope". Presence alone is not enough. */
|
|
13
|
+
const ENVELOPED = '1';
|
|
14
|
+
|
|
15
|
+
// The OpenAPI half — the 200 body and its header — is core's `recordEnvelopeSchema` and
|
|
16
|
+
// `RECORDS_OPENAPI_HEADER`, shared with `@ultimat3/query` so both projections describe one shape.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Decided ONCE per action, at projection, from the schema — never per response from the data.
|
|
20
|
+
* A body whose shape depended on whether this call happened to return rows would need two
|
|
21
|
+
* OpenAPI shapes for one operation; a schema-derived answer needs exactly one.
|
|
22
|
+
*/
|
|
23
|
+
export function carriesRecords(output: StandardSchemaV1): boolean {
|
|
24
|
+
return hasEntityRows(output);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The 200 for an action that carries records: the envelope, and the header that names it. */
|
|
28
|
+
export function recordResponse(output: StandardSchemaV1, result: unknown): Response {
|
|
29
|
+
const response = json(encodeRecordEnvelope(result, rowsOf(output, result)));
|
|
30
|
+
response.headers.set(RECORDS_HEADER, ENVELOPED);
|
|
31
|
+
return response;
|
|
32
|
+
}
|
package/src/transition.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type {
|
|
|
18
18
|
StringSchema,
|
|
19
19
|
} from '@ultimat3/schema';
|
|
20
20
|
import { t } from '@ultimat3/schema';
|
|
21
|
-
import { type
|
|
21
|
+
import { type Mutator, mutator } from './mutator';
|
|
22
22
|
import type { ActionPolicy } from './policy-gate';
|
|
23
23
|
|
|
24
24
|
/**
|
|
@@ -126,9 +126,9 @@ export function transition<
|
|
|
126
126
|
const input = valuesOf(raw);
|
|
127
127
|
// `as Partial<…>`: a computed key widens to an index signature, which is never assignable to
|
|
128
128
|
// a `Partial` of a type parameter. `def.column` is `keyof Row`, so the shape is a real one.
|
|
129
|
-
tx.table<Row
|
|
129
|
+
tx.table<Row>(def.localTable).update(input.id, {
|
|
130
130
|
[def.column]: input.to,
|
|
131
|
-
} as Partial<Row
|
|
131
|
+
} as Partial<Row>);
|
|
132
132
|
},
|
|
133
133
|
// No cast on `from`/`to`: they are the enum's own union, which is `Row[K]`. And no legality
|
|
134
134
|
// check here — `X_STATE_TRANSITION_ILLEGAL`, `X_STATE_CONFLICT` and `X_STATE_UNDECLARED` are
|
package/src/type-pins.ts
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// type-level claim written in one can never fail. This module emits nothing and exports nothing
|
|
4
4
|
// anybody imports — a regression here is a build error, the only enforcement that counts.
|
|
5
5
|
|
|
6
|
+
import type { Row } from '@ultimat3/core';
|
|
6
7
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
7
8
|
import type { Action, AnyAction } from './action';
|
|
8
9
|
import type { ClientMethod } from './client';
|
|
9
10
|
import type { ActionJobHandle } from './job-handle';
|
|
11
|
+
import type { LocalTable } from './mutator';
|
|
10
12
|
|
|
11
13
|
/** Fails to compile when `T` is anything but `true`. The whole mechanism. */
|
|
12
14
|
type Assert<T extends true> = T;
|
|
@@ -43,3 +45,17 @@ export type _ErasedClientIsNotASupertype = Assert<
|
|
|
43
45
|
? false
|
|
44
46
|
: true
|
|
45
47
|
>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A mutator's `tx` table is `@ultimat3/realtime`'s store tx, member for member — the store is what
|
|
51
|
+
* a twin runs against, so a member here it lacks is a twin that typechecks and throws. Pinned by
|
|
52
|
+
* the member set because realtime (tier 3, sideways) cannot be imported to compare the types.
|
|
53
|
+
*/
|
|
54
|
+
export type _LocalTableIsTheStoreTxShape = Assert<
|
|
55
|
+
Equals<keyof LocalTable, 'get' | 'all' | 'insert' | 'upsert' | 'update' | 'delete'>
|
|
56
|
+
>;
|
|
57
|
+
|
|
58
|
+
/** Addressed by KEY: an insert names the key its server twin answers under, never a column. */
|
|
59
|
+
export type _LocalInsertTakesTheKey = Assert<
|
|
60
|
+
Equals<Parameters<LocalTable['insert']>, [key: string, row: Row]>
|
|
61
|
+
>;
|
package/src/wire-headers.ts
CHANGED
|
@@ -13,5 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
export const BUILD_ID_HEADER = 'x-ultimate-build';
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* RFC 9110's spelling, lower-cased. Core's, re-exported by name: `clientTransport` sets it and
|
|
18
|
+
* this package's route reads it, so a second declaration here would be two answers to one header.
|
|
19
|
+
*/
|
|
20
|
+
export { IDEMPOTENCY_HEADER } from '@ultimat3/core';
|