@fougere/core 0.12.2-alpha.0 → 0.12.5-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 (204) hide show
  1. package/dist/EffectiveOperationModel.d.ts +1 -4
  2. package/dist/EffectiveOperationModel.d.ts.map +1 -1
  3. package/dist/EffectiveOperationModel.js +181 -173
  4. package/dist/EffectiveOperationModel.js.map +1 -1
  5. package/dist/FougereConfig.d.ts +2 -3
  6. package/dist/FougereConfig.d.ts.map +1 -1
  7. package/dist/FougereConfig.js +11 -13
  8. package/dist/FougereConfig.js.map +1 -1
  9. package/dist/FrondDeclaration.d.ts +5 -0
  10. package/dist/FrondDeclaration.d.ts.map +1 -1
  11. package/dist/FrondDeclaration.js +4 -5
  12. package/dist/FrondDeclaration.js.map +1 -1
  13. package/dist/FrondsStated.d.ts +4 -2
  14. package/dist/FrondsStated.d.ts.map +1 -1
  15. package/dist/FrondsStated.js.map +1 -1
  16. package/dist/Source.js +1 -1
  17. package/dist/Source.js.map +1 -1
  18. package/dist/SourceView.d.ts +0 -2
  19. package/dist/SourceView.d.ts.map +1 -1
  20. package/dist/StatedFrond.d.ts +3 -0
  21. package/dist/StatedFrond.d.ts.map +1 -1
  22. package/dist/StatedFrond.js +4 -0
  23. package/dist/StatedFrond.js.map +1 -1
  24. package/dist/StatedModules.d.ts +5 -2
  25. package/dist/StatedModules.d.ts.map +1 -1
  26. package/dist/StatedModules.js +11 -6
  27. package/dist/StatedModules.js.map +1 -1
  28. package/dist/boot/App.d.ts +2 -4
  29. package/dist/boot/App.d.ts.map +1 -1
  30. package/dist/boot/AppLifecycle.d.ts +8 -0
  31. package/dist/boot/AppLifecycle.d.ts.map +1 -1
  32. package/dist/boot/AppLifecycle.js +24 -10
  33. package/dist/boot/AppLifecycle.js.map +1 -1
  34. package/dist/boot/AuthRuntime.d.ts +5 -12
  35. package/dist/boot/AuthRuntime.d.ts.map +1 -1
  36. package/dist/boot/AuthRuntime.js +2 -1
  37. package/dist/boot/AuthRuntime.js.map +1 -1
  38. package/dist/boot/CreateAppOptions.d.ts +0 -11
  39. package/dist/boot/CreateAppOptions.d.ts.map +1 -1
  40. package/dist/boot/Emissions.d.ts.map +1 -1
  41. package/dist/boot/Emissions.js +3 -6
  42. package/dist/boot/Emissions.js.map +1 -1
  43. package/dist/boot/Extension.d.ts +6 -0
  44. package/dist/boot/Extension.d.ts.map +1 -1
  45. package/dist/boot/Hosting.d.ts +2 -1
  46. package/dist/boot/Hosting.d.ts.map +1 -1
  47. package/dist/boot/bootstrap.d.ts.map +1 -1
  48. package/dist/boot/bootstrap.js +149 -263
  49. package/dist/boot/bootstrap.js.map +1 -1
  50. package/dist/boot/install.d.ts +3 -0
  51. package/dist/boot/install.d.ts.map +1 -1
  52. package/dist/boot/install.js +103 -114
  53. package/dist/boot/install.js.map +1 -1
  54. package/dist/boot/nesting.d.ts.map +1 -1
  55. package/dist/boot/nesting.js +2 -1
  56. package/dist/boot/nesting.js.map +1 -1
  57. package/dist/boot/relations.d.ts.map +1 -1
  58. package/dist/boot/relations.js.map +1 -1
  59. package/dist/boot/remote.d.ts +2 -1
  60. package/dist/boot/remote.d.ts.map +1 -1
  61. package/dist/boot/remote.js +7 -4
  62. package/dist/boot/remote.js.map +1 -1
  63. package/dist/boot/seed.d.ts +2 -1
  64. package/dist/boot/seed.d.ts.map +1 -1
  65. package/dist/boot/seed.js +10 -29
  66. package/dist/boot/seed.js.map +1 -1
  67. package/dist/descriptor/ExtensionEntry.d.ts +4 -0
  68. package/dist/descriptor/ExtensionEntry.d.ts.map +1 -1
  69. package/dist/descriptor/ExtensionEntry.js +6 -1
  70. package/dist/descriptor/ExtensionEntry.js.map +1 -1
  71. package/dist/descriptor/FrondDescriptor.d.ts +2 -0
  72. package/dist/descriptor/FrondDescriptor.d.ts.map +1 -1
  73. package/dist/descriptor/FrondDescriptor.js +5 -0
  74. package/dist/descriptor/FrondDescriptor.js.map +1 -1
  75. package/dist/descriptor/Fronds.d.ts +5 -0
  76. package/dist/descriptor/Fronds.d.ts.map +1 -1
  77. package/dist/descriptor/Fronds.js +8 -0
  78. package/dist/descriptor/Fronds.js.map +1 -1
  79. package/dist/descriptor/index.d.ts +1 -0
  80. package/dist/descriptor/index.d.ts.map +1 -1
  81. package/dist/descriptor/index.js +1 -0
  82. package/dist/descriptor/index.js.map +1 -1
  83. package/dist/dispatch/ArgumentResolver.d.ts.map +1 -1
  84. package/dist/dispatch/ArgumentResolver.js +2 -1
  85. package/dist/dispatch/ArgumentResolver.js.map +1 -1
  86. package/dist/dispatch/Facade.d.ts +3 -0
  87. package/dist/dispatch/Facade.d.ts.map +1 -1
  88. package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
  89. package/dist/dispatch/HandlerFacade.js +8 -4
  90. package/dist/dispatch/HandlerFacade.js.map +1 -1
  91. package/dist/dispatch/InFlight.d.ts +3 -2
  92. package/dist/dispatch/InFlight.d.ts.map +1 -1
  93. package/dist/dispatch/InFlight.js +11 -1
  94. package/dist/dispatch/InFlight.js.map +1 -1
  95. package/dist/index.d.ts +2 -4
  96. package/dist/index.d.ts.map +1 -1
  97. package/dist/index.js +2 -0
  98. package/dist/index.js.map +1 -1
  99. package/dist/node.d.ts +1 -1
  100. package/dist/node.d.ts.map +1 -1
  101. package/dist/node.js +1 -1
  102. package/dist/node.js.map +1 -1
  103. package/dist/prefab/CrudConstructor.d.ts.map +1 -1
  104. package/dist/prefab/CrudConstructor.js.map +1 -1
  105. package/dist/prefab/collector.d.ts +6 -0
  106. package/dist/prefab/collector.d.ts.map +1 -1
  107. package/dist/prefab/collector.js +26 -1
  108. package/dist/prefab/collector.js.map +1 -1
  109. package/dist/storage/Comparison.d.ts +2 -2
  110. package/dist/storage/Comparison.d.ts.map +1 -1
  111. package/dist/storage/Comparison.js +3 -1
  112. package/dist/storage/Comparison.js.map +1 -1
  113. package/dist/storage/Storage.d.ts +3 -2
  114. package/dist/storage/Storage.d.ts.map +1 -1
  115. package/dist/storage/Storage.js +23 -10
  116. package/dist/storage/Storage.js.map +1 -1
  117. package/dist/storage/Store.d.ts.map +1 -1
  118. package/dist/storage/Store.js +4 -2
  119. package/dist/storage/Store.js.map +1 -1
  120. package/dist/wire/Emit.d.ts +0 -2
  121. package/dist/wire/Emit.d.ts.map +1 -1
  122. package/dist/wire/Emit.js +1 -1
  123. package/dist/wire/Emit.js.map +1 -1
  124. package/dist/wire/Facade.d.ts +7 -0
  125. package/dist/wire/Facade.d.ts.map +1 -1
  126. package/dist/wire/Facade.js +13 -0
  127. package/dist/wire/Facade.js.map +1 -1
  128. package/dist/wire/Invocation.d.ts +2 -1
  129. package/dist/wire/Invocation.d.ts.map +1 -1
  130. package/dist/wire/Invocation.js +4 -1
  131. package/dist/wire/Invocation.js.map +1 -1
  132. package/dist/wire/InvocationContext.d.ts +2 -0
  133. package/dist/wire/InvocationContext.d.ts.map +1 -1
  134. package/dist/wire/OperationContract.d.ts +0 -4
  135. package/dist/wire/OperationContract.d.ts.map +1 -1
  136. package/dist/wire/OperationContract.js +1 -1
  137. package/dist/wire/OperationContract.js.map +1 -1
  138. package/dist/wire/OperationsMap.d.ts +0 -3
  139. package/dist/wire/OperationsMap.d.ts.map +1 -1
  140. package/dist/wire/OperationsMap.js +0 -5
  141. package/dist/wire/OperationsMap.js.map +1 -1
  142. package/dist/wire/StateShape.d.ts +27 -0
  143. package/dist/wire/StateShape.d.ts.map +1 -0
  144. package/dist/wire/StateShape.js +59 -0
  145. package/dist/wire/StateShape.js.map +1 -0
  146. package/package.json +4 -4
  147. package/src/EffectiveOperationModel.ts +221 -201
  148. package/src/FougereConfig.ts +11 -16
  149. package/src/FrondDeclaration.ts +7 -7
  150. package/src/FrondsStated.ts +4 -2
  151. package/src/Source.ts +1 -1
  152. package/src/SourceView.ts +0 -2
  153. package/src/StatedFrond.ts +5 -0
  154. package/src/StatedModules.ts +17 -9
  155. package/src/boot/App.ts +2 -4
  156. package/src/boot/AppLifecycle.ts +27 -10
  157. package/src/boot/AuthRuntime.ts +6 -13
  158. package/src/boot/CreateAppOptions.ts +0 -11
  159. package/src/boot/Emissions.ts +3 -7
  160. package/src/boot/Extension.ts +6 -0
  161. package/src/boot/Hosting.ts +2 -1
  162. package/src/boot/bootstrap.ts +179 -300
  163. package/src/boot/install.ts +124 -146
  164. package/src/boot/nesting.ts +2 -1
  165. package/src/boot/relations.ts +3 -8
  166. package/src/boot/remote.ts +8 -3
  167. package/src/boot/seed.ts +11 -38
  168. package/src/descriptor/ExtensionEntry.ts +16 -1
  169. package/src/descriptor/FrondDescriptor.ts +7 -0
  170. package/src/descriptor/Fronds.ts +10 -0
  171. package/src/descriptor/index.ts +1 -0
  172. package/src/dispatch/ArgumentResolver.ts +2 -1
  173. package/src/dispatch/Facade.ts +3 -0
  174. package/src/dispatch/HandlerFacade.ts +8 -3
  175. package/src/dispatch/InFlight.ts +16 -2
  176. package/src/index.ts +2 -4
  177. package/src/node.ts +1 -1
  178. package/src/prefab/CrudConstructor.ts +0 -11
  179. package/src/prefab/collector.ts +29 -1
  180. package/src/storage/Comparison.ts +7 -4
  181. package/src/storage/Storage.ts +26 -12
  182. package/src/storage/Store.ts +4 -2
  183. package/src/wire/Emit.ts +1 -1
  184. package/src/wire/Facade.ts +17 -10
  185. package/src/wire/Invocation.ts +3 -1
  186. package/src/wire/InvocationContext.ts +2 -0
  187. package/src/wire/OperationContract.ts +1 -1
  188. package/src/wire/OperationsMap.ts +0 -10
  189. package/src/wire/StateShape.ts +76 -0
  190. package/dist/boot/AuthConfig.d.ts +0 -14
  191. package/dist/boot/AuthConfig.d.ts.map +0 -1
  192. package/dist/boot/AuthConfig.js +0 -2
  193. package/dist/boot/AuthConfig.js.map +0 -1
  194. package/dist/boot/AuthContext.d.ts +0 -12
  195. package/dist/boot/AuthContext.d.ts.map +0 -1
  196. package/dist/boot/AuthContext.js +0 -2
  197. package/dist/boot/AuthContext.js.map +0 -1
  198. package/dist/storage/ComparisonName.d.ts +0 -3
  199. package/dist/storage/ComparisonName.d.ts.map +0 -1
  200. package/dist/storage/ComparisonName.js +0 -2
  201. package/dist/storage/ComparisonName.js.map +0 -1
  202. package/src/boot/AuthConfig.ts +0 -14
  203. package/src/boot/AuthContext.ts +0 -12
  204. package/src/storage/ComparisonName.ts +0 -3
@@ -14,8 +14,8 @@ import { peerOver } from './peerOver.js';
14
14
  import { JOURNAL, type Journal } from '../dispatch/Journal.js';
15
15
  import type { RelationCheck } from '../dispatch/RelationCheck.js';
16
16
  import { refusalOf, type Diagnostic } from '../diagnostic.js';
17
- import type { AuthRuntime } from './AuthRuntime.js';
18
17
  import type { App } from './App.js';
18
+ import type { Extension } from './Extension.js';
19
19
  import type { CreateAppOptions } from './CreateAppOptions.js';
20
20
  import type { AppMiddleware } from '../wire/AppMiddleware.js';
21
21
  import { Carry } from '../builtin/Carry.js';
@@ -24,8 +24,6 @@ import type { LogRecord } from '../builtin/LogRecord.js';
24
24
  import LogLine, { CARRIES_LINE } from '../builtin/LogLine.js';
25
25
  import { emitKeyOf, type Emit } from '../wire/Emit.js';
26
26
 
27
- /** The fact the boot announces, spelled once. */
28
- const LOG_LINE = lowerFirst(LogLine.name);
29
27
  import { Config } from '../builtin/config.js';
30
28
  import { createRemoteRouter, createRemoteFacade, type RemoteRouter } from './remote.js';
31
29
  import type { Peer } from './Peer.js';
@@ -38,15 +36,14 @@ import { resolveEffectiveOperations, type EffectiveOperationModel } from '../Eff
38
36
  import { type EffectiveOperationsMap } from '../EffectiveOperationsMap.js';
39
37
 
40
38
  import { InFlight } from '../dispatch/InFlight.js';
41
- // The keys, each read from where its concept is declared — never respelled here.
42
- import { RPC_ENTITY, type RpcAnswer } from '../wire/RpcAnswer.js';
39
+ import { RPC_ENTITY } from '../wire/RpcAnswer.js';
43
40
  import { Invocation } from '../wire/Invocation.js';
44
- import { facadeKeyOf } from '../wire/Facade.js';
41
+ import { addressOf, facadeKeyOf, isFacadeKey } from '../wire/Facade.js';
45
42
  import { identityCardOf } from './card.js';
46
- import { AppLifecycle, migrating } from './AppLifecycle.js';
43
+ import { AppLifecycle, closeAll, migrating } from './AppLifecycle.js';
47
44
  import { seeding } from './seed.js';
48
45
 
49
- import { storageKeyOf } from '../storage/Storage.js';
46
+ import { storageKeyOf, type Storage } from '../storage/Storage.js';
50
47
 
51
48
  import { presenterKeyOf } from '../prefab/presenter.js';
52
49
 
@@ -60,6 +57,8 @@ import { remoteRoutes } from '../dispatch/remoteRoutes.js';
60
57
  import { RouteRegistry } from '../dispatch/RouteRegistry.js';
61
58
  import { facadeOperations } from '../entry/facade.js';
62
59
 
60
+ const LOG_LINE = lowerFirst(LogLine.name);
61
+
63
62
  /** The one wording for "nobody hosts this here", with both ways out. */
64
63
  const notLoaded = (entity: string) =>
65
64
  `Frond for '${entity}' is not loaded.\n` +
@@ -123,11 +122,11 @@ async function readFronds(
123
122
  const { fronds, diagnostics } = await hostedBy(
124
123
  brought.length > 0 ? { ...options, fronds: [...(options.fronds ?? []), ...brought] } : options,
125
124
  );
126
- // An app that states nothing AND scans nothing is a mistake — unless something else it
127
- // declares brings its own entities, which an auth provider does. Refused here and not in
128
- // `hostedBy`, which is handed the frond sources and cannot see the rest of the app. The
129
- // condition is the KEYS, not the count: a scan that found nothing is an ordinary answer.
130
- if (!options.fronds && !options.scan && !options.auth && brought.length === 0) {
125
+ // An app that states nothing AND scans nothing is a mistake — unless an extension brings
126
+ // fronds of its own. Refused here and not in `hostedBy`, which is handed the frond sources
127
+ // and cannot see the rest of the app. The condition is the KEYS, not the count: a scan that
128
+ // found nothing is an ordinary answer.
129
+ if (!options.fronds && !options.scan && brought.length === 0) {
131
130
  throw new Error(
132
131
  'createApp needs `fronds:` (what this app states) or `scan:` (what a scanner found). '
133
132
  + 'Neither was given, and nothing else declares entities of its own.\n'
@@ -175,14 +174,14 @@ async function readFronds(
175
174
  function serveCoreRpc(
176
175
  app: App,
177
176
  hosting: Hosting,
178
- storageFor: (entity: string) => unknown | undefined,
177
+ storageFor: (entity: string) => Storage | undefined,
179
178
  ): void {
180
179
  app.serveRpc('discover', (_invocation, surface) => identityCardOf(app, surface));
181
180
 
182
181
  app.serveRpc('holds', async (invocation) => {
183
182
  const named = String(invocation.params.entity);
184
183
  const keys = (invocation.params.keys ?? []) as readonly unknown[];
185
- const rows = storageFor(named) as { findByKeys(k: readonly string[]): Promise<Map<string, unknown>> } | undefined;
184
+ const rows = storageFor(named);
186
185
  if (!rows) return { missing: [] };
187
186
  const found = await rows.findByKeys(keys.map(String));
188
187
 
@@ -239,22 +238,14 @@ function peerBehind(entity: string, router: RemoteRouter): Peer {
239
238
  };
240
239
  }
241
240
 
242
- /** Close the door, and answer once the calls already running are done. */
243
- async function drainCalls(inflight: InFlight, timeoutMs?: number): Promise<void> {
244
- inflight.close();
245
- if (timeoutMs === undefined) return inflight.whenIdle();
241
+ function ownedBy<T>(fronds: Fronds, container: Container, entity: string, key: string): T | undefined {
242
+ const owner = fronds.owner(entity);
243
+ const scopeKey = `frond:${owner?.name}`;
244
+ if (!owner || !container.has(scopeKey)) return undefined;
246
245
 
247
- let timer: ReturnType<typeof setTimeout>;
246
+ const scope = container.resolve<Container>(scopeKey);
248
247
 
249
- await Promise.race([
250
- inflight.whenIdle().then(() => clearTimeout(timer)),
251
- new Promise<never>((_, reject) => {
252
- timer = setTimeout(
253
- () => reject(new Error(`[drain] ${inflight.count} call(s) still running after ${timeoutMs}ms`)),
254
- timeoutMs,
255
- );
256
- }),
257
- ]);
248
+ return scope.has(key) ? scope.resolve<T>(key) : undefined;
258
249
  }
259
250
 
260
251
  /** What a call goes through in this process, before any frond is installed into it. */
@@ -284,7 +275,7 @@ function dispatching(
284
275
  const scopedMiddlewares = new Map<string, AppMiddleware[]>();
285
276
  const routeRegistry = new RouteRegistry();
286
277
  const dispatchLifecycle = new DispatchLifecycle(
287
- options.dispatchObservers,
278
+ [],
288
279
  (error, event) => log.error(
289
280
  `[dispatch-observer] ${event.stage} ${event.call.address.toString()}`,
290
281
  error,
@@ -354,15 +345,14 @@ interface Hosted {
354
345
  fronds: Fronds;
355
346
  container: Container;
356
347
  options: CreateAppOptions;
357
- declaredRemotes: [string, string][];
358
348
  remoteRouter: RemoteRouter | undefined;
359
349
  entityByName: Map<string, SchemaView>;
360
350
  frondOf: Map<string, string>;
361
- storageOf: (entity: string) => unknown | undefined;
351
+ storageOf: (entity: string) => Storage | undefined;
362
352
  }
363
353
 
364
354
  function hostingFor(
365
- { fronds, container, options, declaredRemotes, remoteRouter, entityByName, frondOf, storageOf }: Hosted,
355
+ { fronds, container, options, remoteRouter, entityByName, frondOf, storageOf }: Hosted,
366
356
  ): { hosting: Hosting; journalOf: () => Journal | undefined } {
367
357
  // What carries a release writes none: an instrumentation frond's own rows are kept while a
368
358
  // release happens, and journalling them would begin one inside the one being written down.
@@ -396,7 +386,7 @@ function hostingFor(
396
386
  // off a card, while the question here is asked of a PROCESS about rows it may be alone in
397
387
  // knowing about. Nothing is cached — a peer that was down at boot answers the next call.
398
388
  peers: () => (options.remoteTransport
399
- ? declaredRemotes.map(([, url]) => peerOver(options.remoteTransport!(url)))
389
+ ? Object.values(options.remotes ?? {}).map((url) => peerOver(options.remoteTransport!(url)))
400
390
  : []),
401
391
  peerOf: (entity) => (remoteRouter ? peerBehind(entity, remoteRouter) : undefined),
402
392
  };
@@ -423,14 +413,11 @@ interface Serving {
423
413
  function readings(
424
414
  { container, fronds, remoteRouter, localDispatcher, routeRegistry, effectiveByKey, log }: Serving,
425
415
  ): Pick<App, 'resolve' | 'schemaFor' | 'facadeFor' | 'operationsFor' | 'presenterFor'> {
426
- /** Stop taking calls, and resolve once the ones already running are done. */
427
416
  const resolve = <T>(name: string): T => {
428
417
  try {
429
418
  return container.resolve<T>(name);
430
419
  } catch (err) {
431
- if (name.endsWith('Handler') && !name.includes(':') && !remoteRouter) {
432
- throw new Error(notLoaded(name.replace(/Handler$/, '')));
433
- }
420
+ if (isFacadeKey(name) && !remoteRouter) throw new Error(notLoaded(addressOf(name)));
434
421
  throw err;
435
422
  }
436
423
  };
@@ -487,17 +474,15 @@ function readings(
487
474
  if (!surface) return facadeAt(facadeKeyOf(entity), true);
488
475
 
489
476
  const own = facadeAt(facadeKeyOf(entity, surface), false);
490
- const owner = fronds.owner(entity);
491
- if (!owner) {
477
+ if (!fronds.owner(entity)) {
492
478
  sayNoSurfaceAcross(entity, surface);
493
479
 
494
480
  return own;
495
481
  }
496
482
 
497
- const declared = owner.surfaces?.[surface];
498
- if (!declared) return own;
499
- if (!declared.some((n) => n.toLowerCase() === entity.toLowerCase())) return undefined;
500
- if (own) return own;
483
+ const admitted = fronds.admits(surface, entity);
484
+ if (admitted === false) return undefined;
485
+ if (own || admitted === undefined) return own;
501
486
 
502
487
  const fallback = facadeAt(facadeKeyOf(entity), false);
503
488
 
@@ -511,257 +496,208 @@ function readings(
511
496
  : undefined;
512
497
  };
513
498
 
514
- /** The terms beside a facade, with the exact same named-surface fallback rule. */
515
499
  const operationsFor = (entity: string, surface?: string): EffectiveOperationsMap | undefined => {
516
500
  if (!surface) return effectiveByKey.get(facadeKeyOf(entity));
517
501
 
518
502
  const own = effectiveByKey.get(facadeKeyOf(entity, surface));
519
- const declared = fronds.owner(entity)?.surfaces?.[surface];
520
- if (!declared) return own;
503
+ const admitted = fronds.admits(surface, entity);
504
+ if (admitted === false) return undefined;
521
505
 
522
- return declared.some((name) => name.toLowerCase() === entity.toLowerCase())
523
- ? (own ?? effectiveByKey.get(facadeKeyOf(entity)))
524
- : undefined;
506
+ return admitted ? (own ?? effectiveByKey.get(facadeKeyOf(entity))) : own;
525
507
  };
526
508
 
527
- /** The presenter of an entity, resolved through its owning frond's scope. */
528
- const presenterFor = (entity: string): unknown | undefined => {
529
- const owner = fronds.owner(entity);
530
- if (!owner) return undefined;
509
+ const presenterFor = (entity: string): unknown | undefined => ownedBy(fronds, container, entity, presenterKeyOf(entity));
531
510
 
532
- try {
533
- return container.resolve<Container>(`frond:${owner.name}`).resolve(presenterKeyOf(entity));
534
- } catch {
535
- return undefined;
511
+ return { resolve, schemaFor, facadeFor, operationsFor, presenterFor };
512
+ }
513
+
514
+ /**
515
+ * Registered under the class name, for type-based DI. A logger holds no level — it reads
516
+ * `setLogLevel`'s at each line — and a frond declaring `class X extends Logger` takes the key.
517
+ */
518
+ function registerBuiltins(container: Container, carry: Carry): void {
519
+ container.registerValue('Logger', new Logger('app', { carry }));
520
+ container.register('Config', Config, { lifetime: 'singleton' });
521
+ }
522
+
523
+ /** `remotes:` IS the topology statement: the frond's code may sit here, it runs elsewhere. */
524
+ function remoteRouterOf(options: CreateAppOptions): RemoteRouter | undefined {
525
+ const declared = Object.entries(options.remotes ?? {});
526
+ if (declared.length === 0) return undefined;
527
+
528
+ if (!options.remoteTransport) {
529
+ throw new Error(
530
+ 'createApp: `remotes` is declared but `remoteTransport` is missing — pass one (e.g. from @fougere/transport-http).',
531
+ );
532
+ }
533
+
534
+ return createRemoteRouter(Object.fromEntries(declared), options.remoteTransport);
535
+ }
536
+
537
+ /** Every check of a phase, said at once — a boot that stops at the first shows the next after a restart. */
538
+ function refuseWhatDoesNotHold(refused: Diagnostic[]): void {
539
+ const refusal = refusalOf(refused, 'declaration(s) that do not hold');
540
+ if (refusal) throw refusal;
541
+ }
542
+
543
+ /**
544
+ * A `ports:` key no port matched reads as a choice that was made. Said once every frond is
545
+ * installed: the entry is app-wide, and no single frond can tell a typo from a neighbour's port.
546
+ */
547
+ function warnAboutUnboundPorts(options: CreateAppOptions, boundPorts: Set<string>, log: Logger): void {
548
+ const unbound = Object.keys(options.ports ?? {}).filter((port) => !boundPorts.has(port));
549
+ if (unbound.length === 0) return;
550
+
551
+ log.warn(
552
+ `[ports] ${unbound.join(', ')} — named in fougere.config.ts, but no scanned class extends `
553
+ + 'them, so nothing was chosen. Check the spelling, or drop the entry.',
554
+ );
555
+ }
556
+
557
+ /** Read from who SUBSCRIBED, so a third party's destination is left alone by the two middlewares that observe every operation. */
558
+ function markLineCarriers(emissions: Emissions): void {
559
+ for (const facade of emissions.facadesFor(LOG_LINE)) {
560
+ CARRIES_LINE.add(addressOf(facade));
561
+ CARRIES_LINE.add(facade);
562
+ }
563
+ }
564
+
565
+ /**
566
+ * A handler another process serves: a stand-in for whoever asks for it by type, and a route for
567
+ * whoever calls it. A dependency names the type as written — `ProductHandler` — while a card
568
+ * declares `product`.
569
+ */
570
+ function answerRemotes(
571
+ { container, routeRegistry, dispatcher, getMiddlewares, options, state }: Pick<Assembly, 'container' | 'routeRegistry' | 'dispatcher' | 'getMiddlewares' | 'options' | 'state'>,
572
+ remoteRouter: RemoteRouter,
573
+ fronds: Fronds,
574
+ ): void {
575
+ // Only for what another process may serve: an entity a frond hosts HERE and gives no handler
576
+ // is served by nobody, and a stand-in for it had GraphQL project an entity with no operation.
577
+ const servedElsewhere = (address: string): boolean => {
578
+ const owner = fronds.owner(address);
579
+
580
+ return owner === undefined || owner.name in (options.remotes ?? {});
581
+ };
582
+ container.setFallback((name) => (isFacadeKey(name) && servedElsewhere(addressOf(name))
583
+ ? facadeOperations(dispatcher, addressOf(name))
584
+ : undefined));
585
+
586
+ const remoteFacades = new Map<string, Record<string, Function>>();
587
+ routeRegistry.addResolver(remoteRoutes((entity) => {
588
+ const known = remoteFacades.get(entity);
589
+ if (known) return known;
590
+ const facade = createRemoteFacade(entity, remoteRouter, getMiddlewares, state);
591
+ remoteFacades.set(entity, facade);
592
+
593
+ return facade;
594
+ }));
595
+ }
596
+
597
+ /** Refused rather than replaced: two declarations of one name would make the answer depend on wiring order. */
598
+ function serveRpcOn(routeRegistry: RouteRegistry): App['serveRpc'] {
599
+ return (op, answer) => {
600
+ const address = new RouteAddress({ entity: 'rpc', operation: op });
601
+ if (routeRegistry.find(address)) {
602
+ throw new Error(
603
+ `[claim] rpc operation '${op}' is already served; a second declaration would depend on wiring order.\n`
604
+ + ' Two extensions declare it — keep one out of `extensions:`.',
605
+ );
536
606
  }
607
+
608
+ routeRegistry.register(new OperationRoute('system', address, (call) => answer(call.invocation, call.address.surface)));
537
609
  };
610
+ }
538
611
 
539
- return { resolve, schemaFor, facadeFor, operationsFor, presenterFor };
612
+ /** A frond's extension travels with its code, so it mounts on whichever process serves that frond. */
613
+ function frondExtensions(fronds: Fronds): Extension[] {
614
+ return fronds.flatMap((frond) => (frond.extensions ?? []).map((one) => ({ name: one.name, ...one.extension })));
540
615
  }
541
616
 
542
617
  /**
543
- * Built once from the lazy `AuthConfig` a provider factory produced (`betterAuth({…})` in
544
- * fougere.config.ts). The provider receives our db and storage factory, so every auth write
545
- * flows through `Storage` like any other.
618
+ * The boot's held lines, and every line after them, handed to the app's destinations — or let go
619
+ * when it has none. After the ascent, because handing them over resolves the destination, and what
620
+ * an extension's destination depends on is registered by that extension's `up`: resolved before,
621
+ * every held line died on `'LogRing' is not registered` (`demos/observability`). A line keeps
622
+ * `at`, when it was WRITTEN, which for a held boot line is not when it is handed over.
546
623
  */
547
- async function authFor(options: CreateAppOptions, log: Logger): Promise<AuthRuntime | undefined> {
548
- if (!options.auth) return undefined;
624
+ function announceLines(emissions: Emissions, container: Container, carry: Carry): (() => void) | undefined {
625
+ if (!emissions.listensTo().includes(LOG_LINE)) {
626
+ carry.forget();
549
627
 
550
- if (!options.storageFactory) {
551
- throw new Error('createApp: `auth` is set but `storageFactory` is missing — auth providers need it to back their adapter. Pass one through CreateAppOptions.storageFactory.');
552
- }
553
- if (options.db === undefined) {
554
- throw new Error('createApp: `auth` is set but `db` is missing — pass the storage handle through CreateAppOptions.db.');
628
+ return undefined;
555
629
  }
556
630
 
557
- log.info('initializing auth runtime');
558
- const runtime = await options.auth.create({ db: options.db, storageFactory: options.storageFactory });
559
- log.info(`auth ready — mounted at ${runtime.basePath}`);
631
+ const emit = container.resolve<Emit<LogLine>>(emitKeyOf(LogLine.name));
560
632
 
561
- return runtime;
633
+ return carry.to(({ at, ...line }: LogRecord) => void emit({ ...line, at: new Date(at) }));
562
634
  }
563
635
 
564
636
  /** Bootstrap a fougere application. */
565
637
  export async function createApp(options: CreateAppOptions): Promise<App> {
566
638
  const container = (options.createContainer ?? createContainer)();
567
-
568
- // Held out here, and not where the ascent reads it, because releasing needs it and
569
- // releasing has to work from the first line the boot takes something.
570
- // The conventional ascent, ordered here: tables, then rows, then whatever the host took
571
- // on. Four hosts assembled these two members themselves — the order is not theirs to
572
- // choose, and a host that forgot lost its migration in silence.
573
- const appLifecycle = new AppLifecycle().add(
574
- migrating(options.migrate),
575
- seeding(),
576
- ...(options.extensions ?? []),
577
- );
578
- /** The app once it exists — a refusal before that releases the two levels that do. */
579
- let built: App | undefined;
580
- /** Where THIS boot's lines wait — never a process-wide slot, see `Carry`. */
639
+ // Tables, then rows, then whatever the host took on — the order is not a host's to choose.
640
+ const appLifecycle = new AppLifecycle().add(migrating(options.migrate), seeding(), ...(options.extensions ?? []));
581
641
  const carry = new Carry();
582
- /** Given back by `carry.to`, so a released app stops writing into a dead container. */
642
+ let built: App | undefined;
583
643
  let stopAnnouncing: (() => void) | undefined;
584
644
 
585
- /**
586
- * Everything this app holds, let go in reverse of how it was taken: what an extension took on
587
- * last, then the container's own, then whoever handed a resource in.
588
- */
645
+ /** What an extension took on last, then the container's own, then whoever handed a resource in. */
589
646
  const release = async (): Promise<void> => {
590
- // Every level is told to close even when one refuses, and the refusals leave together —
591
- // the rule `Lifecycle.down` applies INSIDE its list, applied ACROSS the three. Stated
592
- // there and broken here, a refusing extension took the container and the connection
593
- // down with it, which is the leak this gesture exists to prevent.
594
- const refused: unknown[] = [];
595
- // Whatever is still held will never reach a destination — the console had it.
596
647
  stopAnnouncing?.();
597
648
  carry.forget();
598
- const levels = [
649
+
650
+ await closeAll([
599
651
  ...(built ? [() => appLifecycle.down(built!)] : []),
600
652
  () => container.dispose(),
601
653
  () => options.onDispose?.(),
602
- ];
603
- for (const level of levels) {
604
- try {
605
- await level();
606
- } catch (error) {
607
- // Flattened one level: an extension's refusals are already an AggregateError, and
608
- // nesting them would make the caller unwrap twice to read one list.
609
- if (error instanceof AggregateError) refused.push(...error.errors);
610
- else refused.push(error);
611
- }
612
- }
613
- if (refused.length > 0) {
614
- throw new AggregateError(refused, `${refused.length} refusal(s) while releasing the app`);
615
- }
654
+ ], 'refusal(s) while releasing the app');
616
655
  };
617
656
 
618
657
  try {
619
- // Boot chatter is debug by default; a host (e.g. the CLI) can quiet it.
620
658
  const log = new Logger('boot:app', { carry });
621
-
622
- // Builtins — registered under class name (PascalCase) for type-based DI.
623
- // No level here and none anywhere: a logger consults `setLogLevel`'s value at each
624
- // emission, so this instance survives a level change and so does every handler that
625
- // was handed it. A frond declaring `class X extends Logger` takes this key over,
626
- // like any other port.
627
- container.registerValue('Logger', new Logger('app', { carry }));
628
- container.register('Config', Config, { lifetime: 'singleton' });
659
+ registerBuiltins(container, carry);
629
660
  log.debug('builtins registered (Logger, Config)');
630
661
 
631
662
  const { fronds, operationModel } = await readFronds(options, log);
632
-
633
- const authRuntime = await authFor(options, log);
634
-
635
- // Remote routing — validated at boot: declaring remotes without a transport is a config error.
636
- // A remote declaration wins over local presence: `remotes: { blog: url }` IS
637
- // the topology statement — the frond's code may sit in fronds/**, it runs elsewhere.
638
- const declaredRemotes = Object.entries(options.remotes ?? {});
639
- if (declaredRemotes.length > 0 && !options.remoteTransport) {
640
- throw new Error(
641
- 'createApp: `remotes` is declared but `remoteTransport` is missing — pass one (e.g. from @fougere/transport-http).',
642
- );
643
- }
644
- const remoteRouter = declaredRemotes.length > 0 && options.remoteTransport
645
- ? createRemoteRouter(Object.fromEntries(declaredRemotes), options.remoteTransport)
646
- : undefined;
647
-
663
+ appLifecycle.add(...frondExtensions(fronds));
664
+ const remoteRouter = remoteRouterOf(options);
648
665
  const { inflight, routeRegistry, dispatchLifecycle, dispatcher, localDispatcher, getMiddlewares, use } =
649
666
  dispatching(options, log, fronds, () => journalOf());
650
667
 
651
- /** What every check of this boot writes into — refused together, once they have all run. */
652
668
  const refused: Diagnostic[] = [];
653
- const relations: RelationCheck[] = [];
654
669
  keyClaims(fronds, options.remotes, refused);
655
- // Said before anything is installed: a key claimed twice makes every later error worse —
656
- // the route registry collides first, and names a route instead of the two fronds.
657
- const claimed = refusalOf(refused, 'declaration(s) that do not hold');
658
- if (claimed) throw claimed;
670
+ refuseWhatDoesNotHold(refused);
659
671
 
660
672
  const { entityByName, frondOf, emissions } = whatTheAppKnows(fronds, container, options, refused);
661
-
662
- /** Canonical operation tables, indexed by the same audience key as their facades. */
663
673
  const effectiveByKey = new Map<string, EffectiveOperationsMap>();
664
-
665
- const contractsOf = (operations: EffectiveOperationsMap): OperationsMap => new Map(
666
- [...operations].map(([name, operation]) => [name, operation as OperationContract] as const),
667
- );
668
-
669
- /**
670
- * The storage an entity is backed by — the dual of `facadeFor`, which serves its client-facing
671
- * facade.
672
- */
673
- const storageFor = (entity: string): unknown | undefined => {
674
- const owner = fronds.owner(entity);
675
- if (!owner) return undefined;
676
-
677
- const key = storageKeyOf(entity);
678
- try {
679
- return container.resolve<Container>(`frond:${owner.name}`).resolve(key);
680
- } catch {
681
- return undefined;
682
- }
683
- };
684
-
685
- // What carries a release writes none: an instrumentation frond's own rows are kept while a
686
- // release happens, and journalling them would begin one inside the one being written down.
674
+ const storageFor = <T = Record<string, unknown>>(entity: string): Storage<T> | undefined =>
675
+ ownedBy<Storage<T>>(fronds, container, entity, storageKeyOf(entity));
687
676
  const { hosting, journalOf } = hostingFor({
688
- fronds, container, options, declaredRemotes, remoteRouter, entityByName, frondOf, storageOf: storageFor,
677
+ fronds, container, options, remoteRouter, entityByName, frondOf, storageOf: storageFor,
689
678
  });
690
679
 
691
- // Every port an implementation was bound to, so a `ports:` entry that named none
692
- // can say so rather than look obeyed.
680
+ const relations: RelationCheck[] = [];
693
681
  const boundPorts = new Set<string>();
694
-
695
- // Register frond scopes
696
- // What every frond is installed into, and reads while it is: one container, one route
697
- // table, one emission list — so what a frond serves is there for the next one to find.
698
682
  const assembly: Assembly = {
699
683
  container, routeRegistry, emissions, dispatcher, localDispatcher, effectiveByKey,
700
684
  boundPorts, refused, relations, hosting, operationModel, entityByName, frondOf, contractsOf,
701
685
  getMiddlewares, use, middlewaresOf: new Map(), seamsOf: new Map(), log, options,
686
+ state: appLifecycle.state(),
702
687
  };
703
688
  for (const frond of fronds) await installFrond(frond, assembly);
704
689
 
705
690
  warnAboutRelations(relations, hosting, log);
706
691
  refused.push(...unpaired(fronds.flatMap((frond) => frond.entities), hosting));
692
+ refuseWhatDoesNotHold(refused);
693
+ warnAboutUnboundPorts(options, boundPorts, log);
707
694
 
708
- // Every check of the install, said at once — a boot that stops at the first makes the
709
- // next one visible only after a fix and a restart.
710
- const bootRefusal = refusalOf(refused, 'declaration(s) that do not hold');
711
- if (bootRefusal) throw bootRefusal;
712
-
713
- // A `ports:` key that matched no port anywhere reads as a choice that was made, and
714
- // was not. Said once, at the end, because the entry is app-wide while a port is a
715
- // frond's — no single frond can tell whether a key is a typo or a neighbour's.
716
- const unused = Object.keys(options.ports ?? {}).filter((port) => !boundPorts.has(port));
717
- if (unused.length > 0) {
718
- log.warn(
719
- `[ports] ${unused.join(', ')} — named in fougere.config.ts, but no scanned class extends `
720
- + 'them, so nothing was chosen. Check the spelling, or drop the entry.',
721
- );
722
- }
723
-
724
- // Once every facade exists: what is announced here and what is listened to are both known.
695
+ // A pipe order and an `Emit<T, A>` are read here, once every facade exists.
725
696
  emissions.register();
697
+ refuseWhatDoesNotHold(refused);
698
+ markLineCarriers(emissions);
726
699
 
727
- // The third phase: what a fact's links and its answer type state. Said after `register`,
728
- // which is where a pipe order and an `Emit<T, A>` are read.
729
- const announced = refusalOf(refused, 'declaration(s) that do not hold');
730
- if (announced) throw announced;
731
-
732
- // Which facades carry a line, read from who SUBSCRIBED — so a third party's destination
733
- // is left alone by the two middlewares that observe every operation.
734
- for (const facade of emissions.facadesFor(LOG_LINE)) {
735
- CARRIES_LINE.add(facade.replace(/Handler$/, '').replace(/^./, (c) => c.toLowerCase()));
736
- CARRIES_LINE.add(facade);
737
- }
738
-
739
- /** The last resort, held by the container so every resolution path shares it. */
740
- container.setFallback?.((name) => {
741
- if (!remoteRouter) return undefined;
742
- if (!name.endsWith('Handler') || name.includes(':')) return undefined;
743
-
744
- // Façade-shaped stand-in; routing happens lazily at the first call. Through
745
- // `lowerFirst` because a DEPENDENCY names the type as written — `ProductHandler`,
746
- // PascalCase — while a card declares `product`, so the raw strip asked the router for
747
- // 'Product' and every by-type dependency on a remote handler answered NOT_FOUND.
748
- return facadeOperations(
749
- dispatcher,
750
- lowerFirst(name.replace(/Handler$/, '')),
751
- );
752
- });
753
-
754
- if (remoteRouter) {
755
- const remoteFacades = new Map<string, Record<string, Function>>();
756
- routeRegistry.addResolver(remoteRoutes((entity) => {
757
- const known = remoteFacades.get(entity);
758
- if (known) return known;
759
- const facade = createRemoteFacade(entity, remoteRouter, getMiddlewares);
760
- remoteFacades.set(entity, facade);
761
-
762
- return facade;
763
- }));
764
- }
700
+ if (remoteRouter) answerRemotes(assembly, remoteRouter, fronds);
765
701
 
766
702
  const { resolve, schemaFor, facadeFor, operationsFor, presenterFor } = readings({
767
703
  container, fronds, remoteRouter, localDispatcher, routeRegistry, effectiveByKey, log,
@@ -770,12 +706,8 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
770
706
  const app: App = {
771
707
  container,
772
708
  fronds,
773
- // What this app publishes, straight from fougere.config.ts — the facades read it,
774
- // so an undeclared adapter serves nothing whatever a host mounted.
775
709
  adapters: options.adapters ?? {},
776
- // Where a call goes, as DECLARED. Kept because a reader needs it beside what the
777
- // runtime OBSERVED — `rpc.topology` calls a frond remote because it answered, never
778
- // because a key said so, and the two disagree exactly when something is misconfigured.
710
+ // As DECLARED, beside what the runtime observes — the two disagree exactly when something is misconfigured.
779
711
  remotes: Object.freeze({ ...options.remotes }),
780
712
  dispatch: (call) => dispatcher.dispatch(call),
781
713
  local: localDispatcher,
@@ -788,87 +720,30 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
788
720
  storageFor,
789
721
  presenterFor,
790
722
  dispose: release,
791
- drain: (timeoutMs?: number) => drainCalls(inflight, timeoutMs),
723
+ drain: (timeoutMs?: number) => inflight.drain(timeoutMs),
792
724
  inFlight: () => inflight.count,
793
725
  [Symbol.asyncDispose]: release,
794
- serveRpc(op: string, answer: RpcAnswer): void {
795
- // Refused rather than replaced: two declarations of one name would make the answer
796
- // depend on wiring order, and `discover` is in here precisely so it cannot be taken.
797
- const address = new RouteAddress({ entity: 'rpc', operation: op });
798
- if (routeRegistry.find(address)) {
799
- throw new Error(
800
- `[claim] rpc operation '${op}' is already served; a second declaration would depend on wiring order.\n`
801
- + ' Two extensions declare it — keep one out of `extensions:`.',
802
- );
803
- }
804
- routeRegistry.register(new OperationRoute(
805
- 'system',
806
- address,
807
- (call) => answer(call.invocation, call.address.surface),
808
- ));
809
- },
726
+ serveRpc: serveRpcOn(routeRegistry),
810
727
  extensions: () => appLifecycle.names(),
811
- observe(observer) {
812
- return dispatchLifecycle.add(observer);
813
- },
728
+ observe: (observer) => dispatchLifecycle.add(observer),
814
729
  use(...args: [AppMiddleware] | [string, AppMiddleware]): void {
815
730
  return typeof args[0] === 'string'
816
731
  ? use(args[1] as AppMiddleware, args[0])
817
732
  : use(args[0] as AppMiddleware);
818
733
  },
819
- auth: authRuntime,
820
734
  };
821
735
 
822
- // The card is an rpc op like any other, so one registry answers and one refusal names
823
- // what is served. A package's op is declared the same way, from outside.
824
- // What a release is, resolved once and registered: a package that drives one asks for it
825
- // by type, the way every other dependency is asked for.
826
736
  container.registerValue('Releasing', releasing(hosting));
827
-
828
737
  serveCoreRpc(app, hosting, storageFor);
829
738
 
830
- // The last thing the boot does, and the first thing a release undoes. An extension may
831
- // await here — which is what a provider needing to OPEN something could never do.
832
739
  built = app;
833
-
834
- // What the FRONDS declared, folded in after what the host handed over — a frond's
835
- // extension is written beside the code it instruments and travels with it, so it mounts
836
- // on whichever process ends up serving that frond. Added here rather than at the top
837
- // because the fronds are read below that line, and `up` has not run yet.
838
- appLifecycle.add(...fronds.flatMap((frond) => (frond.extensions ?? []).map((one) => ({
839
- name: one.name,
840
- ...one.extension,
841
- }))));
842
-
843
740
  await appLifecycle.up(app);
844
-
845
- // The boot's own lines, and every line after them. Held until here because a boot
846
- // writes most of what a process logs and writes it before any facade exists — so the
847
- // lines that say what this app is made of are the ones a destination would miss.
848
- // `LogLine` is core's for this reason: naming it costs no optional package.
849
- //
850
- // AFTER the ascent, because handing them over RESOLVES the destination, and what a
851
- // destination an extension brought depends on is registered by that same extension's
852
- // `up`: `calls()` registers `LogRing` and `ErrorRing` there, and `KeepHandler` asks for
853
- // both. Resolved before the ascent, every held line died on `'LogRing' is not
854
- // registered` — measured on `demos/observability`.
855
- if (emissions.listensTo().includes(LOG_LINE)) {
856
- const emit = container.resolve<Emit<LogLine>>(emitKeyOf(LogLine.name));
857
- // `at` is the record's own epoch, and the entity says `created()` — so the line
858
- // keeps WHEN IT WAS WRITTEN rather than when it was handed over, which for a held
859
- // boot line is a different moment.
860
- stopAnnouncing = carry.to(({ at, ...line }: LogRecord) => void emit({ ...line, at: new Date(at) }));
861
- } else {
862
- // No destination in this app: the console had them, and holding more would grow.
863
- carry.forget();
864
- }
741
+ stopAnnouncing = announceLines(emissions, container, carry);
865
742
 
866
743
  return app;
867
744
  } catch (cause) {
868
- // The caller cannot release what a failed boot took: it handed `onDispose` over before
869
- // this call and never receives the app that would carry it back. Whoever opened a
870
- // connection to give us would otherwise leak it on every refusal — a source, a scan
871
- // that threw, a port bound twice, and not only an extension that refused to rise.
745
+ // The caller never receives the app that would carry `onDispose` back, so a refused boot
746
+ // releases what it took itself — a source, a scan that threw, a port bound twice.
872
747
  try {
873
748
  await release();
874
749
  } catch (refused) {
@@ -879,4 +754,8 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
879
754
  }
880
755
  throw cause;
881
756
  }
882
- }
757
+ }
758
+
759
+ function contractsOf(operations: EffectiveOperationsMap): OperationsMap {
760
+ return new Map([...operations].map(([name, operation]) => [name, operation as OperationContract] as const));
761
+ }