@ultimat3/action 22.15.0 → 23.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 CHANGED
@@ -18,11 +18,13 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
18
18
  | `facade.ts` | the fluent surface — binds each projection to the action, re-implements none |
19
19
  | `mutator.ts` | action + optimistic `.local` twin + authoritative `.server` + `.conflict` |
20
20
  | `registry.ts` | export-name registration, collisions, `describeActions()` |
21
- | `define-api.ts` | `defineApi({ actions, mutators, queries, llm, jobs, tasks })` — the app's one boot call |
21
+ | `define-api.ts` | `defineApi({ actions, mutators, queries, llm, jobs, tasks })` — the app's one boot call, and the `Api` type the typed clients are shaped from |
22
+ | `client-scale-pins.ts` | compile-time pin: `rpc<Api['actions']>` over 300 actions in 100 modules, 100 reads in 50 |
22
23
  | `http.ts` | route projection (`enforcedBy: 'handler'`) + OpenAPI operation |
23
24
  | `openapi.ts` | deterministic OpenAPI 3.1 document |
24
25
  | `openapi-complete.ts` | the COMPLETE document (`defineApi({ openapi })`: info, servers, security schemes, 401/429 by the ROUTE's `meta.auth`) and a bearer mount's own cut |
25
26
  | `http-path.ts` | where an action is served: the app's `pathStyle` + a per-action `http.path` pin, read lazily by every projection |
27
+ | `path-style-miss.ts` | `explainActionPathMiss` — `@ultimat3/http`'s `hooks.explainMiss`: a POST under the style this app does not serve is `X_CONTRACT_DRIFT` naming the one it does |
26
28
  | `api-declaration.ts` | `defineApi({ http, openapi })` held process-wide for the boot and `x manifest` |
27
29
  | `errors-http.ts` | the three HTTP-declaration refusals (codes registered in `errors.ts`) |
28
30
  | `client.ts` | typed RPC client (browser-safe: no server imports) — dispatches through core's `clientTransport` |
@@ -77,8 +79,21 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
77
79
  `'resource'` stays the default (changing it moves every URL). `defineApi` sets the style FIRST and
78
80
  `configureActionPathStyle` re-derives every seated action (the module scan may have seated them
79
81
  under the default). `derivePath(name)` is pin-aware through `installPinLookup` — handed down by
80
- `registry.ts`, never imported up (cycle through `action.ts`). `rpc()` cannot see a pin: it takes
81
- `pathStyle`; `action.client()` holds the pin.
82
+ `registry.ts`, never imported up (cycle through `action.ts`). `rpc()` cannot see a pin;
83
+ `action.client()` holds it.
84
+ - **The style is stated ONCE, on the server.** A browser never restates it: the document carries
85
+ `<meta name="ultimate-path-style">` (render's `clientPathStyleTags`, written only for a
86
+ non-default style) and core's `actionPath(name)` reads it for every caller naming none — `rpc`,
87
+ realtime's `useMutation` and the outbox replay. `client.ts` passes `options.pathStyle` through
88
+ and adds NO default of its own. `ClientOptions.pathStyle` is for a caller with no document; a
89
+ wrong one is answered `X_CONTRACT_DRIFT` (404) by `explainActionPathMiss`, never
90
+ `X_ROUTE_NOT_FOUND`. A pinned action and a path under the SERVED style are never explained.
91
+ - **`Merge` in `define-api.ts` never recurses over the module list.** `Head & Merge<Rest>` is not
92
+ a tail call: it cost one instantiation level per MODULE and answered TS2589 at the 48th entry of
93
+ one list, whatever each exported (#534). The list is read as a union and intersected in one
94
+ step. `client-scale-pins.ts` is the build error; `client-scale.contract.test.ts` compiles real
95
+ `action()`/`query()` modules through `defineApi`'s `const` inference; `@ultimat3/query` pins
96
+ `QueryClient` itself.
82
97
  - **`defineApi({ openapi })` opts into the complete document; absent, `openapi.json`'s bytes are
83
98
  unchanged** — an upgrade must not make a committed contract stale by itself.
84
99
  - Registration names the action the app exported, in place. Naming an already-named action is the
@@ -168,7 +183,9 @@ Owns the `action` + `mutator` primitives and their six projections. Tier 3.
168
183
  | `rpc` | 18,097 B | 23,007 B | 18,119 B | 19,074 B |
169
184
  | `rpc` + `createClientFlight` | 23,903 B | 28,823 B | not measured | 25,197 B |
170
185
 
171
- Net against the pre-transport figure: +977 B, about the envelope decoder's size.
186
+ Net against the pre-transport figure: +977 B, about the envelope decoder's size. `As of 2026-10-01`
187
+ `rpc` is 19,671 B (19,490 B before the document's path-style stamp was read: +181 B, +45 gzipped)
188
+ and `rpc` + `createClientFlight` 25,954 B.
172
189
  - **The `sideEffects` array is load-bearing**: `errors.ts` runs `registerErrorCodes` at import. Never `false`.
173
190
 
174
191
  ## Invariants — idempotency
package/README.md CHANGED
@@ -113,8 +113,10 @@ export const client = rpc<Api['actions']>({ baseUrl: '/' });
113
113
  ```
114
114
 
115
115
  `Api['actions']` is the merged module type, so `client.publishPost` is typed from the
116
- declaration with no codegen step. `Api` is imported as a **type only**, which is what keeps
117
- a page's module graph free of any edge to a feature's implementation.
116
+ declaration with no codegen step — at any app size: `client-scale-pins.ts` compiles the idiom over
117
+ 300 actions in 100 modules on every `typecheck` (the limit was 47 modules in one list, #534).
118
+ `x new` writes this file as `apps/web/shared/browser-client.ts`. `Api` is imported as a **type
119
+ only**, which is what keeps a page's module graph free of any edge to a feature's implementation.
118
120
 
119
121
  Every call dispatches through `@ultimat3/core`'s `clientTransport` — the one browser HTTP function.
120
122
  It owns credentials, the JSON body, the `Idempotency-Key` header, the principal fence and the
@@ -157,8 +159,8 @@ await api.charge({ orderId }, { idempotencyKey: `charge:${orderId}`, retry: { at
157
159
  It shipped as a byte-identical copy in each; the copies are gone and every name is importable from
158
160
  this package exactly as before.
159
161
 
160
- Importing `rpc` alone from this package is **23,007 B** minified for the browser; adding
161
- `createClientFlight` is **28,823 B** (`As of 2026-09-22`; `CLAUDE.md` carries the before/after and
162
+ Importing `rpc` alone from this package is **19,671 B** minified for the browser; adding
163
+ `createClientFlight` is **25,954 B** (`As of 2026-10-01`; `CLAUDE.md` carries the before/after and
162
164
  what the delta is). `ClientFlight` is a TYPE inside `client.ts` and never a value, which is what
163
165
  keeps the second number off the first caller's bill.
164
166
 
@@ -182,9 +184,15 @@ One rule per app, declared once — `defineApi({ http: { pathStyle } })` — plu
182
184
  `/_x` (`X_ACTION_HTTP_PATH_INVALID`). A pin colliding with another path is
183
185
  `X_ACTION_PATH_DUPLICATE` at boot.
184
186
  - Every server-side reader agrees: `toRoute`, `toOpenApiOperation`, `describeAction`,
185
- `derivePath(name)` (pin-aware) and `actionHttpPath(action | name)`. The browser's `rpc()` derives
186
- from the name alone, so it is told the style: `rpc({ baseUrl, pathStyle: 'readable' })`; a pinned
187
- action is called through `action.client()`, which holds the pin.
187
+ `derivePath(name)` (pin-aware) and `actionHttpPath(action | name)`. A pinned action is called
188
+ through `action.client()`, which holds the pin.
189
+ - **A browser states no style.** The server stamps a non-default one into every document
190
+ (`<meta name="ultimate-path-style">`) and `rpc({ baseUrl: '' })` derives under it — as do
191
+ `useMutation` and the outbox replay. `'resource'` writes no tag.
192
+ - **A caller with no document passes it**: `rpc({ baseUrl, pathStyle: 'readable' })` in a script or
193
+ another service. The wrong one is answered by the server — 404 `X_CONTRACT_DRIFT`, naming the
194
+ style it serves and the path the action lives at — never a bare `X_ROUTE_NOT_FOUND`
195
+ (`explainActionPathMiss`, wired as `@ultimat3/http`'s `hooks.explainMiss`).
188
196
  - **The style is read when the path is derived, so derive it late.** The scan evaluates
189
197
  `apps/web/api/index.ts` before any other module, so a page or admin view already sees the declared
190
198
  style. A module the index itself imports runs before its `defineApi` call: a module-level
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/action",
3
- "version": "22.15.0",
3
+ "version": "23.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",
@@ -22,6 +22,7 @@
22
22
  "files": [
23
23
  "src",
24
24
  "!src/**/*.test.ts",
25
+ "!src/**/*-fixture.ts",
25
26
  "CLAUDE.md",
26
27
  "README.md",
27
28
  "LICENSE"
@@ -34,11 +35,11 @@
34
35
  "test": "bun test"
35
36
  },
36
37
  "dependencies": {
37
- "@ultimat3/cache": "22.15.0",
38
- "@ultimat3/core": "22.15.0",
39
- "@ultimat3/entity": "22.15.0",
40
- "@ultimat3/http": "22.15.0",
41
- "@ultimat3/policy": "22.15.0",
42
- "@ultimat3/schema": "22.15.0"
38
+ "@ultimat3/cache": "23.0.0",
39
+ "@ultimat3/core": "23.0.0",
40
+ "@ultimat3/entity": "23.0.0",
41
+ "@ultimat3/http": "23.0.0",
42
+ "@ultimat3/policy": "23.0.0",
43
+ "@ultimat3/schema": "23.0.0"
43
44
  }
44
45
  }
@@ -0,0 +1,126 @@
1
+ // Compile-time pin: the documented `rpc<Api['actions']>` idiom at 300 actions in 100 modules.
2
+ // Source, not a `.test.ts`, for `type-pins.ts`'s reason — `tsc -b` never reads a test file. Types
3
+ // only: nothing here is emitted, and a regression is TS2589 in the `typecheck` step.
4
+ //
5
+ // The shape is the OBSERVED one (issue #534): the limit was never the action count, it was the
6
+ // number of MODULES in one `defineApi` list — 47 passed and 48 failed, whatever they exported.
7
+
8
+ import type { NumberSchema, ObjectSchema, StringSchema } from '@ultimat3/schema';
9
+ import type { Action } from './action';
10
+ import type { rpc } from './client';
11
+ import type { defineApi } from './define-api';
12
+
13
+ type Assert<T extends true> = T;
14
+
15
+ type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
16
+
17
+ type Verb = 'create' | 'update' | 'archive';
18
+
19
+ /** Distinct per module (`m7`), so no two modules collapse into one instantiation. */
20
+ type ScaleInput<N extends number> = ObjectSchema<
21
+ {
22
+ readonly id: StringSchema;
23
+ readonly title: StringSchema;
24
+ readonly nested: ObjectSchema<{ readonly label: StringSchema; readonly rank: NumberSchema }>;
25
+ } & { readonly [K in `m${N}`]: NumberSchema }
26
+ >;
27
+
28
+ type ScaleOutput<N extends number> = ObjectSchema<
29
+ { readonly id: StringSchema } & { readonly [K in `out${N}`]: StringSchema }
30
+ >;
31
+
32
+ /** A feature module as `import * as` sees one: its actions, and a helper that is not one. */
33
+ type ActionModule<N extends number> = {
34
+ readonly [K in `${Verb}Thing${N}`]: Action<ScaleInput<N>, ScaleOutput<N>>;
35
+ } & { readonly [K in `helper${N}`]: (value: number) => number };
36
+
37
+ /** `@ultimat3/query` is sideways, so a read is stood in for by the two members `Merge` reads. */
38
+ type QueryModule<N extends number> = {
39
+ readonly [K in `listRows${N}` | `oneRow${N}`]: { readonly kind: 'query'; readonly name: string };
40
+ };
41
+
42
+ /** `[Module<0>, …, Module<TCount - 1>]`. A tail call, so the list length costs no depth here. */
43
+ type ActionModules<
44
+ TCount extends number,
45
+ TAcc extends readonly unknown[] = [],
46
+ > = TAcc['length'] extends TCount
47
+ ? TAcc
48
+ : ActionModules<TCount, [...TAcc, ActionModule<TAcc['length']>]>;
49
+
50
+ type QueryModules<
51
+ TCount extends number,
52
+ TAcc extends readonly unknown[] = [],
53
+ > = TAcc['length'] extends TCount
54
+ ? TAcc
55
+ : QueryModules<TCount, [...TAcc, QueryModule<TAcc['length']>]>;
56
+
57
+ /** 100 modules of 3 actions, 50 modules of 2 reads — through `defineApi`'s own signature. */
58
+ type ScaleApi = ReturnType<
59
+ typeof defineApi<{
60
+ readonly actions: ActionModules<100>;
61
+ readonly queries: QueryModules<50>;
62
+ }>
63
+ >;
64
+
65
+ /** The idiom itself: its `ActionMap` constraint is where TS2589 was raised. */
66
+ type ScaleClient = ReturnType<typeof rpc<ScaleApi['actions']>>;
67
+
68
+ export type _EveryModuleReachesTheClient = Assert<
69
+ 'createThing0' | 'updateThing47' | 'archiveThing99' extends keyof ScaleClient ? true : false
70
+ >;
71
+
72
+ /** 300, counted: a `Merge` that dropped a module would still pass the three names above. */
73
+ export type _TheClientHasExactlyTheRegisteredActions = Assert<
74
+ Equals<keyof ScaleClient, ActionName>
75
+ >;
76
+
77
+ type ActionName = {
78
+ [N in keyof ActionModules<100> & `${number}`]: `${Verb}Thing${N}`;
79
+ }[keyof ActionModules<100> & `${number}`];
80
+
81
+ /** A helper exported beside the actions is not a method — the server never registered it. */
82
+ export type _AHelperIsNotAClientMethod = Assert<'helper0' extends keyof ScaleClient ? false : true>;
83
+
84
+ /** One call, both sides inferred from the LAST module's own schemas. */
85
+ export type _TheLastActionKeepsItsInput = Assert<
86
+ Equals<
87
+ Parameters<ScaleClient['archiveThing99']>[0],
88
+ {
89
+ readonly id: string;
90
+ readonly title: string;
91
+ readonly nested: { readonly label: string; readonly rank: number };
92
+ readonly m99: number;
93
+ }
94
+ >
95
+ >;
96
+
97
+ export type _TheLastActionKeepsItsOutput = Assert<
98
+ Equals<
99
+ Awaited<ReturnType<ScaleClient['archiveThing99']>>,
100
+ { readonly id: string; readonly out99: string }
101
+ >
102
+ >;
103
+
104
+ /** `Api['queries']` is the same `Merge`, and regresses with it. */
105
+ export type _EveryQueryModuleIsMerged = Assert<
106
+ Equals<
107
+ keyof ScaleApi['queries'],
108
+ {
109
+ [N in keyof QueryModules<50> & `${number}`]: `listRows${N}` | `oneRow${N}`;
110
+ }[keyof QueryModules<50> & `${number}`]
111
+ >
112
+ >;
113
+
114
+ /** No list at all, and an empty one, are both the empty map — never `unknown`. */
115
+ type EmptyApi = ReturnType<typeof defineApi<{ readonly actions: readonly [] }>>;
116
+
117
+ export type _AnEmptyListIsAnEmptyMap = Assert<
118
+ Equals<keyof EmptyApi['actions'] | keyof EmptyApi['queries'], never>
119
+ >;
120
+
121
+ export type _AnEmptyMapStillSatisfiesRpc = ReturnType<typeof rpc<EmptyApi['actions']>>;
122
+
123
+ /** One module handed over bare, not in a list — the other spelling `ApiModules` allows. */
124
+ type BareApi = ReturnType<typeof defineApi<{ readonly actions: ActionModule<7> }>>;
125
+
126
+ export type _ABareModuleIsRegisteredToo = Assert<Equals<keyof BareApi['actions'], `${Verb}Thing7`>>;
package/src/client.ts CHANGED
@@ -84,10 +84,13 @@ export interface ClientOptions {
84
84
  */
85
85
  readonly flight?: ClientFlight;
86
86
  /**
87
- * The server's `defineApi({ http: { pathStyle } })`, restated: the browser derives each URL from
88
- * the name and cannot read the server's registry. Absent is `'resource'`, the server's default.
89
- * A per-action `http.path` pin is honoured by `action.client()` (it holds the declaration); the
90
- * name-only `rpc()` proxy cannot see one, so a pinned action is called through its own client.
87
+ * The style of a server that is NOT the one that rendered this page — a script, another
88
+ * service. A browser states nothing: the server stamps its `defineApi({ http: { pathStyle } })`
89
+ * into the document and every path is derived under that. With no document and no value here it
90
+ * is `'resource'`, the server's default; a server reached under the wrong one answers
91
+ * `X_CONTRACT_DRIFT` naming the style it serves. A per-action `http.path` pin is honoured by
92
+ * `action.client()` (it holds the declaration); the name-only `rpc()` proxy cannot see one, so a
93
+ * pinned action is called through its own client.
91
94
  */
92
95
  readonly pathStyle?: ActionPathStyle;
93
96
  }
@@ -123,7 +126,9 @@ export function clientMethodFor<TInput extends StandardSchemaV1, TOutput extends
123
126
  options: ClientOptions,
124
127
  pinnedPath?: string,
125
128
  ): ClientMethod<TInput, TOutput> {
126
- const path = pinnedPath ?? actionPath(name, options.pathStyle ?? 'resource');
129
+ // No default here: for a caller naming no style, `actionPath` asks the document the server
130
+ // rendered, and that answer must be the same one `useMutation` and the outbox replay get.
131
+ const path = pinnedPath ?? actionPath(name, options.pathStyle);
127
132
  const url = `${options.baseUrl.replace(/\/+$/, '')}${path}`;
128
133
  const onResponse = (response: Response): void =>
129
134
  assertSameBuild(options.buildId, response.headers.get(BUILD_ID_HEADER), name);
package/src/define-api.ts CHANGED
@@ -50,16 +50,28 @@ type Registered<TModule, TKind extends string> = {
50
50
  };
51
51
 
52
52
  /**
53
- * Intersect a tuple of module namespaces. `{ createPost } & { inviteMember }` is the map the
53
+ * Intersect a list of module namespaces. `{ createPost } & { inviteMember }` is the map the
54
54
  * typed client indexes, so `rpc<Api['actions']>()` knows every action without a codegen step.
55
+ *
56
+ * Never by recursion over the tuple. `Registered<Head> & Merge<Rest>` is not a tail call, so the
57
+ * compiler spent one level of its instantiation depth per MODULE and answered TS2589 at the 48th
58
+ * entry of one list — whatever the number of actions inside them (measured: 30 modules of 10 pass,
59
+ * 48 modules of 1 fail). The list is read as a union and intersected in one step, so the depth is
60
+ * constant and `client-scale-pins.ts` holds it there.
55
61
  */
56
62
  type Merge<TModules, TKind extends string> = [TModules] extends [undefined]
57
63
  ? EmptyModule
58
- : TModules extends readonly [infer THead, ...infer TRest]
59
- ? Registered<THead, TKind> & Merge<TRest, TKind>
60
- : TModules extends readonly []
61
- ? EmptyModule
62
- : Registered<TModules, TKind>;
64
+ : TModules extends readonly unknown[]
65
+ ? MergeAll<TModules[number], TKind>
66
+ : Registered<TModules, TKind>;
67
+
68
+ /** An empty list has no members to intersect — and `unknown` is not a map `rpc` accepts. */
69
+ type MergeAll<TModule, TKind extends string> = [TModule] extends [never]
70
+ ? EmptyModule
71
+ : Intersect<TModule extends unknown ? (map: Registered<TModule, TKind>) => void : never>;
72
+
73
+ /** A union of one-parameter functions infers that parameter as the intersection of them all. */
74
+ type Intersect<TFns> = [TFns] extends [(map: infer TMap) => void] ? TMap : never;
63
75
 
64
76
  type EmptyModule = Readonly<Record<never, never>>;
65
77
 
package/src/index.ts CHANGED
@@ -237,6 +237,11 @@ export {
237
237
  completeOpenApi,
238
238
  mountOpenApi,
239
239
  } from './openapi-complete';
240
+ /**
241
+ * `@ultimat3/http`'s `hooks.explainMiss` for the action surface: a POST to an action's path under
242
+ * a `pathStyle` this app does not serve answers `X_CONTRACT_DRIFT` naming the style it does.
243
+ */
244
+ export { explainActionPathMiss } from './path-style-miss';
240
245
  export type { ActionPolicy, PolicySubject, Surface } from './policy-gate';
241
246
  /**
242
247
  * `policyCapability` is the display label; `policyPermissions` is what a report MATCHES on.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * What the server says to a caller that derived an action's URL under the WRONG path style: a
3
+ * script or another service passing `rpc({ pathStyle })` by hand, where matching build ids prove
4
+ * nothing. Handed to `@ultimat3/http` as `hooks.explainMiss`, so it runs only when the router
5
+ * matched nothing — and answers `X_CONTRACT_DRIFT` naming the style served, in place of a
6
+ * `X_ROUTE_NOT_FOUND` that sends the caller looking for a route that exists.
7
+ */
8
+
9
+ import type { ActionPathStyle } from '@ultimat3/core';
10
+ import { ACTION_PATH_PREFIX, ACTION_PATH_STYLES, actionRoute } from '@ultimat3/core';
11
+ import { ContractDriftError } from './errors';
12
+ import { actionPathStyle, servedActionRoute } from './http-path';
13
+ import { defOf } from './invoke';
14
+ import { seatedActions } from './registry-store';
15
+
16
+ /**
17
+ * The action `pathname` names under a style this app does not serve, or `undefined`. A pinned
18
+ * action is skipped: its URL is the pin under every style, so no style explains missing it.
19
+ *
20
+ * A walk of the registry per miss, and deliberately not an index: the registry and the style both
21
+ * move (`x dev` re-evaluates modules), and only a POST under `/api/` that matched no route pays —
22
+ * one string derivation per action, no I/O.
23
+ */
24
+ function driftedAction(
25
+ pathname: string,
26
+ ): { readonly name: string; readonly under: ActionPathStyle } | undefined {
27
+ const served = actionPathStyle();
28
+ for (const under of ACTION_PATH_STYLES) {
29
+ if (under === served) continue;
30
+ // why no index: a memo keyed by path would outlive a re-registration and name a stale action.
31
+ for (const [name, seated] of seatedActions) {
32
+ if (defOf(seated).http?.path !== undefined) continue;
33
+ if (actionRoute(name, under).path === pathname) return { name, under };
34
+ }
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ /**
40
+ * `hooks.explainMiss` for the action surface. Every value in the message is the framework's own:
41
+ * the name is a registered export name and both paths are derived from it — the caller's bytes
42
+ * reach the message only by being equal to one of them.
43
+ */
44
+ export function explainActionPathMiss(
45
+ method: string,
46
+ pathname: string,
47
+ ): ContractDriftError | undefined {
48
+ if (method !== 'POST' || !pathname.startsWith(`${ACTION_PATH_PREFIX}/`)) return undefined;
49
+ const drifted = driftedAction(pathname);
50
+ if (drifted === undefined) return undefined;
51
+ const served = actionPathStyle();
52
+ return new ContractDriftError(
53
+ `POST ${pathname} is ${drifted.name} under pathStyle '${drifted.under}'; this server serves pathStyle '${served}', where it is POST ${servedActionRoute(drifted.name).path}`,
54
+ `rpc<Api['actions']>({ baseUrl, pathStyle: '${served}' }) # in a browser, pass no pathStyle: the page states it`,
55
+ );
56
+ }