@fougere/core 0.16.7-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.
Files changed (178) hide show
  1. package/README.md +1 -1
  2. package/dist/CallIdentity.js +2 -2
  3. package/dist/CallIdentity.js.map +1 -1
  4. package/dist/Declared.d.ts +2 -0
  5. package/dist/Declared.d.ts.map +1 -1
  6. package/dist/FrondDeclaration.d.ts.map +1 -1
  7. package/dist/FrondDeclaration.js +1 -0
  8. package/dist/FrondDeclaration.js.map +1 -1
  9. package/dist/boot/App.d.ts +4 -4
  10. package/dist/boot/App.d.ts.map +1 -1
  11. package/dist/boot/AppLifecycle.d.ts +2 -2
  12. package/dist/boot/AppLifecycle.js +3 -3
  13. package/dist/boot/AppLifecycle.js.map +1 -1
  14. package/dist/boot/bootstrap.d.ts.map +1 -1
  15. package/dist/boot/bootstrap.js +27 -28
  16. package/dist/boot/bootstrap.js.map +1 -1
  17. package/dist/boot/card.d.ts.map +1 -1
  18. package/dist/boot/card.js +30 -29
  19. package/dist/boot/card.js.map +1 -1
  20. package/dist/boot/install.d.ts +1 -1
  21. package/dist/boot/install.d.ts.map +1 -1
  22. package/dist/boot/install.js +15 -7
  23. package/dist/boot/install.js.map +1 -1
  24. package/dist/boot/peerOver.js +2 -2
  25. package/dist/boot/peerOver.js.map +1 -1
  26. package/dist/boot/ports.d.ts +2 -0
  27. package/dist/boot/ports.d.ts.map +1 -1
  28. package/dist/boot/ports.js +2 -0
  29. package/dist/boot/ports.js.map +1 -1
  30. package/dist/boot/remote.d.ts +7 -5
  31. package/dist/boot/remote.d.ts.map +1 -1
  32. package/dist/boot/remote.js +42 -29
  33. package/dist/boot/remote.js.map +1 -1
  34. package/dist/builtin/Logger.d.ts.map +1 -1
  35. package/dist/builtin/Logger.js +9 -1
  36. package/dist/builtin/Logger.js.map +1 -1
  37. package/dist/contract.d.ts +2 -2
  38. package/dist/contract.d.ts.map +1 -1
  39. package/dist/contract.js +3 -3
  40. package/dist/contract.js.map +1 -1
  41. package/dist/descriptor/ProviderEntry.d.ts +5 -0
  42. package/dist/descriptor/ProviderEntry.d.ts.map +1 -1
  43. package/dist/descriptor/bases.d.ts +7 -1
  44. package/dist/descriptor/bases.d.ts.map +1 -1
  45. package/dist/descriptor/bases.js +21 -5
  46. package/dist/descriptor/bases.js.map +1 -1
  47. package/dist/dispatch/ArgumentResolver.d.ts +7 -0
  48. package/dist/dispatch/ArgumentResolver.d.ts.map +1 -1
  49. package/dist/dispatch/ArgumentResolver.js +45 -12
  50. package/dist/dispatch/ArgumentResolver.js.map +1 -1
  51. package/dist/dispatch/Dispatcher.js +1 -1
  52. package/dist/dispatch/Dispatcher.js.map +1 -1
  53. package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
  54. package/dist/dispatch/HandlerFacade.js +9 -8
  55. package/dist/dispatch/HandlerFacade.js.map +1 -1
  56. package/dist/dispatch/InFlight.d.ts +1 -1
  57. package/dist/dispatch/InFlight.d.ts.map +1 -1
  58. package/dist/dispatch/InFlight.js +2 -2
  59. package/dist/dispatch/InFlight.js.map +1 -1
  60. package/dist/dispatch/LocalRoutePolicy.d.ts.map +1 -1
  61. package/dist/dispatch/LocalRoutePolicy.js +7 -7
  62. package/dist/dispatch/LocalRoutePolicy.js.map +1 -1
  63. package/dist/dispatch/PresenterExecutor.d.ts +1 -3
  64. package/dist/dispatch/PresenterExecutor.d.ts.map +1 -1
  65. package/dist/dispatch/PresenterExecutor.js +1 -7
  66. package/dist/dispatch/PresenterExecutor.js.map +1 -1
  67. package/dist/dispatch/Release.js +0 -2
  68. package/dist/dispatch/Release.js.map +1 -1
  69. package/dist/dispatch/RouteRegistry.d.ts +1 -1
  70. package/dist/dispatch/RouteRegistry.d.ts.map +1 -1
  71. package/dist/dispatch/RouteRegistry.js +3 -3
  72. package/dist/dispatch/RouteRegistry.js.map +1 -1
  73. package/dist/dispatch/StorageGuard.d.ts.map +1 -1
  74. package/dist/dispatch/StorageGuard.js +3 -9
  75. package/dist/dispatch/StorageGuard.js.map +1 -1
  76. package/dist/dispatch/remoteRoutes.d.ts +1 -1
  77. package/dist/dispatch/remoteRoutes.d.ts.map +1 -1
  78. package/dist/dispatch/remoteRoutes.js +1 -1
  79. package/dist/dispatch/remoteRoutes.js.map +1 -1
  80. package/dist/dispatch/routeNotFound.d.ts.map +1 -1
  81. package/dist/dispatch/routeNotFound.js +6 -5
  82. package/dist/dispatch/routeNotFound.js.map +1 -1
  83. package/dist/dispatch/validateInput.d.ts +1 -1
  84. package/dist/dispatch/validateInput.d.ts.map +1 -1
  85. package/dist/dispatch/validateInput.js +1 -3
  86. package/dist/dispatch/validateInput.js.map +1 -1
  87. package/dist/entry/facade.d.ts +1 -1
  88. package/dist/entry/facade.d.ts.map +1 -1
  89. package/dist/entry/facade.js +2 -2
  90. package/dist/entry/facade.js.map +1 -1
  91. package/dist/entry/transport.js +1 -1
  92. package/dist/entry/transport.js.map +1 -1
  93. package/dist/index.d.ts +1 -1
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/index.js +1 -1
  96. package/dist/index.js.map +1 -1
  97. package/dist/wire/CallRecord.d.ts +1 -1
  98. package/dist/wire/CallRecord.d.ts.map +1 -1
  99. package/dist/wire/FougereError.d.ts +8 -3
  100. package/dist/wire/FougereError.d.ts.map +1 -1
  101. package/dist/wire/FougereError.js +13 -4
  102. package/dist/wire/FougereError.js.map +1 -1
  103. package/dist/wire/FougereErrorOptions.d.ts +1 -1
  104. package/dist/wire/FougereErrorOptions.d.ts.map +1 -1
  105. package/dist/wire/FrondCall.d.ts +2 -2
  106. package/dist/wire/FrondCall.d.ts.map +1 -1
  107. package/dist/wire/OperationContext.d.ts +2 -2
  108. package/dist/wire/OperationContext.d.ts.map +1 -1
  109. package/dist/wire/RouteAddress.d.ts +1 -1
  110. package/dist/wire/RouteAddress.d.ts.map +1 -1
  111. package/dist/wire/RouteAddress.js +5 -5
  112. package/dist/wire/RouteAddress.js.map +1 -1
  113. package/dist/wire/RouteAddressInput.d.ts +1 -1
  114. package/dist/wire/RouteAddressInput.d.ts.map +1 -1
  115. package/dist/wire/RpcAnswer.d.ts +1 -1
  116. package/dist/wire/RpcAnswer.d.ts.map +1 -1
  117. package/dist/wire/RpcAnswer.js +1 -1
  118. package/dist/wire/RpcAnswer.js.map +1 -1
  119. package/dist/wire/SignedCall.d.ts +1 -1
  120. package/dist/wire/SignedCall.d.ts.map +1 -1
  121. package/dist/wire/StateShape.d.ts +1 -1
  122. package/dist/wire/StateShape.d.ts.map +1 -1
  123. package/dist/wire/StateShape.js +2 -2
  124. package/dist/wire/StateShape.js.map +1 -1
  125. package/dist/wire/card/IdentityCard.d.ts +10 -3
  126. package/dist/wire/card/IdentityCard.d.ts.map +1 -1
  127. package/dist/wire/card/IdentityCard.js +3 -1
  128. package/dist/wire/card/IdentityCard.js.map +1 -1
  129. package/dist/wire/drift.d.ts +2 -1
  130. package/dist/wire/drift.d.ts.map +1 -1
  131. package/dist/wire/drift.js +19 -16
  132. package/dist/wire/drift.js.map +1 -1
  133. package/dist/wire/http-error.js +2 -2
  134. package/dist/wire/http-error.js.map +1 -1
  135. package/package.json +3 -3
  136. package/src/CallIdentity.ts +2 -2
  137. package/src/Declared.ts +2 -0
  138. package/src/FrondDeclaration.ts +1 -0
  139. package/src/boot/App.ts +4 -4
  140. package/src/boot/AppLifecycle.ts +3 -3
  141. package/src/boot/bootstrap.ts +29 -30
  142. package/src/boot/card.ts +38 -29
  143. package/src/boot/install.ts +14 -8
  144. package/src/boot/peerOver.ts +2 -2
  145. package/src/boot/ports.ts +3 -0
  146. package/src/boot/remote.ts +52 -36
  147. package/src/builtin/Logger.ts +11 -1
  148. package/src/contract.ts +3 -3
  149. package/src/descriptor/ProviderEntry.ts +5 -0
  150. package/src/descriptor/bases.ts +21 -5
  151. package/src/dispatch/ArgumentResolver.ts +49 -10
  152. package/src/dispatch/Dispatcher.ts +1 -1
  153. package/src/dispatch/HandlerFacade.ts +9 -10
  154. package/src/dispatch/InFlight.ts +2 -2
  155. package/src/dispatch/LocalRoutePolicy.ts +7 -7
  156. package/src/dispatch/PresenterExecutor.ts +0 -4
  157. package/src/dispatch/Release.ts +0 -2
  158. package/src/dispatch/RouteRegistry.ts +3 -3
  159. package/src/dispatch/StorageGuard.ts +3 -9
  160. package/src/dispatch/remoteRoutes.ts +2 -2
  161. package/src/dispatch/routeNotFound.ts +6 -5
  162. package/src/dispatch/validateInput.ts +0 -4
  163. package/src/entry/facade.ts +2 -2
  164. package/src/entry/transport.ts +1 -1
  165. package/src/index.ts +1 -1
  166. package/src/wire/CallRecord.ts +1 -1
  167. package/src/wire/FougereError.ts +16 -5
  168. package/src/wire/FougereErrorOptions.ts +1 -1
  169. package/src/wire/FrondCall.ts +2 -2
  170. package/src/wire/OperationContext.ts +2 -2
  171. package/src/wire/RouteAddress.ts +5 -5
  172. package/src/wire/RouteAddressInput.ts +1 -1
  173. package/src/wire/RpcAnswer.ts +1 -1
  174. package/src/wire/SignedCall.ts +1 -1
  175. package/src/wire/StateShape.ts +2 -2
  176. package/src/wire/card/IdentityCard.ts +9 -7
  177. package/src/wire/drift.ts +22 -27
  178. package/src/wire/http-error.ts +2 -2
@@ -28,7 +28,7 @@ import { targetOf } from '../prefab/prefab.js';
28
28
  import { ownersOf, sharedNames, storageInUserCode, crudOnOwned } from './ownership.js';
29
29
  import { refuseArityDrift } from './arity.js';
30
30
  import { StorageGuard } from '../dispatch/StorageGuard.js';
31
- import { portBindings, seamChains, wrapping, SEAMS } from './ports.js';
31
+ import { heldKeyOf, portBindings, seamChains, wrapping, SEAMS } from './ports.js';
32
32
  import { contractsKeyOf, facadeKeyOf } from '../wire/Facade.js';
33
33
  import { inheritsCrud, subjectOf } from '../prefab/CrudConstructor.js';
34
34
  import { repositoryKeyOf } from '../prefab/RepositoryConstructor.js';
@@ -77,7 +77,7 @@ export interface Assembly {
77
77
  frondOf: Map<string, string>;
78
78
  contractsOf: (operations: EffectiveOperationsMap) => OperationsMap;
79
79
  /** Read at call time and never at boot, so a late registration still applies. */
80
- getMiddlewares: (entity: string) => AppMiddleware[];
80
+ getMiddlewares: (address: string) => AppMiddleware[];
81
81
  /** Take a middleware on — every entity when no entity is named. */
82
82
  use: (middleware: AppMiddleware, entity?: string) => void;
83
83
  /**
@@ -310,17 +310,23 @@ function registerProviders(
310
310
  // Registered from the INSIDE OUT, each wrapper asking for the one it stands in front
311
311
  // of: the container resolves a dep by NAME, so wrapping is a substituted key and
312
312
  // needs nothing of the container itself. The outermost answers under the port.
313
- let inner = nameOf(chain.at(-1)!);
314
- for (const wrapper of chain.slice(0, -1).reverse()) {
313
+ const realization = chain.at(-1)!;
314
+ const held = realization.deps.includes(port);
315
+ const wrappers = held ? chain : chain.slice(0, -1);
316
+ let inner = held ? heldKeyOf(port) : nameOf(realization);
317
+ if (held) scope.registerValue(inner, scope.resolve(port));
318
+ for (const wrapper of wrappers.slice(1).reverse()) {
315
319
  const deps = ownLogger(nameOf(wrapper), wrapper.deps).map((dep) => (dep === port ? inner : dep));
316
320
  inner = nameOf(wrapper);
317
321
  scope.register(inner, wrapper.ctor, { deps, ...lifetimeOf(wrapper.ctor) });
318
322
  }
319
323
  const outermost = chain[0]!;
320
- scope.register(port, outermost.ctor, {
321
- deps: ownLogger(nameOf(outermost), outermost.deps).map((dep) => (dep === port ? nameOf(chain[1]!) : dep)),
324
+ const outermostOptions = {
325
+ deps: ownLogger(nameOf(outermost), outermost.deps).map((dep) => (dep === port ? inner : dep)),
322
326
  ...lifetimeOf(outermost.ctor),
323
- });
327
+ };
328
+ if (wrappers.length > 0) scope.register(nameOf(outermost), outermost.ctor, outermostOptions);
329
+ scope.register(port, outermost.ctor, outermostOptions);
324
330
  boundPorts.add(port);
325
331
  frondLog.debug(`port ${port} → ${chain.map((one) => one.ctor.name).join(' → ')}`);
326
332
  }
@@ -445,7 +451,7 @@ function buildFacadeInto(
445
451
  routeRegistry.register(new OperationRoute(
446
452
  'local',
447
453
  new RouteAddress({
448
- entity: handler.address,
454
+ address: handler.address,
449
455
  operation,
450
456
  ...(surface !== undefined ? { surface } : {}),
451
457
  }),
@@ -1,12 +1,12 @@
1
1
  /** The two readings core serves everywhere, asked of one process over one transport. */
2
2
  import { Invocation } from '../wire/Invocation.js';
3
- import { RPC_ENTITY } from '../wire/RpcAnswer.js';
3
+ import { RPC_ADDRESS } from '../wire/RpcAnswer.js';
4
4
  import type { Transport } from '../wire/Transport.js';
5
5
  import type { Peer } from './Peer.js';
6
6
 
7
7
  export function peerOver(transport: Transport): Peer {
8
8
  const ask = (op: string, params: Record<string, unknown>) =>
9
- transport({ entity: RPC_ENTITY, op }, { ...Invocation.empty, params: params as never });
9
+ transport({ address: RPC_ADDRESS, op }, { ...Invocation.empty, params: params as never });
10
10
 
11
11
  return {
12
12
  missing: async (entity, keys) =>
package/src/boot/ports.ts CHANGED
@@ -20,6 +20,9 @@ import type { Diagnostic } from '../diagnostic.js';
20
20
  */
21
21
  export const SEAMS = new Set(['Storage']);
22
22
 
23
+ /** Where a chain of wrappers finds what the scope already answered under its port — a builtin's own instance. */
24
+ export const heldKeyOf = (port: string): string => `held:${port}`;
25
+
23
26
  /**
24
27
  * What stands in front of each seam, outermost first — the same reading `portBindings`
25
28
  * does, with no realization to find.
@@ -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 { RPC_ENTITY } from '../wire/RpcAnswer.js';
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, type SchemaDescriptor } from '@fougere/schema';
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
- * The entity's schema, rebuilt from the identity card — the same `SchemaConstructor` shape
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
- schema?: SchemaView;
28
+ outputs: Map<string, SchemaView>;
29
29
  }
30
30
 
31
31
  export interface RemoteRouter {
32
- route(entity: string): Promise<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 byEntity = new Map<string, Route>();
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 entities live behind it.
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({ entity: RPC_ENTITY, op: 'discover' }, Invocation.empty);
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, byEntity, claimedBy);
76
+ claimFacades(answered, byAddress, claimedBy);
77
+ storedBy(answered, stored);
74
78
  }
75
79
  };
76
80
 
77
81
  return {
78
- async route(entity) {
79
- if (!byEntity.has(entity) && pending.size > 0) await discover();
80
- const hit = byEntity.get(entity);
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 hosts '${entity}' — unreachable: ${[...pending.keys()].join(', ')}.\n`
94
+ message: `No reachable remote serves '${address}' — unreachable: ${[...pending.keys()].join(', ')}.\n`
86
95
  + ' Named in `remotes:`, and did not answer.',
87
- entity,
96
+ address,
88
97
  });
89
98
  }
90
99
  throw new FougereError({
91
100
  code: ErrorCode.NOT_FOUND,
92
- message: `No declared remote hosts '${entity}'.\n`
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
- entity,
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
- entity: string,
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, schema } = await router.route(entity);
111
- const call: FrondCall = { frond, entity, op };
112
- const state = received.crossed ? { ...received.state } : shape.judge(received.state, entity, op);
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
- entity, frond, operation: op, args: [], state, invocation, crosses: true,
125
+ address, frond, operation: op, args: [], state, invocation, crosses: true,
117
126
  };
118
- const answer = await runMiddlewares(middlewaresFor(entity), ctx, () =>
119
- transport(call, { ...(ctx.invocation ?? invocation), state: shape.judge(ctx.state, entity, op, entered) }));
120
-
121
- // The schema the card carried, put to work: a row crosses as data and comes back through
122
- // the same codecs a local facade applies, so a placement does not decide what a caller
123
- // holds. It is the ENTITY's — an op serving a narrower view is the far side's business,
124
- // and what this card names is the shape it publishes.
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
- byEntity: Map<string, Route>,
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 entity, not a frond, so nothing could choose between them.\n`
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 entity name.`,
162
- entity: facade.name,
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
- byEntity.set(facade.name, {
175
+ byAddress.set(facade.name, {
168
176
  frond: frond.name,
169
177
  transport,
170
- ...(facade.schema ? { schema: Card.fromDescriptor(facade.schema as SchemaDescriptor).toSchema() } : {}),
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
+ }
@@ -119,6 +119,16 @@ export class Logger {
119
119
  const objectOf = (arg: unknown): object =>
120
120
  typeof arg === 'object' && arg !== null && !Array.isArray(arg) ? arg : { value: arg };
121
121
 
122
+ /**
123
+ * The operation as the line's writer sees it: bare when the writer is the class that runs it —
124
+ * `app:blog:PostHandler (publish)` — and whole when a service writes during another's.
125
+ */
126
+ function operationSeenFrom(name: string, during: string): string {
127
+ const dot = during.lastIndexOf('.');
128
+
129
+ return name.endsWith(`:${during.slice(0, dot)}`) ? during.slice(dot + 1) : during;
130
+ }
131
+
122
132
  export function formatted(
123
133
  record: Rendered,
124
134
  color = supportsColor(),
@@ -126,7 +136,7 @@ export function formatted(
126
136
  const style = LEVEL_STYLE[record.level];
127
137
  const time = stamp(record.at);
128
138
  const method = record.level;
129
- const during = record.during ? ` (${record.during})` : '';
139
+ const during = record.during ? ` (${operationSeenFrom(record.name, record.during)})` : '';
130
140
 
131
141
  if (color) {
132
142
  const c = COLORS[style.color];
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 { RPC_ENTITY } from './wire/RpcAnswer.js';
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({ entity, op }, input)` (raw, for dynamic bridges).
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
- ? [{ entity: lowerFirst((target as { name: string }).name), op: opOrInput }, input]
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
  }
@@ -1,13 +1,19 @@
1
1
  import { type ProviderEntry } from './ProviderEntry.js';
2
2
 
3
3
  /**
4
- * Port class name → the classes that extend it, in scan order.
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
- const base = Object.getPrototypeOf(provider.ctor) as { name?: string } | null;
24
- const port = base?.name;
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
- // `null` is a value, not a miss. Nullish coalescing used to make an explicit
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.entity, call.address.operation);
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 entity = this.handler.address;
83
+ const address = this.handler.address;
83
84
  const contract = this.contracts.get(op);
84
85
  if (!contract) {
85
86
  throw new Error(
86
- `${entity} serves no operation '${op}'. `
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, entity, op);
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
- entity,
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 }, this.handler.address, op);
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(entity)),
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)
@@ -11,12 +11,12 @@ export class InFlight {
11
11
  return this.running;
12
12
  }
13
13
 
14
- enter(entity: string, operation: string): () => void {
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
- entity,
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.entity === 'rpc') return undefined;
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 entity = call.address.entity;
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
- ? `Entity '${entity}' is not served on surface '${surface}'`
30
- : `Entity '${entity}' is not hosted here`)
31
- + (hosted.length ? `. Hosted here: ${hosted.join(', ')}.` : '. This app hosts no entity.'),
32
- entity,
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
  }
@@ -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
- entity: address.entity,
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(entity: string, surface?: string): string[] {
72
+ operationNames(address: string, surface?: string): string[] {
73
73
  return this.routes()
74
- .filter((route) => route.address.entity === entity && route.address.surface === surface)
74
+ .filter((route) => route.address.address === address && route.address.surface === surface)
75
75
  .map((route) => route.address.operation);
76
76
  }
77
77
  }