@fougere/core 0.16.8-alpha.0 → 0.17.0-alpha.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/README.md +1 -1
- package/dist/CallIdentity.js +2 -2
- package/dist/CallIdentity.js.map +1 -1
- package/dist/Declared.d.ts +2 -0
- package/dist/Declared.d.ts.map +1 -1
- package/dist/FrondDeclaration.d.ts.map +1 -1
- package/dist/FrondDeclaration.js +1 -0
- package/dist/FrondDeclaration.js.map +1 -1
- package/dist/boot/App.d.ts +4 -4
- package/dist/boot/App.d.ts.map +1 -1
- package/dist/boot/AppLifecycle.d.ts +2 -2
- package/dist/boot/AppLifecycle.js +3 -3
- package/dist/boot/AppLifecycle.js.map +1 -1
- package/dist/boot/bootstrap.d.ts.map +1 -1
- package/dist/boot/bootstrap.js +27 -28
- package/dist/boot/bootstrap.js.map +1 -1
- package/dist/boot/card.d.ts.map +1 -1
- package/dist/boot/card.js +30 -29
- package/dist/boot/card.js.map +1 -1
- package/dist/boot/install.d.ts +1 -1
- package/dist/boot/install.d.ts.map +1 -1
- package/dist/boot/install.js +15 -7
- package/dist/boot/install.js.map +1 -1
- package/dist/boot/peerOver.js +2 -2
- package/dist/boot/peerOver.js.map +1 -1
- package/dist/boot/ports.d.ts +2 -0
- package/dist/boot/ports.d.ts.map +1 -1
- package/dist/boot/ports.js +2 -0
- package/dist/boot/ports.js.map +1 -1
- package/dist/boot/remote.d.ts +7 -5
- package/dist/boot/remote.d.ts.map +1 -1
- package/dist/boot/remote.js +42 -29
- package/dist/boot/remote.js.map +1 -1
- package/dist/contract.d.ts +2 -2
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +3 -3
- package/dist/contract.js.map +1 -1
- package/dist/descriptor/ProviderEntry.d.ts +5 -0
- package/dist/descriptor/ProviderEntry.d.ts.map +1 -1
- package/dist/descriptor/bases.d.ts +7 -1
- package/dist/descriptor/bases.d.ts.map +1 -1
- package/dist/descriptor/bases.js +21 -5
- package/dist/descriptor/bases.js.map +1 -1
- package/dist/dispatch/ArgumentResolver.d.ts +7 -0
- package/dist/dispatch/ArgumentResolver.d.ts.map +1 -1
- package/dist/dispatch/ArgumentResolver.js +45 -12
- package/dist/dispatch/ArgumentResolver.js.map +1 -1
- package/dist/dispatch/Dispatcher.js +1 -1
- package/dist/dispatch/Dispatcher.js.map +1 -1
- package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
- package/dist/dispatch/HandlerFacade.js +9 -8
- package/dist/dispatch/HandlerFacade.js.map +1 -1
- package/dist/dispatch/InFlight.d.ts +1 -1
- package/dist/dispatch/InFlight.d.ts.map +1 -1
- package/dist/dispatch/InFlight.js +2 -2
- package/dist/dispatch/InFlight.js.map +1 -1
- package/dist/dispatch/LocalRoutePolicy.d.ts.map +1 -1
- package/dist/dispatch/LocalRoutePolicy.js +7 -7
- package/dist/dispatch/LocalRoutePolicy.js.map +1 -1
- package/dist/dispatch/PresenterExecutor.d.ts +1 -3
- package/dist/dispatch/PresenterExecutor.d.ts.map +1 -1
- package/dist/dispatch/PresenterExecutor.js +1 -7
- package/dist/dispatch/PresenterExecutor.js.map +1 -1
- package/dist/dispatch/Release.js +0 -2
- package/dist/dispatch/Release.js.map +1 -1
- package/dist/dispatch/RouteRegistry.d.ts +1 -1
- package/dist/dispatch/RouteRegistry.d.ts.map +1 -1
- package/dist/dispatch/RouteRegistry.js +3 -3
- package/dist/dispatch/RouteRegistry.js.map +1 -1
- package/dist/dispatch/StorageGuard.d.ts.map +1 -1
- package/dist/dispatch/StorageGuard.js +3 -9
- package/dist/dispatch/StorageGuard.js.map +1 -1
- package/dist/dispatch/remoteRoutes.d.ts +1 -1
- package/dist/dispatch/remoteRoutes.d.ts.map +1 -1
- package/dist/dispatch/remoteRoutes.js +1 -1
- package/dist/dispatch/remoteRoutes.js.map +1 -1
- package/dist/dispatch/routeNotFound.d.ts.map +1 -1
- package/dist/dispatch/routeNotFound.js +6 -5
- package/dist/dispatch/routeNotFound.js.map +1 -1
- package/dist/dispatch/validateInput.d.ts +1 -1
- package/dist/dispatch/validateInput.d.ts.map +1 -1
- package/dist/dispatch/validateInput.js +1 -3
- package/dist/dispatch/validateInput.js.map +1 -1
- package/dist/entry/facade.d.ts +1 -1
- package/dist/entry/facade.d.ts.map +1 -1
- package/dist/entry/facade.js +2 -2
- package/dist/entry/facade.js.map +1 -1
- package/dist/entry/transport.js +1 -1
- package/dist/entry/transport.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/wire/CallRecord.d.ts +1 -1
- package/dist/wire/CallRecord.d.ts.map +1 -1
- package/dist/wire/FougereError.d.ts +8 -3
- package/dist/wire/FougereError.d.ts.map +1 -1
- package/dist/wire/FougereError.js +13 -4
- package/dist/wire/FougereError.js.map +1 -1
- package/dist/wire/FougereErrorOptions.d.ts +1 -1
- package/dist/wire/FougereErrorOptions.d.ts.map +1 -1
- package/dist/wire/FrondCall.d.ts +2 -2
- package/dist/wire/FrondCall.d.ts.map +1 -1
- package/dist/wire/OperationContext.d.ts +2 -2
- package/dist/wire/OperationContext.d.ts.map +1 -1
- package/dist/wire/RouteAddress.d.ts +1 -1
- package/dist/wire/RouteAddress.d.ts.map +1 -1
- package/dist/wire/RouteAddress.js +5 -5
- package/dist/wire/RouteAddress.js.map +1 -1
- package/dist/wire/RouteAddressInput.d.ts +1 -1
- package/dist/wire/RouteAddressInput.d.ts.map +1 -1
- package/dist/wire/RpcAnswer.d.ts +1 -1
- package/dist/wire/RpcAnswer.d.ts.map +1 -1
- package/dist/wire/RpcAnswer.js +1 -1
- package/dist/wire/RpcAnswer.js.map +1 -1
- package/dist/wire/SignedCall.d.ts +1 -1
- package/dist/wire/SignedCall.d.ts.map +1 -1
- package/dist/wire/StateShape.d.ts +1 -1
- package/dist/wire/StateShape.d.ts.map +1 -1
- package/dist/wire/StateShape.js +2 -2
- package/dist/wire/StateShape.js.map +1 -1
- package/dist/wire/card/IdentityCard.d.ts +10 -3
- package/dist/wire/card/IdentityCard.d.ts.map +1 -1
- package/dist/wire/card/IdentityCard.js +3 -1
- package/dist/wire/card/IdentityCard.js.map +1 -1
- package/dist/wire/drift.d.ts +2 -1
- package/dist/wire/drift.d.ts.map +1 -1
- package/dist/wire/drift.js +19 -16
- package/dist/wire/drift.js.map +1 -1
- package/dist/wire/http-error.js +2 -2
- package/dist/wire/http-error.js.map +1 -1
- package/package.json +3 -3
- package/src/CallIdentity.ts +2 -2
- package/src/Declared.ts +2 -0
- package/src/FrondDeclaration.ts +1 -0
- package/src/boot/App.ts +4 -4
- package/src/boot/AppLifecycle.ts +3 -3
- package/src/boot/bootstrap.ts +29 -30
- package/src/boot/card.ts +38 -29
- package/src/boot/install.ts +14 -8
- package/src/boot/peerOver.ts +2 -2
- package/src/boot/ports.ts +3 -0
- package/src/boot/remote.ts +52 -36
- package/src/contract.ts +3 -3
- package/src/descriptor/ProviderEntry.ts +5 -0
- package/src/descriptor/bases.ts +21 -5
- package/src/dispatch/ArgumentResolver.ts +49 -10
- package/src/dispatch/Dispatcher.ts +1 -1
- package/src/dispatch/HandlerFacade.ts +9 -10
- package/src/dispatch/InFlight.ts +2 -2
- package/src/dispatch/LocalRoutePolicy.ts +7 -7
- package/src/dispatch/PresenterExecutor.ts +0 -4
- package/src/dispatch/Release.ts +0 -2
- package/src/dispatch/RouteRegistry.ts +3 -3
- package/src/dispatch/StorageGuard.ts +3 -9
- package/src/dispatch/remoteRoutes.ts +2 -2
- package/src/dispatch/routeNotFound.ts +6 -5
- package/src/dispatch/validateInput.ts +0 -4
- package/src/entry/facade.ts +2 -2
- package/src/entry/transport.ts +1 -1
- package/src/index.ts +1 -1
- package/src/wire/CallRecord.ts +1 -1
- package/src/wire/FougereError.ts +16 -5
- package/src/wire/FougereErrorOptions.ts +1 -1
- package/src/wire/FrondCall.ts +2 -2
- package/src/wire/OperationContext.ts +2 -2
- package/src/wire/RouteAddress.ts +5 -5
- package/src/wire/RouteAddressInput.ts +1 -1
- package/src/wire/RpcAnswer.ts +1 -1
- package/src/wire/SignedCall.ts +1 -1
- package/src/wire/StateShape.ts +2 -2
- package/src/wire/card/IdentityCard.ts +9 -7
- package/src/wire/drift.ts +22 -27
- package/src/wire/http-error.ts +2 -2
package/src/boot/remote.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { FrondCall } from '../wire/FrondCall.js';
|
|
7
7
|
import type { StateShape } from '../wire/StateShape.js';
|
|
8
8
|
import type { Transport } from '../wire/Transport.js';
|
|
9
|
-
import {
|
|
9
|
+
import { RPC_ADDRESS } from '../wire/RpcAnswer.js';
|
|
10
10
|
import { assertIdentityCard } from '../wire/card/IdentityCard.js';
|
|
11
11
|
import { runMiddlewares, type AppMiddleware } from '../wire/AppMiddleware.js';
|
|
12
12
|
import { type OperationContext } from '../wire/OperationContext.js';
|
|
@@ -14,7 +14,7 @@ import { Invocation } from '../wire/Invocation.js';
|
|
|
14
14
|
import { type InvocationContext } from '../wire/InvocationContext.js';
|
|
15
15
|
import { ErrorCode } from '../wire/ErrorCode.js';
|
|
16
16
|
import { FougereError } from '../wire/FougereError.js';
|
|
17
|
-
import { Card, type SchemaView
|
|
17
|
+
import { Card, type SchemaView } from '@fougere/schema';
|
|
18
18
|
import { dynamicOperations } from '../entry/facade.js';
|
|
19
19
|
import { decoded } from '../dispatch/decoded.js';
|
|
20
20
|
|
|
@@ -22,23 +22,26 @@ interface Route {
|
|
|
22
22
|
frond: string;
|
|
23
23
|
transport: Transport;
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
* `entity({...})` produces, live validation included.
|
|
25
|
+
* What each operation answers, rebuilt from the identity card — the same `SchemaConstructor`
|
|
26
|
+
* shape `entity({...})` produces, live validation included.
|
|
27
27
|
*/
|
|
28
|
-
|
|
28
|
+
outputs: Map<string, SchemaView>;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export interface RemoteRouter {
|
|
32
|
-
route(
|
|
32
|
+
route(address: string): Promise<Route>;
|
|
33
|
+
/** The schema a remote STORES under an entity's name — `undefined` when none does. */
|
|
34
|
+
schemaOf(entity: string): Promise<SchemaView | undefined>;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export function createRemoteRouter(
|
|
36
38
|
remotes: Record<string, string>,
|
|
37
39
|
makeTransport: (url: string) => Transport,
|
|
38
40
|
): RemoteRouter {
|
|
39
|
-
const
|
|
41
|
+
const byAddress = new Map<string, Route>();
|
|
42
|
+
const stored = new Map<string, SchemaView>();
|
|
40
43
|
// The remotes config key is a label for the address — the identity card is
|
|
41
|
-
// what decides which
|
|
44
|
+
// what decides which addresses answer behind it.
|
|
42
45
|
const pending = new Map(Object.entries(remotes));
|
|
43
46
|
const transports = new Map<string, Transport>();
|
|
44
47
|
|
|
@@ -54,7 +57,7 @@ export function createRemoteRouter(
|
|
|
54
57
|
const transport = transports.get(url) ?? makeTransport(url);
|
|
55
58
|
transports.set(url, transport);
|
|
56
59
|
try {
|
|
57
|
-
const answer = await transport({
|
|
60
|
+
const answer = await transport({ address: RPC_ADDRESS, op: 'discover' }, Invocation.empty);
|
|
58
61
|
|
|
59
62
|
// Judged below and not here: this catch means "unreachable, retry", and a
|
|
60
63
|
// refusal thrown inside it would be swallowed into another silent retry.
|
|
@@ -70,28 +73,34 @@ export function createRemoteRouter(
|
|
|
70
73
|
if (!answered) continue;
|
|
71
74
|
|
|
72
75
|
pending.delete(answered.label);
|
|
73
|
-
claimFacades(answered,
|
|
76
|
+
claimFacades(answered, byAddress, claimedBy);
|
|
77
|
+
storedBy(answered, stored);
|
|
74
78
|
}
|
|
75
79
|
};
|
|
76
80
|
|
|
77
81
|
return {
|
|
78
|
-
async
|
|
79
|
-
if (!
|
|
80
|
-
|
|
82
|
+
async schemaOf(entity) {
|
|
83
|
+
if (!stored.has(entity) && pending.size > 0) await discover();
|
|
84
|
+
|
|
85
|
+
return stored.get(entity);
|
|
86
|
+
},
|
|
87
|
+
async route(address) {
|
|
88
|
+
if (!byAddress.has(address) && pending.size > 0) await discover();
|
|
89
|
+
const hit = byAddress.get(address);
|
|
81
90
|
if (hit) return hit;
|
|
82
91
|
if (pending.size > 0) {
|
|
83
92
|
throw new FougereError({
|
|
84
93
|
code: ErrorCode.SERVICE_UNAVAILABLE,
|
|
85
|
-
message: `No reachable remote
|
|
94
|
+
message: `No reachable remote serves '${address}' — unreachable: ${[...pending.keys()].join(', ')}.\n`
|
|
86
95
|
+ ' Named in `remotes:`, and did not answer.',
|
|
87
|
-
|
|
96
|
+
address,
|
|
88
97
|
});
|
|
89
98
|
}
|
|
90
99
|
throw new FougereError({
|
|
91
100
|
code: ErrorCode.NOT_FOUND,
|
|
92
|
-
message: `No declared remote
|
|
101
|
+
message: `No declared remote serves '${address}'.\n`
|
|
93
102
|
+ ' Nothing here serves it either — add its frond to `fronds:`/`scan:`, or name the frond that does in `remotes:`.',
|
|
94
|
-
|
|
103
|
+
address,
|
|
95
104
|
});
|
|
96
105
|
},
|
|
97
106
|
};
|
|
@@ -101,28 +110,27 @@ type Facade = Record<string, (invocation?: InvocationContext) => Promise<unknown
|
|
|
101
110
|
|
|
102
111
|
/** Façade-shaped stand-in — the consumer can't tell it from a local facade. */
|
|
103
112
|
export function createRemoteFacade(
|
|
104
|
-
|
|
113
|
+
address: string,
|
|
105
114
|
router: RemoteRouter,
|
|
106
115
|
middlewaresFor: (address: string) => AppMiddleware[],
|
|
107
116
|
shape: StateShape,
|
|
108
117
|
): Facade {
|
|
109
118
|
const opFn = (op: string) => async (received: InvocationContext = Invocation.empty) => {
|
|
110
|
-
const { frond, transport,
|
|
111
|
-
const call: FrondCall = { frond,
|
|
112
|
-
const state = received.crossed ? { ...received.state } : shape.judge(received.state,
|
|
119
|
+
const { frond, transport, outputs } = await router.route(address);
|
|
120
|
+
const call: FrondCall = { frond, address, op };
|
|
121
|
+
const state = received.crossed ? { ...received.state } : shape.judge(received.state, address, op);
|
|
113
122
|
const invocation = { ...received, state };
|
|
114
123
|
const entered = { ...state };
|
|
115
124
|
const ctx: OperationContext = {
|
|
116
|
-
|
|
125
|
+
address, frond, operation: op, args: [], state, invocation, crosses: true,
|
|
117
126
|
};
|
|
118
|
-
const answer = await runMiddlewares(middlewaresFor(
|
|
119
|
-
transport(call, { ...(ctx.invocation ?? invocation), state: shape.judge(ctx.state,
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
// the
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
return decoded(schema, answer);
|
|
127
|
+
const answer = await runMiddlewares(middlewaresFor(address), ctx, () =>
|
|
128
|
+
transport(call, { ...(ctx.invocation ?? invocation), state: shape.judge(ctx.state, address, op, entered) }))
|
|
129
|
+
.catch((error: unknown) => { throw error instanceof FougereError ? error.at(address, op) : error; });
|
|
130
|
+
|
|
131
|
+
// What the op says it answers, put to work: a row crosses as data and comes back through
|
|
132
|
+
// the same codecs a local facade applies, so a placement does not decide what a caller holds.
|
|
133
|
+
return decoded(outputs.get(op), answer);
|
|
126
134
|
};
|
|
127
135
|
|
|
128
136
|
return dynamicOperations(opFn) as Facade;
|
|
@@ -143,7 +151,7 @@ interface Answered {
|
|
|
143
151
|
*/
|
|
144
152
|
function claimFacades(
|
|
145
153
|
{ label, url, transport, answer }: Answered,
|
|
146
|
-
|
|
154
|
+
byAddress: Map<string, Route>,
|
|
147
155
|
claimedBy: Map<string, string>,
|
|
148
156
|
): void {
|
|
149
157
|
const card = assertIdentityCard(answer, `Remote '${label}' (${url})`);
|
|
@@ -156,19 +164,27 @@ function claimFacades(
|
|
|
156
164
|
code: ErrorCode.INTERNAL_ERROR,
|
|
157
165
|
message:
|
|
158
166
|
`[claim] Two remotes serve '${facade.name}': '${first}' and '${label}'.\n`
|
|
159
|
-
+ ` A call names an
|
|
167
|
+
+ ` A call names an address, not a frond, so nothing could choose between them.\n`
|
|
160
168
|
+ ` - Keep one of the two out of \`remotes:\`, or\n`
|
|
161
|
-
+ ` - expose one of them under a different
|
|
162
|
-
|
|
169
|
+
+ ` - expose one of them under a different address.`,
|
|
170
|
+
address: facade.name,
|
|
163
171
|
});
|
|
164
172
|
}
|
|
165
173
|
|
|
166
174
|
claimedBy.set(facade.name, label);
|
|
167
|
-
|
|
175
|
+
byAddress.set(facade.name, {
|
|
168
176
|
frond: frond.name,
|
|
169
177
|
transport,
|
|
170
|
-
|
|
178
|
+
outputs: new Map(facade.ops.flatMap((op) =>
|
|
179
|
+
op.output ? [[op.name, Card.fromDescriptor(op.output).toSchema()] as const] : [])),
|
|
171
180
|
});
|
|
172
181
|
}
|
|
173
182
|
}
|
|
174
183
|
}
|
|
184
|
+
|
|
185
|
+
/** What each remote stores, by entity name — what `schemaFor` answers for a row kept elsewhere. */
|
|
186
|
+
function storedBy({ label, url, answer }: Answered, stored: Map<string, SchemaView>): void {
|
|
187
|
+
for (const frond of assertIdentityCard(answer, `Remote '${label}' (${url})`).fronds) {
|
|
188
|
+
for (const entity of frond.entities) stored.set(entity.name, Card.fromDescriptor(entity.schema).toSchema());
|
|
189
|
+
}
|
|
190
|
+
}
|
package/src/contract.ts
CHANGED
|
@@ -27,7 +27,7 @@ export type { Transport } from './wire/Transport.js';
|
|
|
27
27
|
export { ENVELOPE_BYTES, maxBodyBytes, maxFrameBytes, setMaxBodyBytes } from './wire/BodyLimit.js';
|
|
28
28
|
// The reserved entity, VALUE and not type: a consumer that wants to leave it alone — a
|
|
29
29
|
// call log ignoring its own reader — has to be able to name it.
|
|
30
|
-
export {
|
|
30
|
+
export { RPC_ADDRESS } from './wire/RpcAnswer.js';
|
|
31
31
|
export type { CallPage } from './wire/CallPage.js';
|
|
32
32
|
export { pageOf, asPage, type Page } from './wire/Page.js';
|
|
33
33
|
export type { CallRecord } from './wire/CallRecord.js';
|
|
@@ -69,7 +69,7 @@ export interface CallValue {
|
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Fabricate the call value — one designation, two spellings: `callValueOf(Post, 'list', { query
|
|
72
|
-
* })` (class + verb) or `callValueOf({
|
|
72
|
+
* })` (class + verb) or `callValueOf({ address, op }, input)` (raw, for dynamic bridges).
|
|
73
73
|
*/
|
|
74
74
|
export function callValueOf(
|
|
75
75
|
target: { name: string } | FrondCall,
|
|
@@ -78,7 +78,7 @@ export function callValueOf(
|
|
|
78
78
|
): CallValue {
|
|
79
79
|
const [call, given] =
|
|
80
80
|
typeof opOrInput === 'string'
|
|
81
|
-
? [{
|
|
81
|
+
? [{ address: lowerFirst((target as { name: string }).name), op: opOrInput }, input]
|
|
82
82
|
: [target as FrondCall, opOrInput];
|
|
83
83
|
|
|
84
84
|
return { call, invocation: Invocation.from(given) };
|
|
@@ -15,4 +15,9 @@ export interface ProviderEntry {
|
|
|
15
15
|
deps: string[];
|
|
16
16
|
/** Absolute file path (for debugging). */
|
|
17
17
|
filePath: string;
|
|
18
|
+
/**
|
|
19
|
+
* What the source said and the runtime erased: a port, which a class below answers for and
|
|
20
|
+
* nothing may instantiate — never a candidate for the ports above it.
|
|
21
|
+
*/
|
|
22
|
+
abstract?: true;
|
|
18
23
|
}
|
package/src/descriptor/bases.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { type ProviderEntry } from './ProviderEntry.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Port class name →
|
|
4
|
+
* Port class name → every concrete class below it, in scan order.
|
|
5
5
|
*
|
|
6
6
|
* ONE condition, and the caller owns it: something already answers under that name. The boot
|
|
7
7
|
* asks its container, where the providers sit in the frond's scope and the builtins in its
|
|
8
8
|
* parent — so a neighbour service and `Logger` both count, a framework class being a port like
|
|
9
9
|
* any other, which is what makes a default overridable.
|
|
10
10
|
*
|
|
11
|
+
* A provider is ranked under the highest ancestor that answers AND under every class it crossed
|
|
12
|
+
* to reach it: a class something extends is a port, declared or not. `StripePayment extends
|
|
13
|
+
* CardPayment extends Payment` answers both, so an abstract `CardPayment` nobody registers is
|
|
14
|
+
* still a key, and a DECLARED abstract one is never a candidate. A wrapper stops at the class it asks for — it stands in front of that one and
|
|
15
|
+
* realizes nothing above it.
|
|
16
|
+
*
|
|
11
17
|
* It is also what excludes a prefab: a repository extends the class `Repository(Post)`
|
|
12
18
|
* returned (`RepositoryBase`), and no key is ever that name.
|
|
13
19
|
*
|
|
@@ -20,11 +26,21 @@ export function basesOf(
|
|
|
20
26
|
): Map<string, ProviderEntry[]> {
|
|
21
27
|
const bases = new Map<string, ProviderEntry[]>();
|
|
22
28
|
for (const provider of providers) {
|
|
23
|
-
|
|
24
|
-
const port
|
|
25
|
-
if (!port || !answers(port)) continue;
|
|
26
|
-
bases.set(port, [...(bases.get(port) ?? []), provider]);
|
|
29
|
+
if (provider.abstract) continue;
|
|
30
|
+
for (const port of portsOf(provider, answers)) bases.set(port, [...(bases.get(port) ?? []), provider]);
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
return bases;
|
|
30
34
|
}
|
|
35
|
+
|
|
36
|
+
/** The ancestors a provider answers under, nearest first, up to the last one that answers. */
|
|
37
|
+
function portsOf(provider: ProviderEntry, answers: (name: string) => boolean): string[] {
|
|
38
|
+
const crossed: string[] = [];
|
|
39
|
+
for (let base = Object.getPrototypeOf(provider.ctor) as { name?: string } | null; base?.name; base = Object.getPrototypeOf(base) as { name?: string } | null) {
|
|
40
|
+
if (provider.deps.includes(base.name)) return [base.name];
|
|
41
|
+
crossed.push(base.name);
|
|
42
|
+
}
|
|
43
|
+
const last = crossed.findLastIndex(answers);
|
|
44
|
+
|
|
45
|
+
return crossed.slice(0, last + 1);
|
|
46
|
+
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { InvocationContext } from '../wire/InvocationContext.js';
|
|
2
2
|
import type { BindingPlan } from '../wire/binding.js';
|
|
3
|
+
import { ErrorCode } from '../wire/ErrorCode.js';
|
|
4
|
+
import { FougereError } from '../wire/FougereError.js';
|
|
3
5
|
import type { CollectorLookup } from './CollectorLookup.js';
|
|
4
6
|
import { collectedAs } from '../prefab/collector.js';
|
|
5
7
|
|
|
8
|
+
type ParamSource = Extract<BindingPlan[number]['source'], { kind: 'param' }>;
|
|
9
|
+
|
|
6
10
|
/** Resolves an operation's declared binding plan against one invocation. */
|
|
7
11
|
export class ArgumentResolver {
|
|
8
12
|
constructor(private readonly collectors?: CollectorLookup) {}
|
|
@@ -22,16 +26,7 @@ export class ArgumentResolver {
|
|
|
22
26
|
break;
|
|
23
27
|
}
|
|
24
28
|
case 'param': {
|
|
25
|
-
|
|
26
|
-
// nullable path/GraphQL argument fall through to query (or become undefined),
|
|
27
|
-
// collapsing `T | null` into `T | undefined`. Only undefined means absent.
|
|
28
|
-
const fromParams = ctx.params[binding.source.name];
|
|
29
|
-
let val: unknown = fromParams === undefined
|
|
30
|
-
? ctx.query[binding.source.name]
|
|
31
|
-
: fromParams;
|
|
32
|
-
if (val != null && binding.source.coerce === 'number') val = Number(val);
|
|
33
|
-
if (val != null && binding.source.coerce === 'boolean') val = val === 'true' || val === '1' || val === true;
|
|
34
|
-
args.push(val);
|
|
29
|
+
args.push(ArgumentResolver.param(binding.source, binding.optional, ctx));
|
|
35
30
|
break;
|
|
36
31
|
}
|
|
37
32
|
case 'fact':
|
|
@@ -60,4 +55,48 @@ export class ArgumentResolver {
|
|
|
60
55
|
|
|
61
56
|
return args;
|
|
62
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A value the caller typed in a path or a query string. `null` is a value, not a miss: only
|
|
61
|
+
* `undefined` means absent, so `T | null` stays apart from `T | undefined`.
|
|
62
|
+
*/
|
|
63
|
+
private static param(source: ParamSource, optional: boolean, ctx: InvocationContext): unknown {
|
|
64
|
+
const { name, coerce } = source;
|
|
65
|
+
const fromParams = ctx.params[name];
|
|
66
|
+
const value: unknown = fromParams === undefined ? ctx.query[name] : fromParams;
|
|
67
|
+
|
|
68
|
+
if (value === undefined) {
|
|
69
|
+
if (optional) return undefined;
|
|
70
|
+
throw refused(name, 'Required');
|
|
71
|
+
}
|
|
72
|
+
if (value === null || coerce === undefined) return value;
|
|
73
|
+
|
|
74
|
+
const coerced = coerce === 'number' ? ArgumentResolver.number(value) : ArgumentResolver.boolean(value);
|
|
75
|
+
if (coerced === undefined) throw refused(name, `Expected a ${coerce}, got ${JSON.stringify(value)}`);
|
|
76
|
+
|
|
77
|
+
return coerced;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private static number(value: unknown): number | undefined {
|
|
81
|
+
if (typeof value === 'number') return Number.isNaN(value) ? undefined : value;
|
|
82
|
+
if (typeof value !== 'string' || value.trim() === '') return undefined;
|
|
83
|
+
const parsed = Number(value);
|
|
84
|
+
|
|
85
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private static boolean(value: unknown): boolean | undefined {
|
|
89
|
+
if (value === true || value === 'true' || value === '1') return true;
|
|
90
|
+
if (value === false || value === 'false' || value === '0') return false;
|
|
91
|
+
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function refused(name: string, message: string): FougereError {
|
|
97
|
+
return new FougereError({
|
|
98
|
+
code: ErrorCode.VALIDATION_FAILED,
|
|
99
|
+
message: `${name}: ${message}`,
|
|
100
|
+
details: [{ path: [name], message }],
|
|
101
|
+
});
|
|
63
102
|
}
|
|
@@ -65,7 +65,7 @@ export class Dispatcher implements DispatchPort {
|
|
|
65
65
|
this.lifecycle.publish(DispatchEvent.received(call));
|
|
66
66
|
|
|
67
67
|
try {
|
|
68
|
-
release = this.inFlight.enter(call.address.
|
|
68
|
+
release = this.inFlight.enter(call.address.address, call.address.operation);
|
|
69
69
|
route = await this.routeFor(call);
|
|
70
70
|
|
|
71
71
|
this.lifecycle.publish(DispatchEvent.resolved(call, route.kind));
|
|
@@ -21,6 +21,7 @@ import { OutputView } from './OutputView.js';
|
|
|
21
21
|
import { PresenterExecutor } from './PresenterExecutor.js';
|
|
22
22
|
import { presenterArguments, presenterPlans } from './presenterArguments.js';
|
|
23
23
|
import { validateInput } from './validateInput.js';
|
|
24
|
+
import { FougereError } from '../wire/FougereError.js';
|
|
24
25
|
import type { Facade } from './Facade.js';
|
|
25
26
|
|
|
26
27
|
/** Adapts one handler facade to executable operations. */
|
|
@@ -79,22 +80,22 @@ export class HandlerFacade {
|
|
|
79
80
|
|
|
80
81
|
/** One operation, through the same ordered boundary steps on every route. */
|
|
81
82
|
async execute(op: string, input?: Partial<InvocationContext>): Promise<unknown> {
|
|
82
|
-
const
|
|
83
|
+
const address = this.handler.address;
|
|
83
84
|
const contract = this.contracts.get(op);
|
|
84
85
|
if (!contract) {
|
|
85
86
|
throw new Error(
|
|
86
|
-
`${
|
|
87
|
+
`${address} serves no operation '${op}'. `
|
|
87
88
|
+ `It serves ${[...this.contracts.keys()].join(', ')}.`,
|
|
88
89
|
);
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
const received = Invocation.from(input);
|
|
92
|
-
const state = received.crossed ? { ...received.state } : this.facade.state.judge(received.state,
|
|
93
|
+
const state = received.crossed ? { ...received.state } : this.facade.state.judge(received.state, address, op);
|
|
93
94
|
const invocation = received.withState(state);
|
|
94
95
|
const entered = { ...state };
|
|
95
96
|
const { className, method } = this.effectiveOperations.get(op)!.implementation;
|
|
96
97
|
const context: OperationContext = {
|
|
97
|
-
|
|
98
|
+
address,
|
|
98
99
|
frond: this.facade.frond,
|
|
99
100
|
handler: className,
|
|
100
101
|
operation: op,
|
|
@@ -106,7 +107,8 @@ export class HandlerFacade {
|
|
|
106
107
|
const label = `${this.facade.frond}:${className}.${method}`;
|
|
107
108
|
|
|
108
109
|
return runMiddlewares(this.facade.middlewares(), context, () =>
|
|
109
|
-
this.facade.enterOperation(label, () => this.answer(op, contract, context, invocation, entered)))
|
|
110
|
+
this.facade.enterOperation(label, () => this.answer(op, contract, context, invocation, entered)))
|
|
111
|
+
.catch((error: unknown) => { throw error instanceof FougereError ? error.at(address, op) : error; });
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
/**
|
|
@@ -121,7 +123,7 @@ export class HandlerFacade {
|
|
|
121
123
|
entered: Record<string, unknown>,
|
|
122
124
|
): Promise<unknown> {
|
|
123
125
|
const state = this.facade.state.judge(context.state, this.handler.address, op, entered);
|
|
124
|
-
const validated = validateInput(contract.input, { ...invocation, state }
|
|
126
|
+
const validated = validateInput(contract.input, { ...invocation, state });
|
|
125
127
|
context.invocation = validated;
|
|
126
128
|
|
|
127
129
|
const args = contract.binding ? await this.arguments.resolve(contract.binding, validated) : [];
|
|
@@ -149,12 +151,9 @@ export class HandlerFacade {
|
|
|
149
151
|
output: unknown,
|
|
150
152
|
invocation: InvocationContext,
|
|
151
153
|
): Promise<unknown> {
|
|
152
|
-
const entity = this.handler.address;
|
|
153
154
|
const executor = new PresenterExecutor(
|
|
154
|
-
this.facade.presenterScope.resolve(presenterKeyOf(
|
|
155
|
+
this.facade.presenterScope.resolve(presenterKeyOf(this.handler.address)),
|
|
155
156
|
presenter.fields,
|
|
156
|
-
entity,
|
|
157
|
-
op,
|
|
158
157
|
);
|
|
159
158
|
|
|
160
159
|
return presenterArguments(this.presenterPlans, invocation, this.arguments)
|
package/src/dispatch/InFlight.ts
CHANGED
|
@@ -11,12 +11,12 @@ export class InFlight {
|
|
|
11
11
|
return this.running;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
enter(
|
|
14
|
+
enter(address: string, operation: string): () => void {
|
|
15
15
|
if (!this.accepting) {
|
|
16
16
|
throw new FougereError({
|
|
17
17
|
code: ErrorCode.SERVICE_UNAVAILABLE,
|
|
18
18
|
message: 'This app is being released and takes no new call — reach the current one through its handle.',
|
|
19
|
-
|
|
19
|
+
address,
|
|
20
20
|
operation,
|
|
21
21
|
});
|
|
22
22
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Call } from '../wire/Call.js';
|
|
2
2
|
import { ErrorCode } from '../wire/ErrorCode.js';
|
|
3
3
|
import { FougereError } from '../wire/FougereError.js';
|
|
4
|
+
import { RPC_ADDRESS } from '../wire/RpcAnswer.js';
|
|
4
5
|
import type { Route } from './Route.js';
|
|
5
6
|
import type { RoutePolicy } from './RoutePolicy.js';
|
|
6
7
|
import { routeNotFound, servedOperations } from './routeNotFound.js';
|
|
@@ -14,22 +15,21 @@ export class LocalRoutePolicy implements RoutePolicy {
|
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
notFound(call: Call, routes: readonly Route[]): Error | undefined {
|
|
17
|
-
if (call.address.
|
|
18
|
+
if (call.address.address === RPC_ADDRESS) return undefined;
|
|
18
19
|
|
|
19
20
|
const served = servedOperations(call, routes, this);
|
|
20
21
|
if (served.length > 0) return routeNotFound(call, served);
|
|
21
22
|
|
|
22
23
|
const hosted = this.hostedNames(call.address.surface);
|
|
23
|
-
const
|
|
24
|
-
const surface = call.address.surface;
|
|
24
|
+
const { address, surface } = call.address;
|
|
25
25
|
|
|
26
26
|
return new FougereError({
|
|
27
27
|
code: ErrorCode.NOT_FOUND,
|
|
28
28
|
message: (surface
|
|
29
|
-
? `
|
|
30
|
-
: `
|
|
31
|
-
+ (hosted.length ? `.
|
|
32
|
-
|
|
29
|
+
? `'${address}' is not served on surface '${surface}'`
|
|
30
|
+
: `Nothing here answers at '${address}'`)
|
|
31
|
+
+ (hosted.length ? `. Served here: ${hosted.join(', ')}.` : '. This app serves no address.'),
|
|
32
|
+
address,
|
|
33
33
|
operation: call.address.operation,
|
|
34
34
|
});
|
|
35
35
|
}
|
|
@@ -9,8 +9,6 @@ export class PresenterExecutor {
|
|
|
9
9
|
constructor(
|
|
10
10
|
private readonly presenter: Record<string, unknown> | undefined,
|
|
11
11
|
private readonly fieldNames: readonly string[] | undefined,
|
|
12
|
-
private readonly entity = 'unknown',
|
|
13
|
-
private readonly operation = 'unknown',
|
|
14
12
|
) {}
|
|
15
13
|
|
|
16
14
|
async present(result: unknown, args: PresenterArgs = {}): Promise<unknown> {
|
|
@@ -64,8 +62,6 @@ export class PresenterExecutor {
|
|
|
64
62
|
throw new FougereError({
|
|
65
63
|
code: ErrorCode.INTERNAL_ERROR,
|
|
66
64
|
message: `Computed field '${name}' failed: ${(cause as Error)?.message ?? cause}`,
|
|
67
|
-
entity: this.entity,
|
|
68
|
-
operation: this.operation,
|
|
69
65
|
cause,
|
|
70
66
|
});
|
|
71
67
|
}
|
package/src/dispatch/Release.ts
CHANGED
|
@@ -131,8 +131,6 @@ async function refuseHeld(
|
|
|
131
131
|
code: ErrorCode.CONFLICT,
|
|
132
132
|
message: `${dependent.entity}.${dependent.field} holds ${found.length} row(s) naming this one, `
|
|
133
133
|
+ `and states onDelete 'restrict' — take them out first, or declare what should happen.`,
|
|
134
|
-
entity,
|
|
135
|
-
operation: 'delete',
|
|
136
134
|
});
|
|
137
135
|
}
|
|
138
136
|
|
|
@@ -32,7 +32,7 @@ export class RouteRegistry {
|
|
|
32
32
|
if (exact || address.surface === undefined) return exact;
|
|
33
33
|
|
|
34
34
|
const shared = this.byAddress.get(new RouteAddress({
|
|
35
|
-
|
|
35
|
+
address: address.address,
|
|
36
36
|
operation: address.operation,
|
|
37
37
|
}).key());
|
|
38
38
|
|
|
@@ -69,9 +69,9 @@ export class RouteRegistry {
|
|
|
69
69
|
return [...this.byAddress.values()];
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
operationNames(
|
|
72
|
+
operationNames(address: string, surface?: string): string[] {
|
|
73
73
|
return this.routes()
|
|
74
|
-
.filter((route) => route.address.
|
|
74
|
+
.filter((route) => route.address.address === address && route.address.surface === surface)
|
|
75
75
|
.map((route) => route.address.operation);
|
|
76
76
|
}
|
|
77
77
|
}
|
|
@@ -154,9 +154,7 @@ export class StorageGuard {
|
|
|
154
154
|
if (errors.length > 0) {
|
|
155
155
|
throw new FougereError({
|
|
156
156
|
code: ErrorCode.BAD_REQUEST,
|
|
157
|
-
message: `Refused as a filter — ${errors.join(', ')}`,
|
|
158
|
-
entity: this.entity,
|
|
159
|
-
operation: 'list',
|
|
157
|
+
message: `Refused as a filter on ${this.entity} — ${errors.join(', ')}`,
|
|
160
158
|
details: errors,
|
|
161
159
|
});
|
|
162
160
|
}
|
|
@@ -247,9 +245,7 @@ export class StorageGuard {
|
|
|
247
245
|
if (errors.length > 0) {
|
|
248
246
|
throw new FougereError({
|
|
249
247
|
code: ErrorCode.INTERNAL_ERROR,
|
|
250
|
-
message: `Refused on the way out — ${errors.join(', ')}`,
|
|
251
|
-
entity: this.entity,
|
|
252
|
-
operation,
|
|
248
|
+
message: `Refused on the way out of ${this.entity}.${operation} — ${errors.join(', ')}`,
|
|
253
249
|
details: errors,
|
|
254
250
|
});
|
|
255
251
|
}
|
|
@@ -287,9 +283,7 @@ export class StorageGuard {
|
|
|
287
283
|
|
|
288
284
|
throw new FougereError({
|
|
289
285
|
code: ErrorCode.VALIDATION_FAILED,
|
|
290
|
-
message: `Refused on the way out — ${errors.join(', ')}`,
|
|
291
|
-
entity: this.entity,
|
|
292
|
-
operation,
|
|
286
|
+
message: `Refused on the way out of ${this.entity}.${operation} — ${errors.join(', ')}`,
|
|
293
287
|
details: errors,
|
|
294
288
|
});
|
|
295
289
|
}
|
|
@@ -2,14 +2,14 @@ import { OperationRoute } from './OperationRoute.js';
|
|
|
2
2
|
import type { RouteResolver } from './RouteResolver.js';
|
|
3
3
|
|
|
4
4
|
type RemoteFacade = Record<string, Function>;
|
|
5
|
-
export type RemoteFacadeResolver = (
|
|
5
|
+
export type RemoteFacadeResolver = (address: string) => RemoteFacade | undefined;
|
|
6
6
|
|
|
7
7
|
/** Builds a remote route on the first call, once the remote façade can be resolved. */
|
|
8
8
|
export function remoteRoutes(facades: RemoteFacadeResolver): RouteResolver {
|
|
9
9
|
return (address) => {
|
|
10
10
|
if (address.surface !== undefined) return undefined;
|
|
11
11
|
|
|
12
|
-
const facade = facades(address.
|
|
12
|
+
const facade = facades(address.address);
|
|
13
13
|
const operation = facade?.[address.operation];
|
|
14
14
|
|
|
15
15
|
if (typeof operation !== 'function') return undefined;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Call } from '../wire/Call.js';
|
|
2
2
|
import { ErrorCode } from '../wire/ErrorCode.js';
|
|
3
3
|
import { FougereError } from '../wire/FougereError.js';
|
|
4
|
+
import { RPC_ADDRESS } from '../wire/RpcAnswer.js';
|
|
4
5
|
import type { Route } from './Route.js';
|
|
5
6
|
import type { RoutePolicy } from './RoutePolicy.js';
|
|
6
7
|
|
|
@@ -12,7 +13,7 @@ export function servedOperations(
|
|
|
12
13
|
): readonly string[] {
|
|
13
14
|
return routes
|
|
14
15
|
.filter((route) => !policy || policy.accepts(route))
|
|
15
|
-
.filter((route) => route.address.
|
|
16
|
+
.filter((route) => route.address.address === call.address.address)
|
|
16
17
|
.filter((route) => route.kind === 'system' || route.address.surface === call.address.surface)
|
|
17
18
|
.map((route) => route.address.operation);
|
|
18
19
|
}
|
|
@@ -22,13 +23,13 @@ export function routeNotFound(
|
|
|
22
23
|
call: Call,
|
|
23
24
|
served: readonly string[],
|
|
24
25
|
): FougereError {
|
|
25
|
-
const {
|
|
26
|
-
const message =
|
|
26
|
+
const { address, operation } = call.address;
|
|
27
|
+
const message = address === RPC_ADDRESS
|
|
27
28
|
? `Unknown rpc operation '${operation}'. `
|
|
28
29
|
+ (served.length ? `It serves ${served.join(', ')}.` : 'It serves nothing.')
|
|
29
30
|
: served.length
|
|
30
|
-
? `Unknown operation '${operation}' on '${
|
|
31
|
+
? `Unknown operation '${operation}' on '${address}'. It serves ${served.join(', ')}.`
|
|
31
32
|
: `No route serves '${call.address}'`;
|
|
32
33
|
|
|
33
|
-
return new FougereError({ code: ErrorCode.NOT_FOUND, message,
|
|
34
|
+
return new FougereError({ code: ErrorCode.NOT_FOUND, message, address, operation });
|
|
34
35
|
}
|
|
@@ -7,8 +7,6 @@ import { FougereError } from '../wire/FougereError.js';
|
|
|
7
7
|
export function validateInput(
|
|
8
8
|
schema: SchemaView | undefined,
|
|
9
9
|
invocation: InvocationContext,
|
|
10
|
-
entity: string,
|
|
11
|
-
operation: string,
|
|
12
10
|
): InvocationContext {
|
|
13
11
|
if (!schema || invocation.input === undefined || invocation.input === null) return invocation;
|
|
14
12
|
|
|
@@ -20,8 +18,6 @@ export function validateInput(
|
|
|
20
18
|
code: ErrorCode.VALIDATION_FAILED,
|
|
21
19
|
message: result.errors.map((error) => `${dotted(error.path)}: ${error.message}`).join(', '),
|
|
22
20
|
details: result.errors,
|
|
23
|
-
entity,
|
|
24
|
-
operation,
|
|
25
21
|
});
|
|
26
22
|
}
|
|
27
23
|
|
package/src/entry/facade.ts
CHANGED
|
@@ -32,7 +32,7 @@ export function dynamicOperations(operation: (name: string) => Operation): Recor
|
|
|
32
32
|
*/
|
|
33
33
|
export function facadeOperations(
|
|
34
34
|
dispatcher: DispatchPort,
|
|
35
|
-
|
|
35
|
+
address: string,
|
|
36
36
|
operationNames?: Iterable<string>,
|
|
37
37
|
surface?: string,
|
|
38
38
|
received?: Received,
|
|
@@ -40,7 +40,7 @@ export function facadeOperations(
|
|
|
40
40
|
const operation = (name: string): Operation => async (invocation) => {
|
|
41
41
|
const answer = await dispatcher.dispatch(new Call(
|
|
42
42
|
new RouteAddress({
|
|
43
|
-
|
|
43
|
+
address,
|
|
44
44
|
operation: name,
|
|
45
45
|
...(surface !== undefined ? { surface } : {}),
|
|
46
46
|
}),
|