@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/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 { Action, ActionCache, ActionDef, ActionDescriptor, ActionMcp } from './action';
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
- export interface LocalTable<TRow extends LocalRow> {
37
- insert(row: TRow): void;
38
- update(id: string, patch: Partial<TRow> | ((row: TRow) => Partial<TRow>)): void;
39
- delete(id: string): void;
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. @ultimat3/realtime
44
- * implements it over OPFS SQLite; tests implement it over a Map.
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], LocalRow>>;
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 LocalRow>(name: string): LocalTable<TRow>;
65
+ table<TRow extends object = Row>(name: string): LocalTable<TRow>;
51
66
  };
52
67
 
53
- export interface CustomConflict<TOutput> {
54
- readonly strategy: 'custom';
55
- merge(local: TOutput, server: TOutput): TOutput;
56
- }
57
-
58
- export type Conflict<TOutput> = 'server-wins' | 'last-write-wins' | CustomConflict<TOutput>;
59
-
60
- export function custom<TOutput>(
61
- merge: (local: TOutput, server: TOutput) => TOutput,
62
- ): CustomConflict<TOutput> {
63
- return { strategy: 'custom', merge };
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<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1> {
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: Conflict<InferOutput<TOutput>>;
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: Conflict<InferOutput<TOutput>>;
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<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
124
- def: MutatorDef<TInput, TOutput>,
125
- ): Mutator<TInput, TOutput> {
126
- const actionDef: ActionDef<TInput, TOutput> = {
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
- export function strategyOf<TOutput>(
178
- conflict: Conflict<TOutput>,
179
- ): 'server-wins' | 'last-write-wins' | 'custom' {
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
- /** Irregular plurals we actually hit in domain models. Extend deliberately, not eagerly. */
9
- const IRREGULAR: Readonly<Record<string, string>> = {
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
- * Naive-on-purpose English pluralizer. A word that already ends in `s` is left
40
- * alone, so `publishPosts` and `publishPost` agree on the `posts` resource.
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 function pluralize(word: string): string {
43
- // `Object.hasOwn`, never a truthiness check on the read: `IRREGULAR['constructor']` is the
44
- // `Object` FUNCTION off the prototype chain, not `undefined`, and `splitWords` lowercases —
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
- const words = splitWords(name);
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 LocalRow, type Mutator, mutator } from './mutator';
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 & LocalRow>(def.localTable).update(input.id, {
129
+ tx.table<Row>(def.localTable).update(input.id, {
130
130
  [def.column]: input.to,
131
- } as Partial<Row & LocalRow>);
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
+ >;
@@ -13,5 +13,8 @@
13
13
  */
14
14
  export const BUILD_ID_HEADER = 'x-ultimate-build';
15
15
 
16
- /** RFC 9110's spelling, lower-cased, as `Headers` normalises it. */
17
- export const IDEMPOTENCY_HEADER = 'idempotency-key';
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';