@ultimat3/action 22.14.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 +21 -4
- package/README.md +15 -7
- package/package.json +8 -7
- package/src/client-scale-pins.ts +126 -0
- package/src/client.ts +10 -5
- package/src/define-api.ts +18 -6
- package/src/index.ts +5 -0
- package/src/path-style-miss.ts +56 -0
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
|
|
81
|
-
`
|
|
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
|
|
117
|
-
|
|
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 **
|
|
161
|
-
`createClientFlight` is **
|
|
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)`.
|
|
186
|
-
|
|
187
|
-
|
|
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": "
|
|
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": "
|
|
38
|
-
"@ultimat3/core": "
|
|
39
|
-
"@ultimat3/entity": "
|
|
40
|
-
"@ultimat3/http": "
|
|
41
|
-
"@ultimat3/policy": "
|
|
42
|
-
"@ultimat3/schema": "
|
|
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
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
-
|
|
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
|
|
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 [
|
|
59
|
-
?
|
|
60
|
-
: TModules
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
}
|