@fougere/core 0.8.3-alpha.0 → 0.9.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 (279) hide show
  1. package/dist/boot/AppLifecycle.d.ts +8 -0
  2. package/dist/boot/AppLifecycle.d.ts.map +1 -1
  3. package/dist/boot/AppLifecycle.js.map +1 -1
  4. package/dist/boot/Emissions.d.ts +26 -0
  5. package/dist/boot/Emissions.d.ts.map +1 -1
  6. package/dist/boot/Emissions.js +144 -9
  7. package/dist/boot/Emissions.js.map +1 -1
  8. package/dist/boot/auth.d.ts +1 -1
  9. package/dist/boot/auth.d.ts.map +1 -1
  10. package/dist/boot/bootstrap.d.ts.map +1 -1
  11. package/dist/boot/bootstrap.js +401 -598
  12. package/dist/boot/bootstrap.js.map +1 -1
  13. package/dist/boot/card.js +1 -1
  14. package/dist/boot/card.js.map +1 -1
  15. package/dist/boot/hosted.d.ts +1 -1
  16. package/dist/boot/hosted.d.ts.map +1 -1
  17. package/dist/boot/hosted.js +1 -0
  18. package/dist/boot/hosted.js.map +1 -1
  19. package/dist/boot/install.d.ts +42 -0
  20. package/dist/boot/install.d.ts.map +1 -0
  21. package/dist/boot/install.js +327 -0
  22. package/dist/boot/install.js.map +1 -0
  23. package/dist/boot/ownership.d.ts +1 -1
  24. package/dist/boot/ownership.d.ts.map +1 -1
  25. package/dist/boot/ownership.js +14 -1
  26. package/dist/boot/ownership.js.map +1 -1
  27. package/dist/boot/ports.d.ts +12 -3
  28. package/dist/boot/ports.d.ts.map +1 -1
  29. package/dist/boot/ports.js +51 -21
  30. package/dist/boot/ports.js.map +1 -1
  31. package/dist/boot/remote.d.ts.map +1 -1
  32. package/dist/boot/remote.js +3 -3
  33. package/dist/boot/remote.js.map +1 -1
  34. package/dist/boot/seed.d.ts +7 -1
  35. package/dist/boot/seed.d.ts.map +1 -1
  36. package/dist/boot/seed.js +7 -2
  37. package/dist/boot/seed.js.map +1 -1
  38. package/dist/boot/statement-drift.d.ts +1 -1
  39. package/dist/boot/statement-drift.d.ts.map +1 -1
  40. package/dist/boot/together.d.ts +1 -1
  41. package/dist/boot/together.d.ts.map +1 -1
  42. package/dist/boot/together.js +1 -1
  43. package/dist/boot/together.js.map +1 -1
  44. package/dist/boot/types.d.ts +18 -3
  45. package/dist/boot/types.d.ts.map +1 -1
  46. package/dist/builtin/LogLine.d.ts +38 -0
  47. package/dist/builtin/LogLine.d.ts.map +1 -0
  48. package/dist/builtin/LogLine.js +37 -0
  49. package/dist/builtin/LogLine.js.map +1 -0
  50. package/dist/builtin/logger.d.ts +57 -0
  51. package/dist/builtin/logger.d.ts.map +1 -1
  52. package/dist/builtin/logger.js +72 -17
  53. package/dist/builtin/logger.js.map +1 -1
  54. package/dist/config-loader.d.ts +3 -2
  55. package/dist/config-loader.d.ts.map +1 -1
  56. package/dist/config-loader.js.map +1 -1
  57. package/dist/contract.d.ts +3 -7
  58. package/dist/contract.d.ts.map +1 -1
  59. package/dist/contract.js +3 -4
  60. package/dist/contract.js.map +1 -1
  61. package/dist/{scan/conventions.d.ts → conventions.d.ts} +6 -0
  62. package/dist/conventions.d.ts.map +1 -0
  63. package/dist/{scan/conventions.js → conventions.js} +3 -2
  64. package/dist/conventions.js.map +1 -0
  65. package/dist/crypto/encoding.d.ts +0 -1
  66. package/dist/crypto/encoding.d.ts.map +1 -1
  67. package/dist/crypto/encoding.js +0 -7
  68. package/dist/crypto/encoding.js.map +1 -1
  69. package/dist/declare.d.ts +11 -1
  70. package/dist/declare.d.ts.map +1 -1
  71. package/dist/declare.js +10 -1
  72. package/dist/declare.js.map +1 -1
  73. package/dist/descriptor/frond.d.ts +34 -0
  74. package/dist/descriptor/frond.d.ts.map +1 -1
  75. package/dist/descriptor/index.d.ts +15 -0
  76. package/dist/descriptor/index.d.ts.map +1 -0
  77. package/dist/descriptor/index.js +14 -0
  78. package/dist/descriptor/index.js.map +1 -0
  79. package/dist/descriptor/surface.d.ts +8 -0
  80. package/dist/descriptor/surface.d.ts.map +1 -0
  81. package/dist/descriptor/surface.js +16 -0
  82. package/dist/descriptor/surface.js.map +1 -0
  83. package/dist/dispatch/ArgumentResolver.d.ts.map +1 -1
  84. package/dist/dispatch/ArgumentResolver.js +6 -2
  85. package/dist/dispatch/ArgumentResolver.js.map +1 -1
  86. package/dist/dispatch/HandlerFacade.d.ts +2 -0
  87. package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
  88. package/dist/dispatch/HandlerFacade.js +9 -4
  89. package/dist/dispatch/HandlerFacade.js.map +1 -1
  90. package/dist/dispatch/StorageGuard.d.ts.map +1 -1
  91. package/dist/dispatch/StorageGuard.js +9 -23
  92. package/dist/dispatch/StorageGuard.js.map +1 -1
  93. package/dist/dispatch/presenterArguments.d.ts +7 -1
  94. package/dist/dispatch/presenterArguments.d.ts.map +1 -1
  95. package/dist/dispatch/presenterArguments.js +15 -4
  96. package/dist/dispatch/presenterArguments.js.map +1 -1
  97. package/dist/dispatch/validateInput.d.ts.map +1 -1
  98. package/dist/dispatch/validateInput.js +2 -2
  99. package/dist/dispatch/validateInput.js.map +1 -1
  100. package/dist/effective-operation.d.ts +1 -1
  101. package/dist/effective-operation.d.ts.map +1 -1
  102. package/dist/effective-operation.js +13 -19
  103. package/dist/effective-operation.js.map +1 -1
  104. package/dist/frond-config.d.ts +24 -0
  105. package/dist/frond-config.d.ts.map +1 -1
  106. package/dist/frond-config.js.map +1 -1
  107. package/dist/index.d.ts +35 -40
  108. package/dist/index.d.ts.map +1 -1
  109. package/dist/index.js +17 -14
  110. package/dist/index.js.map +1 -1
  111. package/dist/node.d.ts +2 -12
  112. package/dist/node.d.ts.map +1 -1
  113. package/dist/node.js +2 -12
  114. package/dist/node.js.map +1 -1
  115. package/dist/prefab/crud.d.ts +1 -1
  116. package/dist/prefab/crud.d.ts.map +1 -1
  117. package/dist/prefab/mirror.d.ts +10 -11
  118. package/dist/prefab/mirror.d.ts.map +1 -1
  119. package/dist/prefab/mirror.js +1 -27
  120. package/dist/prefab/mirror.js.map +1 -1
  121. package/dist/prefab/repository.d.ts +1 -1
  122. package/dist/prefab/repository.d.ts.map +1 -1
  123. package/dist/{scan/result.d.ts → scan.d.ts} +2 -2
  124. package/dist/scan.d.ts.map +1 -0
  125. package/dist/scan.js +2 -0
  126. package/dist/scan.js.map +1 -0
  127. package/dist/source.d.ts +1 -1
  128. package/dist/source.d.ts.map +1 -1
  129. package/dist/storage/criterion.d.ts.map +1 -0
  130. package/dist/{criterion.js → storage/criterion.js} +2 -2
  131. package/dist/storage/criterion.js.map +1 -0
  132. package/dist/{storage.d.ts → storage/port.d.ts} +6 -3
  133. package/dist/storage/port.d.ts.map +1 -0
  134. package/dist/{storage.js → storage/port.js} +13 -6
  135. package/dist/storage/port.js.map +1 -0
  136. package/dist/{store.d.ts → storage/store.d.ts} +1 -1
  137. package/dist/storage/store.d.ts.map +1 -0
  138. package/dist/{store.js → storage/store.js} +33 -17
  139. package/dist/storage/store.js.map +1 -0
  140. package/dist/verify.js +1 -1
  141. package/dist/verify.js.map +1 -1
  142. package/dist/wire/Invocation.d.ts +1 -3
  143. package/dist/wire/Invocation.d.ts.map +1 -1
  144. package/dist/wire/Invocation.js +1 -5
  145. package/dist/wire/Invocation.js.map +1 -1
  146. package/dist/wire/binding.d.ts +9 -0
  147. package/dist/wire/binding.d.ts.map +1 -1
  148. package/dist/wire/binding.js +11 -6
  149. package/dist/wire/binding.js.map +1 -1
  150. package/dist/wire/emit.d.ts +45 -0
  151. package/dist/wire/emit.d.ts.map +1 -0
  152. package/dist/wire/emit.js +44 -0
  153. package/dist/wire/emit.js.map +1 -0
  154. package/dist/wire/errors.js +2 -2
  155. package/dist/wire/errors.js.map +1 -1
  156. package/dist/wire/loggerMiddleware.d.ts.map +1 -1
  157. package/dist/wire/loggerMiddleware.js +6 -0
  158. package/dist/wire/loggerMiddleware.js.map +1 -1
  159. package/package.json +10 -4
  160. package/src/boot/AppLifecycle.ts +8 -0
  161. package/src/boot/Emissions.ts +177 -9
  162. package/src/boot/auth.ts +1 -1
  163. package/src/boot/bootstrap.ts +456 -672
  164. package/src/boot/card.ts +1 -1
  165. package/src/boot/hosted.ts +4 -2
  166. package/src/boot/install.ts +421 -0
  167. package/src/boot/ownership.ts +17 -2
  168. package/src/boot/ports.ts +71 -27
  169. package/src/boot/remote.ts +3 -3
  170. package/src/boot/seed.ts +18 -3
  171. package/src/boot/statement-drift.ts +1 -1
  172. package/src/boot/together.ts +1 -1
  173. package/src/boot/types.ts +18 -3
  174. package/src/builtin/LogLine.ts +38 -0
  175. package/src/builtin/logger.ts +109 -17
  176. package/src/config-loader.ts +3 -2
  177. package/src/contract.ts +5 -9
  178. package/src/{scan/conventions.ts → conventions.ts} +9 -2
  179. package/src/crypto/encoding.ts +0 -7
  180. package/src/declare.ts +20 -2
  181. package/src/descriptor/frond.ts +31 -0
  182. package/src/descriptor/index.ts +15 -0
  183. package/src/descriptor/surface.ts +23 -0
  184. package/src/dispatch/ArgumentResolver.ts +6 -2
  185. package/src/dispatch/HandlerFacade.ts +11 -4
  186. package/src/dispatch/StorageGuard.ts +8 -21
  187. package/src/dispatch/presenterArguments.ts +21 -9
  188. package/src/dispatch/validateInput.ts +2 -2
  189. package/src/effective-operation.ts +16 -21
  190. package/src/frond-config.ts +20 -0
  191. package/src/index.ts +44 -57
  192. package/src/node.ts +2 -16
  193. package/src/prefab/crud.ts +1 -1
  194. package/src/prefab/mirror.ts +12 -38
  195. package/src/prefab/repository.ts +1 -1
  196. package/src/{scan/result.ts → scan.ts} +1 -1
  197. package/src/source.ts +1 -1
  198. package/src/{criterion.ts → storage/criterion.ts} +2 -2
  199. package/src/{storage.ts → storage/port.ts} +20 -5
  200. package/src/{store.ts → storage/store.ts} +35 -16
  201. package/src/verify.ts +1 -1
  202. package/src/wire/Invocation.ts +2 -7
  203. package/src/wire/binding.ts +17 -6
  204. package/src/wire/call.ts +1 -1
  205. package/src/wire/emit.ts +84 -0
  206. package/src/wire/errors.ts +2 -2
  207. package/src/wire/loggerMiddleware.ts +6 -0
  208. package/dist/boot/boot.d.ts +0 -45
  209. package/dist/boot/boot.d.ts.map +0 -1
  210. package/dist/boot/boot.js +0 -63
  211. package/dist/boot/boot.js.map +0 -1
  212. package/dist/criterion.d.ts.map +0 -1
  213. package/dist/criterion.js.map +0 -1
  214. package/dist/declarations.d.ts +0 -34
  215. package/dist/declarations.d.ts.map +0 -1
  216. package/dist/declarations.js +0 -75
  217. package/dist/declarations.js.map +0 -1
  218. package/dist/emit.d.ts +0 -13
  219. package/dist/emit.d.ts.map +0 -1
  220. package/dist/emit.js +0 -21
  221. package/dist/emit.js.map +0 -1
  222. package/dist/imports.d.ts +0 -16
  223. package/dist/imports.d.ts.map +0 -1
  224. package/dist/imports.js +0 -78
  225. package/dist/imports.js.map +0 -1
  226. package/dist/placement.d.ts +0 -25
  227. package/dist/placement.d.ts.map +0 -1
  228. package/dist/placement.js +0 -69
  229. package/dist/placement.js.map +0 -1
  230. package/dist/scan/adapters.d.ts +0 -3
  231. package/dist/scan/adapters.d.ts.map +0 -1
  232. package/dist/scan/adapters.js +0 -25
  233. package/dist/scan/adapters.js.map +0 -1
  234. package/dist/scan/bundling.d.ts +0 -3
  235. package/dist/scan/bundling.d.ts.map +0 -1
  236. package/dist/scan/bundling.js +0 -15
  237. package/dist/scan/bundling.js.map +0 -1
  238. package/dist/scan/contract.d.ts +0 -27
  239. package/dist/scan/contract.d.ts.map +0 -1
  240. package/dist/scan/contract.js +0 -46
  241. package/dist/scan/contract.js.map +0 -1
  242. package/dist/scan/conventions.d.ts.map +0 -1
  243. package/dist/scan/conventions.js.map +0 -1
  244. package/dist/scan/emit.d.ts +0 -10
  245. package/dist/scan/emit.d.ts.map +0 -1
  246. package/dist/scan/emit.js +0 -144
  247. package/dist/scan/emit.js.map +0 -1
  248. package/dist/scan/handler-parser.d.ts +0 -26
  249. package/dist/scan/handler-parser.d.ts.map +0 -1
  250. package/dist/scan/handler-parser.js +0 -607
  251. package/dist/scan/handler-parser.js.map +0 -1
  252. package/dist/scan/result.d.ts.map +0 -1
  253. package/dist/scan/result.js +0 -2
  254. package/dist/scan/result.js.map +0 -1
  255. package/dist/scan/scanner.d.ts +0 -12
  256. package/dist/scan/scanner.d.ts.map +0 -1
  257. package/dist/scan/scanner.js +0 -573
  258. package/dist/scan/scanner.js.map +0 -1
  259. package/dist/scan/statement.d.ts +0 -4
  260. package/dist/scan/statement.d.ts.map +0 -1
  261. package/dist/scan/statement.js +0 -135
  262. package/dist/scan/statement.js.map +0 -1
  263. package/dist/storage.d.ts.map +0 -1
  264. package/dist/storage.js.map +0 -1
  265. package/dist/store.d.ts.map +0 -1
  266. package/dist/store.js.map +0 -1
  267. package/src/boot/boot.ts +0 -110
  268. package/src/declarations.ts +0 -110
  269. package/src/emit.ts +0 -32
  270. package/src/imports.ts +0 -94
  271. package/src/placement.ts +0 -86
  272. package/src/scan/adapters.ts +0 -32
  273. package/src/scan/bundling.ts +0 -14
  274. package/src/scan/contract.ts +0 -80
  275. package/src/scan/emit.ts +0 -160
  276. package/src/scan/handler-parser.ts +0 -695
  277. package/src/scan/scanner.ts +0 -698
  278. package/src/scan/statement.ts +0 -159
  279. /package/dist/{criterion.d.ts → storage/criterion.d.ts} +0 -0
@@ -3,10 +3,17 @@ import { lowerFirst, type Fields, type SchemaView } from '@fougere/schema';
3
3
  import { nameOf } from '../descriptor/frond.js';
4
4
  import type { EntityEntry, HandlerEntry, PresenterEntry } from '../descriptor/frond.js';
5
5
  import { hostedBy } from './hosted.js';
6
+ import { installFrond, type Assembly } from './install.js';
6
7
  import type { AuthRuntime } from './auth.js';
7
8
  import type { CreateAppOptions, App } from './types.js';
8
9
  import type { AppMiddleware } from '../wire/middleware.js';
9
- import { Logger } from '../builtin/logger.js';
10
+ import { Carry, Logger } from '../builtin/logger.js';
11
+ import type { LogRecord } from '../builtin/logger.js';
12
+ import LogLine, { CARRIES_LINE } from '../builtin/LogLine.js';
13
+ import { emitKeyOf, type Emit } from '../wire/emit.js';
14
+
15
+ /** The fact the boot announces, spelled once. */
16
+ const LOG_LINE = lowerFirst(LogLine.name);
10
17
  import { Config } from '../builtin/config.js';
11
18
  import { createRemoteRouter, createRemoteFacade } from './remote.js';
12
19
  import { registerFrames } from './together.js';
@@ -25,10 +32,11 @@ import { InFlight } from '../dispatch/InFlight.js';
25
32
  // The keys, each read from where its concept is declared — never respelled here.
26
33
  import { facadeKeyOf, contractsKeyOf, type RpcAnswer } from '../wire/call.js';
27
34
  import { identityCardOf } from './card.js';
28
- import { AppLifecycle } from './AppLifecycle.js';
35
+ import { AppLifecycle, migrating } from './AppLifecycle.js';
36
+ import { seeding } from './seed.js';
29
37
  import { inheritsCrud, subjectOf } from '../prefab/crud.js';
30
38
  import { repositoryKeyOf } from '../prefab/repository.js';
31
- import { storageKeyOf } from '../storage.js';
39
+ import { storageKeyOf } from '../storage/port.js';
32
40
  import { declares } from '../source.js';
33
41
  import { presenterKeyOf } from '../prefab/presenter.js';
34
42
  import { collectorKeyOf } from '../prefab/collector.js';
@@ -36,6 +44,7 @@ import { RouteAddress } from '../wire/RouteAddress.js';
36
44
  import { DispatchLifecycle } from '../dispatch/DispatchLifecycle.js';
37
45
  import { Dispatcher } from '../dispatch/Dispatcher.js';
38
46
  import { LocalRoutePolicy } from '../dispatch/LocalRoutePolicy.js';
47
+ import { servedSurfaces } from '../descriptor/surface.js';
39
48
  import { OperationRoute } from '../dispatch/OperationRoute.js';
40
49
  import { remoteRoutes } from '../dispatch/remoteRoutes.js';
41
50
  import { RouteRegistry } from '../dispatch/RouteRegistry.js';
@@ -78,726 +87,503 @@ function assertOneOwnerPerKey(
78
87
  /** Bootstrap a fougere application. */
79
88
  export async function createApp(options: CreateAppOptions): Promise<App> {
80
89
  const container = options.createContainer();
81
- // Boot chatter is debug by default; a host (e.g. the CLI) can quiet it.
82
- const log = new Logger('boot:app');
83
-
84
- // Builtins — registered under class name (PascalCase) for type-based DI.
85
- // No level here and none anywhere: a logger consults `setLogLevel`'s value at each
86
- // emission, so this instance survives a level change and so does every handler that
87
- // was handed it. A frond declaring `class X extends Logger` takes this key over,
88
- // like any other port.
89
- container.registerValue('Logger', new Logger('app'));
90
- container.register('Config', Config, { lifetime: 'singleton' });
91
- log.debug('builtins registered (Logger, Config)');
92
-
93
- // What this app hosts is HANDED IN — stated, scanned, or both (`hostedBy`). Producing
94
- // it may read a disk; consuming it never does, which is the whole reason this file names
95
- // no builtin and a Worker can run what it builds.
96
- const scanStart = performance.now();
97
- const { fronds, diagnostics } = await hostedBy(options);
98
- // An app that states nothing AND scans nothing is a mistake — unless something else it
99
- // declares brings its own entities, which an auth provider does. Refused here and not in
100
- // `hostedBy`, which is handed the frond sources and cannot see the rest of the app. The
101
- // condition is the KEYS, not the count: a scan that found nothing is an ordinary answer.
102
- if (!options.fronds && !options.scan && !options.auth) {
103
- throw new Error(
104
- 'createApp needs `fronds:` (what this app states) or `scan:` (what a scanner found). '
105
- + 'Neither was given, and nothing else declares entities of its own.',
106
- );
107
- }
108
- const operationModel = resolveEffectiveOperations(fronds, {
109
- diagnostics,
110
- remotes: options.remotes,
111
- adapters: options.adapters,
112
- });
113
- const scanMs = (performance.now() - scanStart).toFixed(0);
114
- const blocking = diagnostics.filter((d) => d.severity === 'blocking');
115
- log.info(`read ${fronds.length} frond(s) in ${scanMs}ms`
116
- + (diagnostics.length ? ` — ${diagnostics.length} thing(s) the scan could not do` : ''));
117
-
118
- /** Say what could not be read, at the one line everyone already watches. */
119
- for (const d of blocking) log.error(`[${d.code}] ${d.message}`, d.cause);
120
- for (const d of diagnostics) if (d.severity === 'warning') log.warn(`[${d.code}] ${d.message}`);
121
-
122
- /** An ambiguous convention is not a partial scan. */
123
- const invalidOperations = operationModel.resolutionDiagnostics
124
- .filter((diagnostic) => diagnostic.severity === 'blocking');
125
- if (invalidOperations.length > 0) {
126
- const details = invalidOperations.map((d) =>
127
- ` [${d.code}]${d.subject ? ` ${d.subject}` : ''}\n ${d.message}\n ${d.filePath}`,
128
- );
129
- throw new Error(
130
- `Fougere boot refused: ${invalidOperations.length} unresolved operation contract(s):\n`
131
- + details.join('\n'),
132
- );
133
- }
134
-
135
- // Auth runtime — built once from the lazy AuthConfig produced by a provider factory
136
- // (e.g. betterAuth({...})) in fougere.config.ts. The provider receives our db +
137
- // storageFactory so all auth writes flow through Storage.
138
- let authRuntime: AuthRuntime | undefined;
139
- if (options.auth) {
140
- if (!options.storageFactory) {
141
- throw new Error('createApp: `auth` is set but `storageFactory` is missing — auth providers need it to back their adapter.');
142
- }
143
- if (options.db === undefined) {
144
- throw new Error('createApp: `auth` is set but `db` is missing — pass the storage handle through CreateAppOptions.db.');
145
- }
146
- log.info('initializing auth runtime');
147
- authRuntime = await options.auth.create({
148
- db: options.db,
149
- storageFactory: options.storageFactory,
150
- });
151
- log.info(`auth ready — mounted at ${authRuntime.basePath}`);
152
- }
153
90
 
154
- // Remote routing validated at boot: declaring remotes without a transport is a config error.
155
- // A remote declaration wins over local presence: `remotes: { blog: url }` IS
156
- // the topology statement the frond's code may sit in fronds/**, it runs elsewhere.
157
- const declaredRemotes = Object.entries(options.remotes ?? {});
158
- if (declaredRemotes.length > 0 && !options.remoteTransport) {
159
- throw new Error(
160
- 'createApp: `remotes` is declared but `remoteTransport` is missing — pass one (e.g. from @fougere/transport-http).',
161
- );
162
- }
163
- const remoteRouter = declaredRemotes.length > 0 && options.remoteTransport
164
- ? createRemoteRouter(Object.fromEntries(declaredRemotes), options.remoteTransport)
165
- : undefined;
166
-
167
- // What is running on this app — counted at the one door every caller goes through,
168
- // so releasing it can wait for the work instead of pulling the floor out.
169
- const inflight = new InFlight();
170
-
171
- // Middleware storage — read at call time, not at boot time
172
- const globalMiddlewares: AppMiddleware[] = [];
173
- const scopedMiddlewares = new Map<string, AppMiddleware[]>();
174
- /** What this app took on beyond its fronds. Its `up` is the last thing the boot does. */
175
- const appLifecycle = new AppLifecycle().add(...(options.extensions ?? []));
176
- const routeRegistry = new RouteRegistry();
177
- const dispatchLifecycle = new DispatchLifecycle(
178
- options.dispatchObservers,
179
- (error, event) => log.error(
180
- `[dispatch-observer] ${event.stage} ${event.call.address.toString()}`,
181
- error,
182
- ),
183
- );
184
- const dispatcher = new Dispatcher(routeRegistry, inflight, dispatchLifecycle);
185
- const localDispatcher = new Dispatcher(
186
- routeRegistry,
187
- inflight,
188
- dispatchLifecycle,
189
- new LocalRoutePolicy((surface) => fronds.servedNames(surface)),
91
+ // Held out here, and not where the ascent reads it, because releasing needs it and
92
+ // releasing has to work from the first line the boot takes something.
93
+ // The conventional ascent, ordered here: tables, then rows, then whatever the host took
94
+ // on. Four hosts assembled these two members themselves — the order is not theirs to
95
+ // choose, and a host that forgot lost its migration in silence.
96
+ const appLifecycle = new AppLifecycle().add(
97
+ migrating(options.migrate),
98
+ seeding(),
99
+ ...(options.extensions ?? []),
190
100
  );
101
+ /** The app once it exists — a refusal before that releases the two levels that do. */
102
+ let built: App | undefined;
103
+ /** Where THIS boot's lines wait — never a process-wide slot, see `Carry`. */
104
+ const carry = new Carry();
105
+ /** Given back by `carry.to`, so a released app stops writing into a dead container. */
106
+ let stopAnnouncing: (() => void) | undefined;
191
107
 
192
- function getMiddlewares(entity: string): AppMiddleware[] {
193
- const scoped = scopedMiddlewares.get(entity) ?? [];
194
- return [...globalMiddlewares, ...scoped];
195
- }
196
-
197
- assertOneOwnerPerKey(fronds, options.remotes);
198
-
199
- // Every entity of every frond, by name — so a fact can be validated where it LANDS, and
200
- // so a `reads:` clause can name a neighbour's.
201
- const entityByName = fronds.schemas();
202
- // Which frond holds an entity what turns "a member is remote" into a refusal that
203
- // names the frond rather than the entity, since `remotes:` is declared per frond.
204
- const frondOf = new Map(fronds.flatMap((f) => f.entities.map((e) => [e.name, f.name] as const)));
205
- const emissions = new Emissions(fronds, entityByName, container, log, options.onEmit);
206
- /** Canonical operation tables, indexed by the same audience key as their facades. */
207
- const effectiveByKey = new Map<string, EffectiveOperationsMap>();
208
-
209
- const contractsOf = (operations: EffectiveOperationsMap): OperationsMap => new Map(
210
- [...operations].map(([name, operation]) => [name, operation as OperationContract] as const),
211
- );
212
-
213
- // Every port an implementation was bound to, so a `ports:` entry that named none
214
- // can say so rather than look obeyed.
215
- const boundPorts = new Set<string>();
216
-
217
- // Register frond scopes
218
- for (const frond of fronds) {
219
- // Declared remote: keep the scanned metadata (bridges route with it),
220
- // register nothing locally — resolve() falls through to the remote façade.
221
- if (options.remotes && frond.name in options.remotes) {
222
- log.child(frond.name).info('declared remote — not hosted locally');
223
- // Its doors answer elsewhere, but what they LISTEN to was read here.
224
- for (const handler of frond.handlers) {
225
- const key = facadeKeyOf(handler.address, handler.surface);
226
- const operations = operationModel.forHandler(handler);
227
- effectiveByKey.set(key, operations);
228
- emissions.note(contractsOf(operations), key);
229
-
108
+ /**
109
+ * Everything this app holds, let go in reverse of how it was taken: what an extension took on
110
+ * last, then the container's own, then whoever handed a resource in.
111
+ */
112
+ const release = async (): Promise<void> => {
113
+ // Every level is told to close even when one refuses, and the refusals leave together —
114
+ // the rule `Lifecycle.down` applies INSIDE its list, applied ACROSS the three. Stated
115
+ // there and broken here, a refusing extension took the container and the connection
116
+ // down with it, which is the leak this gesture exists to prevent.
117
+ const refused: unknown[] = [];
118
+ // Whatever is still held will never reach a destination the console had it.
119
+ stopAnnouncing?.();
120
+ carry.forget();
121
+ const levels = [
122
+ ...(built ? [() => appLifecycle.down(built!)] : []),
123
+ () => container.dispose(),
124
+ () => options.onDispose?.(),
125
+ ];
126
+ for (const level of levels) {
127
+ try {
128
+ await level();
129
+ } catch (error) {
130
+ // Flattened one level: an extension's refusals are already an AggregateError, and
131
+ // nesting them would make the caller unwrap twice to read one list.
132
+ if (error instanceof AggregateError) refused.push(...error.errors);
133
+ else refused.push(error);
230
134
  }
231
- continue;
232
- }
233
- const scope = container.createScope();
234
- const frondLog = log.child(frond.name);
235
-
236
- // `reads:` is what makes a cross-source reader exist here, and the list IS its
237
- // environment — a source holding none of these is never opened. Registered under
238
- // the type's own name, which is the key `depKeyOf` already derives for a plain
239
- // parameter: `constructor(private reads: Reads)` and nothing else to say.
240
- // Declaring `reads:` with nothing to build the reader is a boot that ignores a
241
- // clause: the handler asking for `Reads` then dies at its first call, on a
242
- // container message that names neither the clause nor what is missing.
243
- if (frond.reads?.length && !options.sourcesFactory) {
244
- frondLog.warn(
245
- `[reads] ${frond.reads.join(', ')} — declared in frond.config.ts, but this boot passes no `
246
- + '`sourcesFactory`, so no reader is registered and a handler asking for `Reads` will fail '
247
- + 'at its first call. Pass one (`@fougere/adapter-duckdb`), or drop the clause.',
248
- );
249
135
  }
250
- if (frond.reads?.length && options.sourcesFactory) {
251
- // Resolved across the WHOLE app, not this frond's own entities: a cross-source
252
- // query joins entities from different fronds by definition — `Progress` here,
253
- // `Book` next door — so restricting the list to its own would make it useless.
254
- // Naming one IS the authorization; that is what the declaration is for.
255
- const named = frond.reads
256
- .map((name) => entityByName.get(lowerFirst(name)))
257
- .filter((entity): entity is NonNullable<typeof entity> => entity !== undefined);
258
- if (named.length !== frond.reads.length) {
259
- const missing = frond.reads.filter((name) => !entityByName.has(lowerFirst(name)));
260
- frondLog.warn(
261
- `[reads] ${missing.join(', ')} — named in frond.config.ts but scanned nowhere in this app, `
262
- + 'so a query naming one would find no table. Check the spelling, or the entity file.',
263
- );
264
- }
265
- scope.registerValue('Reads', await options.sourcesFactory(named, frond.name));
266
- frondLog.debug(`cross-source reader over ${named.length} entit(ies)`);
136
+ if (refused.length > 0) {
137
+ throw new AggregateError(refused, `${refused.length} refusal(s) while releasing the app`);
267
138
  }
139
+ };
268
140
 
269
- // Who owns what, and the rule that makes owning mean something. Before anything is
270
- // registered, so a bad line is named by this refusal rather than by the container's.
271
- const owners = ownersOf(frond.providers);
272
- refuseStorageInUserCode(frond, owners, (entity) => entityByName.has(entity));
273
- refuseCrudOnOwned(frond, owners);
274
-
275
- for (const provider of frond.providers) {
276
- scope.register(nameOf(provider), provider.ctor, { deps: provider.deps });
277
- }
278
- // …and again under the port each one extends, so `private payment: Payment`
279
- // reaches the realization instead of the base class it is declared against.
280
- // Registered AFTER the loop above so a port key always wins over the base's
281
- // own registration — same precedence as a declared repository over its default.
282
- for (const [port, impl] of portBindings(frond.providers, (n) => scope.has(n), options.ports)) {
283
- scope.register(port, impl.ctor, { deps: impl.deps });
284
- boundPorts.add(port);
285
- frondLog.debug(`port ${port} ${impl.ctor.name}`);
141
+ try {
142
+ // Boot chatter is debug by default; a host (e.g. the CLI) can quiet it.
143
+ const log = new Logger('boot:app', { carry });
144
+
145
+ // Builtins — registered under class name (PascalCase) for type-based DI.
146
+ // No level here and none anywhere: a logger consults `setLogLevel`'s value at each
147
+ // emission, so this instance survives a level change and so does every handler that
148
+ // was handed it. A frond declaring `class X extends Logger` takes this key over,
149
+ // like any other port.
150
+ container.registerValue('Logger', new Logger('app', { carry }));
151
+ container.register('Config', Config, { lifetime: 'singleton' });
152
+ log.debug('builtins registered (Logger, Config)');
153
+
154
+ // What this app hosts is HANDED IN — stated, scanned, or both (`hostedBy`). Producing
155
+ // it may read a disk; consuming it never does, which is the whole reason this file names
156
+ // no builtin and a Worker can run what it builds.
157
+ const scanStart = performance.now();
158
+ // An extension's fronds sit beside the app's own: it is installed like any other, and
159
+ // its handlers resolve at call time — by which point the extension's `up` has put what
160
+ // they ask for in the container.
161
+ const brought = (options.extensions ?? [])
162
+ .flatMap((extension) => extension?.fronds ?? [])
163
+ .map((frond) => ({ ...frond, brought: true as const }));
164
+ const { fronds, diagnostics } = await hostedBy(
165
+ brought.length > 0 ? { ...options, fronds: [...(options.fronds ?? []), ...brought] } : options,
166
+ );
167
+ // An app that states nothing AND scans nothing is a mistake — unless something else it
168
+ // declares brings its own entities, which an auth provider does. Refused here and not in
169
+ // `hostedBy`, which is handed the frond sources and cannot see the rest of the app. The
170
+ // condition is the KEYS, not the count: a scan that found nothing is an ordinary answer.
171
+ if (!options.fronds && !options.scan && !options.auth && brought.length === 0) {
172
+ throw new Error(
173
+ 'createApp needs `fronds:` (what this app states) or `scan:` (what a scanner found). '
174
+ + 'Neither was given, and nothing else declares entities of its own.',
175
+ );
286
176
  }
287
- if (frond.providers.length > 0) {
288
- frondLog.debug(`${frond.providers.length} provider(s): ${frond.providers.map(nameOf).join(', ')}`);
177
+ const operationModel = resolveEffectiveOperations(fronds, {
178
+ diagnostics,
179
+ remotes: options.remotes,
180
+ adapters: options.adapters,
181
+ });
182
+ const scanMs = (performance.now() - scanStart).toFixed(0);
183
+ const blocking = diagnostics.filter((d) => d.severity === 'blocking');
184
+ log.info(`read ${fronds.length} frond(s) in ${scanMs}ms`
185
+ + (diagnostics.length ? ` — ${diagnostics.length} thing(s) the scan could not do` : ''));
186
+
187
+ /** Say what could not be read, at the one line everyone already watches. */
188
+ for (const d of blocking) log.error(`[${d.code}] ${d.message}`, d.cause);
189
+ for (const d of diagnostics) if (d.severity === 'warning') log.warn(`[${d.code}] ${d.message}`);
190
+
191
+ /** An ambiguous convention is not a partial scan. */
192
+ const invalidOperations = operationModel.resolutionDiagnostics
193
+ .filter((diagnostic) => diagnostic.severity === 'blocking');
194
+ if (invalidOperations.length > 0) {
195
+ const details = invalidOperations.map((d) =>
196
+ ` [${d.code}]${d.subject ? ` ${d.subject}` : ''}\n ${d.message}\n ${d.filePath}`,
197
+ );
198
+ throw new Error(
199
+ `Fougere boot refused: ${invalidOperations.length} unresolved operation contract(s):\n`
200
+ + details.join('\n'),
201
+ );
289
202
  }
290
203
 
291
- // Register Storage for each entity PascalCase type name (e.g. 'PostStorage')
292
- // When a handler declares Crud(Entity, Output), scope the storage via .output(Output)
293
- if (options.storageFactory) {
294
- const unenforced: string[] = [];
295
- for (const entity of frond.entities) {
296
- const key = storageKeyOf(entity.name);
297
- const source = options.sourceOf?.(entity.name) ?? 'db';
298
- if (declares(entity.entityClass, 'unique') && options.enforces?.(source, 'unique') === false) {
299
- unenforced.push(`${entity.name} in '${source}'`);
300
- }
301
- const baseStorage = options.storageFactory(entity.entityClass, entity.name);
302
-
303
- // Check if the default handler (no surface) declares an output override
304
- const defaultHandler = frond.handlers.find((h) => h.address === entity.name && !h.surface);
305
- const outputSchema = defaultHandler?.outputOverride ?? (defaultHandler?.ctor as any)?.__output;
306
- const scoped = outputSchema && outputSchema !== entity.entityClass
307
- ? baseStorage.output(outputSchema)
308
- : baseStorage;
309
-
310
- // Storage is a way out like the client surface — see `StorageGuard`.
311
- const guarded = new StorageGuard(entity.entityClass.getFields(), entity.name).guard(scoped);
312
- scope.registerValue(key, guarded);
313
-
314
- // The default repository IS the guarded port — it already answers every gesture a
315
- // declared one forwards, so the two forms have the same shape and a handler reads
316
- // `repo.list()` either way. The wrapper that used to sit here (`{ storage: guarded }`)
317
- // existed to make `repo.storage` true in both, back when `.storage` was the way in.
318
- //
319
- // Not registered for an OWNED entity: an aggregate's members are reached through it
320
- // and nowhere else, and the default would be a second door under a name a handler
321
- // can spell. Every member is skipped, not just the one the key is named after —
322
- // that asymmetry was the whole hole.
323
- const repoKey = repositoryKeyOf(entity.name);
324
- const owner = owners.get(entity.name);
325
- if (owner) {
326
- frondLog.debug(`${entity.name} — owned by ${owner}, no default repository`);
327
- } else if (!scope.has(repoKey)) {
328
- scope.registerValue(repoKey, guarded);
329
- }
204
+ // Auth runtime built once from the lazy AuthConfig produced by a provider factory
205
+ // (e.g. betterAuth({...})) in fougere.config.ts. The provider receives our db +
206
+ // storageFactory so all auth writes flow through Storage.
207
+ let authRuntime: AuthRuntime | undefined;
208
+ if (options.auth) {
209
+ if (!options.storageFactory) {
210
+ throw new Error('createApp: `auth` is set but `storageFactory` is missing — auth providers need it to back their adapter.');
330
211
  }
331
- if (frond.entities.length > 0) {
332
- frondLog.debug(`${frond.entities.length} entity storage(s): ${frond.entities.map((e) => e.name).join(', ')}`);
212
+ if (options.db === undefined) {
213
+ throw new Error('createApp: `auth` is set but `db` is missing — pass the storage handle through CreateAppOptions.db.');
333
214
  }
334
- // The judge refuses a duplicate it can SEE — the row already stored. Two writes arriving
335
- // together see the same absence, and only the place they land can refuse the second.
336
- if (unenforced.length > 0) {
337
- frondLog.warn(
338
- `unique declared, and the source does not enforce it: ${unenforced.join(', ')} — `
339
- + 'two concurrent writes can both pass',
340
- );
341
- }
342
- }
343
-
344
- // Frames, after the ORMs and before anything that may ask for one. A frame is read
345
- // from the same `deps` every other port is read from — asking for it IS declaring it,
346
- // so nothing is registered for a frame nobody wants.
347
- registerFrames(
348
- scope,
349
- [...frond.handlers, ...frond.providers, ...frond.presenters, ...frond.collectors].flatMap((d) => d.deps),
350
- frond.providers,
351
- {
352
- entityByName,
353
- frondOf,
354
- hostedHere: (name) => !(options.remotes && name in options.remotes),
215
+ log.info('initializing auth runtime');
216
+ authRuntime = await options.auth.create({
217
+ db: options.db,
355
218
  storageFactory: options.storageFactory,
356
- sourceOf: options.sourceOf,
357
- transacts: options.transacts,
358
- transacted: options.transacted,
359
- log: frondLog,
360
- },
361
- );
362
-
363
- // Register presenters in scope — PascalCase type name (e.g. 'PostPresenter')
364
- const presenterMap = new Map(frond.presenters.map((p) => [p.entityName, p]));
365
- for (const presenter of frond.presenters) {
366
- scope.register(presenterKeyOf(presenter.entityName), presenter.ctor, { deps: presenter.deps });
367
- }
368
- if (frond.presenters.length > 0) {
369
- frondLog.debug(`${frond.presenters.length} presenter(s): ${frond.presenters.map((p) => p.entityName).join(', ')}`);
219
+ });
220
+ log.info(`auth ready — mounted at ${authRuntime.basePath}`);
370
221
  }
371
222
 
372
- // Register collectors in scope PascalCase type name (e.g. 'UserCollector')
373
- const collectorTypeNames = new Set(frond.collectors.map((c) => c.typeName));
374
- for (const collector of frond.collectors) {
375
- const key = collectorKeyOf(collector.typeName);
376
- scope.register(key, collector.ctor, { deps: collector.deps });
223
+ // Remote routing validated at boot: declaring remotes without a transport is a config error.
224
+ // A remote declaration wins over local presence: `remotes: { blog: url }` IS
225
+ // the topology statement — the frond's code may sit in fronds/**, it runs elsewhere.
226
+ const declaredRemotes = Object.entries(options.remotes ?? {});
227
+ if (declaredRemotes.length > 0 && !options.remoteTransport) {
228
+ throw new Error(
229
+ 'createApp: `remotes` is declared but `remoteTransport` is missing — pass one (e.g. from @fougere/transport-http).',
230
+ );
377
231
  }
378
- if (frond.collectors.length > 0) {
379
- frondLog.debug(`${frond.collectors.length} collector(s): ${frond.collectors.map((c) => c.typeName).join(', ')}`);
232
+ const remoteRouter = declaredRemotes.length > 0 && options.remoteTransport
233
+ ? createRemoteRouter(Object.fromEntries(declaredRemotes), options.remoteTransport)
234
+ : undefined;
235
+
236
+ // What is running on this app — counted at the one door every caller goes through,
237
+ // so releasing it can wait for the work instead of pulling the floor out.
238
+ const inflight = new InFlight();
239
+
240
+ // Middleware storage — read at call time, not at boot time
241
+ const globalMiddlewares: AppMiddleware[] = [];
242
+ const scopedMiddlewares = new Map<string, AppMiddleware[]>();
243
+ /** What this app took on beyond its fronds. Its `up` is the last thing the boot does. */
244
+ const routeRegistry = new RouteRegistry();
245
+ const dispatchLifecycle = new DispatchLifecycle(
246
+ options.dispatchObservers,
247
+ (error, event) => log.error(
248
+ `[dispatch-observer] ${event.stage} ${event.call.address.toString()}`,
249
+ error,
250
+ ),
251
+ );
252
+ const dispatcher = new Dispatcher(routeRegistry, inflight, dispatchLifecycle);
253
+ const localDispatcher = new Dispatcher(
254
+ routeRegistry,
255
+ inflight,
256
+ dispatchLifecycle,
257
+ new LocalRoutePolicy((surface) => fronds.servedNames(surface)),
258
+ );
259
+
260
+ function getMiddlewares(entity: string): AppMiddleware[] {
261
+ const scoped = scopedMiddlewares.get(entity) ?? [];
262
+ return [...globalMiddlewares, ...scoped];
380
263
  }
381
264
 
382
- // Build handler facades → registered in ROOT container (public contract)
383
- const defaultHandlers = frond.handlers.filter((h) => !h.surface);
384
- const surfaceHandlers = frond.handlers.filter((h) => h.surface);
385
- const defaultHandlerMap = new Map(defaultHandlers.map((h) => [h.address, h]));
386
-
387
- /** Build the door of a handler and register it under the audience it serves. */
388
- const buildFacade = (
389
- entity: EntityEntry | undefined,
390
- handler: HandlerEntry,
391
- targetScope: Container,
392
- facadeKey: string,
393
- ) => {
394
- if (inheritsCrud(handler.ctor) && !entity) {
395
- // An installed Crud subject may be absent from the local scan.
396
- frondLog.debug(`${handler.ctor.name} extends Crud() and no scanned entity is named `
397
- + `'${subjectOf(handler.ctor, handler.address)}' — installed entity, or a missing `
398
- + `one: no storage will be injected`);
265
+ /**
266
+ * The one place a middleware is taken on. `App.use` is its late form, and a frond's
267
+ * `middlewares/` its early one — the app does not exist yet while fronds install.
268
+ */
269
+ function use(middleware: AppMiddleware, entity?: string): void {
270
+ if (entity === undefined) {
271
+ globalMiddlewares.push(middleware);
272
+ return;
399
273
  }
274
+ const scoped = scopedMiddlewares.get(entity) ?? [];
275
+ scoped.push(middleware);
276
+ scopedMiddlewares.set(entity, scoped);
277
+ }
400
278
 
401
- const facade = new HandlerFacade(handler, targetScope, {
402
- key: facadeKey,
403
- frond: frond.name,
404
- handlers: frond.handlers,
405
- operations: operationModel.forHandler(handler),
406
- collectors: collectorTypeNames,
407
- presenter: presenterMap.get(handler.address),
408
- presenterScope: scope,
409
- middlewares: () => getMiddlewares(handler.address),
410
- });
411
- // Emissions use the same contracts and execution path as direct calls.
412
- emissions.note(facade.contracts, facadeKey);
413
- // The terms alongside the door, under the same audience a surface that serves
414
- // fewer ops describes fewer ops.
415
- container.registerValue(contractsKeyOf(handler.address, handler.surface), facade.contracts);
416
- effectiveByKey.set(facadeKey, facade.effectiveOperations);
417
-
418
- const surfaces = new Set<string | undefined>([handler.surface]);
419
- if (!handler.surface) {
420
- for (const [surface, names] of Object.entries(frond.surfaces ?? {})) {
421
- const isDeclared = names.some((name) =>
422
- name.toLowerCase() === handler.address.toLowerCase());
423
- const hasOwnDoor = surfaceHandlers.some((candidate) =>
424
- candidate.surface === surface && candidate.address === handler.address);
425
- if (isDeclared && !hasOwnDoor) surfaces.add(surface);
426
- }
427
- }
279
+ assertOneOwnerPerKey(fronds, options.remotes);
280
+
281
+ // Every entity of every frond, by name — so a fact can be validated where it LANDS, and
282
+ // so a `reads:` clause can name a neighbour's.
283
+ const entityByName = fronds.schemas();
284
+ // The line is core's, so its SHAPE is too: a destination that declares only a handler
285
+ // would otherwise be handed a line with no `at` — the announcement stamps `created()`
286
+ // off the shape, and the strict judge refuses what it did not stamp. Measured on
287
+ // `demos/observability`, where the ring held 11 calls and 0 lines.
288
+ if (!entityByName.has(LOG_LINE)) entityByName.set(LOG_LINE, LogLine);
289
+ // Which frond holds an entity what turns "a member is remote" into a refusal that
290
+ // names the frond rather than the entity, since `remotes:` is declared per frond.
291
+ const frondOf = new Map(fronds.flatMap((f) => f.entities.map((e) => [e.name, f.name] as const)));
292
+ // Its own writer, which does NOT announce: this is what carries a fact, and a line
293
+ // about carrying one would come back here. See `LoggerOptions.carries`.
294
+ const emissions = new Emissions(
295
+ fronds, entityByName, container,
296
+ // No carry: this is what CARRIES a fact, and a line about carrying one comes back.
297
+ new Logger('boot:app'),
298
+ options.onEmit,
299
+ );
300
+ /** Canonical operation tables, indexed by the same audience key as their facades. */
301
+ const effectiveByKey = new Map<string, EffectiveOperationsMap>();
428
302
 
429
- for (const operation of facade.contracts.keys()) {
430
- for (const surface of surfaces) {
431
- const address = new RouteAddress({
432
- entity: handler.address,
433
- operation,
434
- ...(surface !== undefined ? { surface } : {}),
435
- });
436
- routeRegistry.register(new OperationRoute(
437
- 'local',
438
- address,
439
- (call) => facade.execute(operation, call.invocation),
440
- ));
441
- }
442
- }
303
+ const contractsOf = (operations: EffectiveOperationsMap): OperationsMap => new Map(
304
+ [...operations].map(([name, operation]) => [name, operation as OperationContract] as const),
305
+ );
443
306
 
444
- const operations = facadeOperations(
445
- handler.surface ? localDispatcher : dispatcher,
446
- handler.address,
447
- routeRegistry.operationNames(handler.address, handler.surface),
448
- handler.surface,
449
- );
450
- container.registerValue(facadeKey, operations);
307
+ // Every port an implementation was bound to, so a `ports:` entry that named none
308
+ // can say so rather than look obeyed.
309
+ const boundPorts = new Set<string>();
310
+
311
+ // Register frond scopes
312
+ // What every frond is installed into, and reads while it is: one container, one route
313
+ // table, one emission list — so what a frond serves is there for the next one to find.
314
+ const assembly: Assembly = {
315
+ container, routeRegistry, emissions, dispatcher, localDispatcher, effectiveByKey,
316
+ boundPorts, operationModel, entityByName, frondOf, contractsOf, getMiddlewares, use,
317
+ log, options,
451
318
  };
452
-
453
- // A presenter is about an entity — computed fields sit on a shape — so this walks
454
- // entities. Exposing the instance lazily; the bridge resolves it on first access.
455
- for (const entity of frond.entities) {
456
- if (!presenterMap.has(entity.name)) continue;
457
- const presenterKey = presenterKeyOf(entity.name);
458
- let presenterInstance: any;
459
- container.registerValue(presenterKey, new Proxy({} as any, {
460
- get(_target, prop) {
461
- if (!presenterInstance) presenterInstance = scope.resolve(presenterKey);
462
- return presenterInstance[prop];
463
- },
464
- }));
319
+ for (const frond of fronds) await installFrond(frond, assembly);
320
+
321
+ // A `ports:` key that matched no port anywhere reads as a choice that was made, and
322
+ // was not. Said once, at the end, because the entry is app-wide while a port is a
323
+ // frond's — no single frond can tell whether a key is a typo or a neighbour's.
324
+ const unused = Object.keys(options.ports ?? {}).filter((port) => !boundPorts.has(port));
325
+ if (unused.length > 0) {
326
+ log.warn(
327
+ `[ports] ${unused.join(', ')} — named in fougere.config.ts, but no scanned class extends `
328
+ + 'them, so nothing was chosen. Check the spelling, or drop the entry.',
329
+ );
465
330
  }
466
331
 
467
- // A facade is about a handler, so this walks HANDLERS. It walked entities before,
468
- // which made an entity a precondition for being callable at all: a handler naming
469
- // none was scanned, then never built, and nothing said so.
470
- for (const handler of defaultHandlers) {
471
- // Two ways to know the subject, and the explicit one wins: `Crud(Item)` names the
472
- // entity it was built on, whatever the handler is called. Otherwise the handler's
473
- // own name is the only thing pointing at one and pointing at nothing is legal.
474
- //
475
- // By NAME, not by identity: the scanner loads an entity through its own loader and
476
- // the handler imports it through the runtime's, so the same class arrives as two
477
- // objects. `===` compares module instances, which is not the question being asked.
478
- const crudTarget = targetOf(handler.ctor);
479
- const subject = crudTarget?.name ? lowerFirst(crudTarget.name) : handler.address;
480
- const entity = frond.entities.find((e) => e.name === subject);
481
- const facadeKey = facadeKeyOf(handler.address);
482
- buildFacade(entity, handler, scope, facadeKey);
483
- frondLog.debug(`${facadeKey} [${Object.keys(container.resolve(facadeKey) as any).join(', ')}]`
484
- + (entity ? '' : ' — no entity of that name: no storage, no projection, no presenter'));
332
+ // Once every door exists: what is announced here and what is listened to are both known.
333
+ emissions.register();
334
+
335
+ // The boot's own lines, and every line after them. Held until here because a boot
336
+ // writes most of what a process logs and writes it before any door exists — so the
337
+ // lines that say what this app is made of are the ones a destination would miss.
338
+ // `LogLine` is core's for this reason: naming it costs no optional package.
339
+ // Which doors carry a line, read from who SUBSCRIBED — so a third party's destination
340
+ // is left alone by the two middlewares that observe every operation.
341
+ for (const door of emissions.doorsFor(LOG_LINE)) {
342
+ CARRIES_LINE.add(door.replace(/Handler$/, '').replace(/^./, (c) => c.toLowerCase()));
343
+ CARRIES_LINE.add(door);
485
344
  }
486
345
 
487
- // The dual, and it stays: a shape that declares no operation answers nothing. Said
488
- // once per entity rather than deduced from a silence.
489
- for (const entity of frond.entities) {
490
- if (!defaultHandlerMap.has(entity.name)) {
491
- frondLog.debug(`${entity.name} entity only, no handler: exposes nothing`);
492
- }
346
+ if (emissions.listensTo().includes(LOG_LINE)) {
347
+ const emit = container.resolve<Emit<LogLine>>(emitKeyOf(LogLine.name));
348
+ // `at` is the record's own epoch, and the entity says `created()` — so the line
349
+ // keeps WHEN IT WAS WRITTEN rather than when it was handed over, which for a held
350
+ // boot line is a different moment.
351
+ stopAnnouncing = carry.to(({ at, ...line }: LogRecord) => void emit({ ...line, at: new Date(at) }));
352
+ } else {
353
+ // No destination in this app: the console had them, and holding more would grow.
354
+ carry.forget();
493
355
  }
494
356
 
495
- // Surface handlers create sub-scope per surface handler with scoped storage
496
- //
497
- // Pointing at nothing is legal HERE TOO. This loop used to `continue` when no entity
498
- // carried the handler's name, so `handlers/public/SearchHandler.ts` with no `Search`
499
- // entity got no door at all and no line saying why — while the very same handler at
500
- // the default surface is built and logged. One rule, both surfaces.
501
- for (const handler of surfaceHandlers) {
502
- const entity = frond.entities.find((e) => e.name === handler.address);
503
- const surfaceScope = scope.createScope();
504
-
505
- // Register scoped storage if output override differs from entity — under the REPOSITORY
506
- // key, which is what a Crud handler asks for, and under the port's own for a holder
507
- // that legitimately names it. Registering only the latter left a named surface with
508
- // no door at all once the façade stopped spelling the storage.
509
- if (entity && options.storageFactory) {
510
- const baseStorage = options.storageFactory(entity.entityClass, entity.name);
511
- const outputSchema = handler.outputOverride ?? (handler.ctor as any).__output;
512
- const scoped = outputSchema && outputSchema !== entity.entityClass
513
- ? baseStorage.output(outputSchema)
514
- : baseStorage;
515
- // The view is handed over so a filter on a field this door hides is SAID. The
516
- // guard holds no logger — a warning is the boot's to voice, as a seed's report is.
517
- const guarded = new StorageGuard(entity.entityClass.getFields(), entity.name, {
518
- ...(outputSchema && outputSchema !== entity.entityClass
519
- ? { view: (outputSchema as { getFields(): Fields }).getFields() }
520
- : {}),
521
- outOfView: (message) => frondLog.warn(message),
522
- }).guard(scoped);
523
- surfaceScope.registerValue(storageKeyOf(entity.name), guarded);
524
- surfaceScope.registerValue(repositoryKeyOf(entity.name), guarded);
525
- }
526
-
527
- const facadeKey = facadeKeyOf(handler.address, handler.surface);
528
- buildFacade(entity, handler, surfaceScope, facadeKey);
529
- frondLog.debug(`${facadeKey} [${Object.keys(container.resolve(facadeKey) as any).join(', ')}]`
530
- + (entity ? '' : ' — no entity of that name: no storage, no projection, no presenter'));
531
- }
532
-
533
- // A named surface is closed, so what it contains is a fact worth stating.
534
- // Saying it at boot is the difference between a rule and a rule you can
535
- // check: an entity you meant to serve and never wrote a handler for is
536
- // absent HERE, in one line, instead of being discovered missing later.
537
- const surfaceNames = [...new Set(surfaceHandlers.map((h) => h.surface as string))].sort();
538
- for (const surfaceName of surfaceNames) {
539
- const served = surfaceHandlers
540
- .filter((h) => h.surface === surfaceName)
541
- .map((h) => h.address)
542
- .sort();
543
- const absent = frond.entities.map((e) => e.name).filter((n) => !served.includes(n));
544
- frondLog.info(
545
- `surface '${surfaceName}' — ${served.length} entit${served.length === 1 ? 'y' : 'ies'}: ${served.join(', ')}` +
546
- (absent.length > 0 ? ` (not served: ${absent.join(', ')})` : ''),
357
+ /** The last resort, held by the container so every resolution path shares it. */
358
+ container.setFallback?.((name) => {
359
+ if (!remoteRouter) return undefined;
360
+ if (!name.endsWith('Handler') || name.includes(':')) return undefined;
361
+ // Façade-shaped stand-in; routing happens lazily at the first call. Through
362
+ // `lowerFirst` because a DEPENDENCY names the type as written `ProductHandler`,
363
+ // PascalCase while a card declares `product`, so the raw strip asked the router for
364
+ // 'Product' and every by-type dependency on a remote handler answered NOT_FOUND.
365
+ return facadeOperations(
366
+ dispatcher,
367
+ lowerFirst(name.replace(/Handler$/, '')),
547
368
  );
548
- }
369
+ });
549
370
 
550
- container.registerValue(`frond:${frond.name}`, scope);
551
- frondLog.info(`registered — ${frond.entities.length} entities, ${frond.handlers.length} handlers, ${frond.seeds.length} seeds`);
552
- }
553
371
 
554
- // A `ports:` key that matched no port anywhere reads as a choice that was made, and
555
- // was not. Said once, at the end, because the entry is app-wide while a port is a
556
- // frond's — no single frond can tell whether a key is a typo or a neighbour's.
557
- const unused = Object.keys(options.ports ?? {}).filter((port) => !boundPorts.has(port));
558
- if (unused.length > 0) {
559
- log.warn(
560
- `[ports] ${unused.join(', ')} named in fougere.config.ts, but no scanned class extends `
561
- + 'them, so nothing was chosen. Check the spelling, or drop the entry.',
562
- );
563
- }
564
-
565
- // Once every door exists: what is announced here and what is listened to are both known.
566
- emissions.register();
567
-
568
- /** The last resort, held by the container so every resolution path shares it. */
569
- container.setFallback?.((name) => {
570
- if (!remoteRouter) return undefined;
571
- if (!name.endsWith('Handler') || name.includes(':')) return undefined;
572
- // Façade-shaped stand-in; routing happens lazily at the first call. Through
573
- // `lowerFirst` because a DEPENDENCY names the type as written — `ProductHandler`,
574
- // PascalCase — while a card declares `product`, so the raw strip asked the router for
575
- // 'Product' and every by-type dependency on a remote handler answered NOT_FOUND.
576
- return facadeOperations(
577
- dispatcher,
578
- lowerFirst(name.replace(/Handler$/, '')),
579
- );
580
- });
372
+ /** Stop taking calls, and resolve once the ones already running are done. */
373
+ const drain = async (timeoutMs?: number): Promise<void> => {
374
+ inflight.close();
375
+ if (timeoutMs === undefined) return inflight.whenIdle();
376
+ let timer: ReturnType<typeof setTimeout>;
377
+ await Promise.race([
378
+ inflight.whenIdle().then(() => clearTimeout(timer)),
379
+ new Promise<never>((_, reject) => {
380
+ timer = setTimeout(
381
+ () => reject(new Error(`[drain] ${inflight.count} call(s) still running after ${timeoutMs}ms`)),
382
+ timeoutMs,
383
+ );
384
+ }),
385
+ ]);
386
+ };
581
387
 
582
- /**
583
- * Everything this app holds, let go in reverse of how it was taken: what an extension took on
584
- * last, then the container's own, then whoever handed a resource in.
585
- */
586
- const release = async (): Promise<void> => {
587
- // Every level is told to close even when one refuses, and the refusals leave together —
588
- // the rule `Lifecycle.down` applies INSIDE its list, applied ACROSS the three. Stated
589
- // there and broken here, a refusing extension took the container and the connection
590
- // down with it, which is the leak this gesture exists to prevent.
591
- const refused: unknown[] = [];
592
- for (const level of [() => appLifecycle.down(app), () => container.dispose(), () => options.onDispose?.()]) {
388
+ const resolve = <T>(name: string): T => {
593
389
  try {
594
- await level();
595
- } catch (error) {
596
- // Flattened one level: an extension's refusals are already an AggregateError, and
597
- // nesting them would make the caller unwrap twice to read one list.
598
- if (error instanceof AggregateError) refused.push(...error.errors);
599
- else refused.push(error);
390
+ return container.resolve<T>(name);
391
+ } catch (err) {
392
+ if (name.endsWith('Handler') && !name.includes(':') && !remoteRouter) {
393
+ throw new Error(notLoaded(name.replace(/Handler$/, '')));
394
+ }
395
+ throw err;
600
396
  }
601
- }
602
- if (refused.length > 0) {
603
- throw new AggregateError(refused, `${refused.length} refusal(s) while releasing the app`);
604
- }
605
- };
606
-
607
- /** Stop taking calls, and resolve once the ones already running are done. */
608
- const drain = async (timeoutMs?: number): Promise<void> => {
609
- inflight.close();
610
- if (timeoutMs === undefined) return inflight.whenIdle();
611
- let timer: ReturnType<typeof setTimeout>;
612
- await Promise.race([
613
- inflight.whenIdle().then(() => clearTimeout(timer)),
614
- new Promise<never>((_, reject) => {
615
- timer = setTimeout(
616
- () => reject(new Error(`[drain] ${inflight.count} call(s) still running after ${timeoutMs}ms`)),
617
- timeoutMs,
618
- );
619
- }),
620
- ]);
621
- };
397
+ };
622
398
 
623
- const resolve = <T>(name: string): T => {
624
- try {
625
- return container.resolve<T>(name);
626
- } catch (err) {
627
- if (name.endsWith('Handler') && !name.includes(':') && !remoteRouter) {
628
- throw new Error(notLoaded(name.replace(/Handler$/, '')));
399
+ const schemaFor = async (entity: string): Promise<SchemaView> => {
400
+ const found = fronds.entity(entity);
401
+ if (found) return found.entityClass;
402
+ if (remoteRouter) {
403
+ const route = await remoteRouter.route(entity);
404
+ // A remote door that stores nothing publishes ops and no shape. Saying so beats
405
+ // handing back an empty schema, which would validate every input it was given.
406
+ if (!route.schema) {
407
+ throw new Error(
408
+ `'${entity}' is served by frond '${route.frond}' but stores no rows, so it has no schema. `
409
+ + `Call its operations through the façade instead.`,
410
+ );
411
+ }
412
+ return route.schema;
629
413
  }
630
- throw err;
631
- }
632
- };
414
+ throw new Error(notLoaded(entity));
415
+ };
633
416
 
634
- const schemaFor = async (entity: string): Promise<SchemaView> => {
635
- const found = fronds.entity(entity);
636
- if (found) return found.entityClass;
637
- if (remoteRouter) {
638
- const route = await remoteRouter.route(entity);
639
- // A remote door that stores nothing publishes ops and no shape. Saying so beats
640
- // handing back an empty schema, which would validate every input it was given.
641
- if (!route.schema) {
642
- throw new Error(
643
- `'${entity}' is served by frond '${route.frond}' but stores no rows, so it has no schema. `
644
- + `Call its operations through the façade instead.`,
645
- );
417
+ const facadeAt = (key: string, topology: boolean): Record<string, Function> | undefined => {
418
+ try {
419
+ return topology
420
+ ? resolve<Record<string, Function>>(key)
421
+ : container.resolve<Record<string, Function>>(key);
422
+ } catch {
423
+ return undefined;
646
424
  }
647
- return route.schema;
648
- }
649
- throw new Error(notLoaded(entity));
650
- };
651
-
652
- const facadeAt = (key: string, topology: boolean): Record<string, Function> | undefined => {
653
- try {
654
- return topology
655
- ? resolve<Record<string, Function>>(key)
656
- : container.resolve<Record<string, Function>>(key);
657
- } catch {
658
- return undefined;
659
- }
660
- };
661
-
662
- /** THE membership rule, stated once — every projection reads this and nothing else. */
663
- const facadeFor = (entity: string, surface?: string): Record<string, Function> | undefined => {
664
- if (!surface) return facadeAt(facadeKeyOf(entity), true);
665
-
666
- const own = facadeAt(facadeKeyOf(entity, surface), false);
667
- const declared = fronds.owner(entity)?.surfaces?.[surface];
668
- if (!declared) return own;
669
- if (!declared.some((n) => n.toLowerCase() === entity.toLowerCase())) return undefined;
670
- if (own) return own;
671
-
672
- const fallback = facadeAt(facadeKeyOf(entity), false);
673
- return fallback
674
- ? facadeOperations(
675
- localDispatcher,
676
- entity,
677
- routeRegistry.operationNames(entity, surface),
678
- surface,
679
- )
680
- : undefined;
681
- };
425
+ };
682
426
 
683
- if (remoteRouter) {
684
- const remoteFacades = new Map<string, Record<string, Function>>();
685
- routeRegistry.addResolver(remoteRoutes((entity) => {
686
- const known = remoteFacades.get(entity);
687
- if (known) return known;
688
- const facade = createRemoteFacade(entity, remoteRouter, getMiddlewares);
689
- remoteFacades.set(entity, facade);
690
- return facade;
691
- }));
692
- }
427
+ /** Said once per pair, so a door that registers in a loop says it once. */
428
+ const saidAbsent = new Set<string>();
429
+
430
+ /**
431
+ * A surface is declared in the frond that serves it. When that frond runs in another
432
+ * process, this one never asked for its doors, and answering 'no' is the only thing a
433
+ * synchronous rule can do — so it says so rather than registering nothing in silence.
434
+ */
435
+ const sayNoSurfaceAcross = (entity: string, surface: string): void => {
436
+ if (!remoteRouter || saidAbsent.has(`${surface}:${entity}`)) return;
437
+ saidAbsent.add(`${surface}:${entity}`);
438
+ log.warn(
439
+ `surface '${surface}' serves nothing for '${entity}' — the frond that declares it runs `
440
+ + 'elsewhere, and a remote is asked for its doors at the first call, not at boot. '
441
+ + 'The default door answers.',
442
+ );
443
+ };
693
444
 
694
- /** The terms beside a door, with the exact same named-surface fallback rule. */
695
- const operationsFor = (entity: string, surface?: string): EffectiveOperationsMap | undefined => {
696
- if (!surface) return effectiveByKey.get(facadeKeyOf(entity));
445
+ /** THE membership rule, stated once every projection reads this and nothing else. */
446
+ const facadeFor = (entity: string, surface?: string): Record<string, Function> | undefined => {
447
+ if (!surface) return facadeAt(facadeKeyOf(entity), true);
697
448
 
698
- const own = effectiveByKey.get(facadeKeyOf(entity, surface));
699
- const declared = fronds.owner(entity)?.surfaces?.[surface];
700
- if (!declared) return own;
701
- return declared.some((name) => name.toLowerCase() === entity.toLowerCase())
702
- ? (own ?? effectiveByKey.get(facadeKeyOf(entity)))
703
- : undefined;
704
- };
449
+ const own = facadeAt(facadeKeyOf(entity, surface), false);
450
+ const owner = fronds.owner(entity);
451
+ if (!owner) {
452
+ sayNoSurfaceAcross(entity, surface);
453
+ return own;
454
+ }
705
455
 
706
- /**
707
- * The storage an entity is backed by — the dual of `facadeFor`, which serves its client-facing
708
- * door.
709
- */
710
- const storageFor = (entity: string): unknown | undefined => {
711
- const owner = fronds.owner(entity);
712
- if (!owner) return undefined;
456
+ const declared = owner.surfaces?.[surface];
457
+ if (!declared) return own;
458
+ if (!declared.some((n) => n.toLowerCase() === entity.toLowerCase())) return undefined;
459
+ if (own) return own;
460
+
461
+ const fallback = facadeAt(facadeKeyOf(entity), false);
462
+ return fallback
463
+ ? facadeOperations(
464
+ localDispatcher,
465
+ entity,
466
+ routeRegistry.operationNames(entity, surface),
467
+ surface,
468
+ )
469
+ : undefined;
470
+ };
713
471
 
714
- const key = storageKeyOf(entity);
715
- try {
716
- return container.resolve<Container>(`frond:${owner.name}`).resolve(key);
717
- } catch {
718
- return undefined;
472
+ if (remoteRouter) {
473
+ const remoteFacades = new Map<string, Record<string, Function>>();
474
+ routeRegistry.addResolver(remoteRoutes((entity) => {
475
+ const known = remoteFacades.get(entity);
476
+ if (known) return known;
477
+ const facade = createRemoteFacade(entity, remoteRouter, getMiddlewares);
478
+ remoteFacades.set(entity, facade);
479
+ return facade;
480
+ }));
719
481
  }
720
- };
721
482
 
722
- /** The presenter of an entity, resolved through its owning frond's scope. */
723
- const presenterFor = (entity: string): unknown | undefined => {
724
- const owner = fronds.owner(entity);
725
- if (!owner) return undefined;
483
+ /** The terms beside a door, with the exact same named-surface fallback rule. */
484
+ const operationsFor = (entity: string, surface?: string): EffectiveOperationsMap | undefined => {
485
+ if (!surface) return effectiveByKey.get(facadeKeyOf(entity));
726
486
 
727
- try {
728
- return container.resolve<Container>(`frond:${owner.name}`).resolve(presenterKeyOf(entity));
729
- } catch {
730
- return undefined;
731
- }
732
- };
487
+ const own = effectiveByKey.get(facadeKeyOf(entity, surface));
488
+ const declared = fronds.owner(entity)?.surfaces?.[surface];
489
+ if (!declared) return own;
490
+ return declared.some((name) => name.toLowerCase() === entity.toLowerCase())
491
+ ? (own ?? effectiveByKey.get(facadeKeyOf(entity)))
492
+ : undefined;
493
+ };
733
494
 
734
- const app: App = {
735
- container,
736
- fronds,
737
- // What this app publishes, straight from fougere.config.ts — the doors read it,
738
- // so an undeclared adapter serves nothing whatever a host mounted.
739
- adapters: options.adapters ?? {},
740
- // Where a call goes, as DECLARED. Kept because a reader needs it beside what the
741
- // runtime OBSERVED — `rpc.topology` calls a frond remote because it answered, never
742
- // because a key said so, and the two disagree exactly when something is misconfigured.
743
- remotes: Object.freeze({ ...(options.remotes ?? {}) }),
744
- dispatch: (call) => dispatcher.dispatch(call),
745
- local: localDispatcher,
746
- resolve,
747
- schemaFor,
748
- facadeFor,
749
- operationsFor,
750
- listensTo: () => emissions.listensTo(),
751
- deliver: (fact, payload) => emissions.deliver(fact, payload),
752
- storageFor,
753
- presenterFor,
754
- dispose: release,
755
- drain,
756
- inFlight: () => inflight.count,
757
- [Symbol.asyncDispose]: release,
758
- serveRpc(op: string, answer: RpcAnswer): void {
759
- // Refused rather than replaced: two declarations of one name would make the answer
760
- // depend on wiring order, and `discover` is in here precisely so it cannot be taken.
761
- const address = new RouteAddress({ entity: 'rpc', operation: op });
762
- if (routeRegistry.find(address)) {
763
- throw new Error(`rpc operation '${op}' is already served; a second declaration would depend on wiring order`);
495
+ /**
496
+ * The storage an entity is backed by — the dual of `facadeFor`, which serves its client-facing
497
+ * door.
498
+ */
499
+ const storageFor = (entity: string): unknown | undefined => {
500
+ const owner = fronds.owner(entity);
501
+ if (!owner) return undefined;
502
+
503
+ const key = storageKeyOf(entity);
504
+ try {
505
+ return container.resolve<Container>(`frond:${owner.name}`).resolve(key);
506
+ } catch {
507
+ return undefined;
764
508
  }
765
- routeRegistry.register(new OperationRoute(
766
- 'system',
767
- address,
768
- (call) => answer(call.invocation, call.address.surface),
769
- ));
770
- },
771
- extensions: () => appLifecycle.names(),
772
- observe(observer) {
773
- return dispatchLifecycle.add(observer);
774
- },
775
- use(...args: [AppMiddleware] | [string, AppMiddleware]): void {
776
- if (typeof args[0] === 'string') {
777
- const [entity, mw] = args as [string, AppMiddleware];
778
- const list = scopedMiddlewares.get(entity) ?? [];
779
- list.push(mw);
780
- scopedMiddlewares.set(entity, list);
781
- } else {
782
- globalMiddlewares.push(args[0] as AppMiddleware);
509
+ };
510
+
511
+ /** The presenter of an entity, resolved through its owning frond's scope. */
512
+ const presenterFor = (entity: string): unknown | undefined => {
513
+ const owner = fronds.owner(entity);
514
+ if (!owner) return undefined;
515
+
516
+ try {
517
+ return container.resolve<Container>(`frond:${owner.name}`).resolve(presenterKeyOf(entity));
518
+ } catch {
519
+ return undefined;
783
520
  }
784
- },
785
- auth: authRuntime,
786
- };
521
+ };
787
522
 
788
- // The card is an rpc op like any other, so one registry answers and one refusal names
789
- // what is served. A package's op is declared the same way, from outside.
790
- app.serveRpc('discover', (_invocation, surface) => identityCardOf(app, surface));
523
+ const app: App = {
524
+ container,
525
+ fronds,
526
+ // What this app publishes, straight from fougere.config.ts — the doors read it,
527
+ // so an undeclared adapter serves nothing whatever a host mounted.
528
+ adapters: options.adapters ?? {},
529
+ // Where a call goes, as DECLARED. Kept because a reader needs it beside what the
530
+ // runtime OBSERVED — `rpc.topology` calls a frond remote because it answered, never
531
+ // because a key said so, and the two disagree exactly when something is misconfigured.
532
+ remotes: Object.freeze({ ...(options.remotes ?? {}) }),
533
+ dispatch: (call) => dispatcher.dispatch(call),
534
+ local: localDispatcher,
535
+ resolve,
536
+ schemaFor,
537
+ facadeFor,
538
+ operationsFor,
539
+ listensTo: () => emissions.listensTo(),
540
+ deliver: (fact, payload) => emissions.deliver(fact, payload),
541
+ storageFor,
542
+ presenterFor,
543
+ dispose: release,
544
+ drain,
545
+ inFlight: () => inflight.count,
546
+ [Symbol.asyncDispose]: release,
547
+ serveRpc(op: string, answer: RpcAnswer): void {
548
+ // Refused rather than replaced: two declarations of one name would make the answer
549
+ // depend on wiring order, and `discover` is in here precisely so it cannot be taken.
550
+ const address = new RouteAddress({ entity: 'rpc', operation: op });
551
+ if (routeRegistry.find(address)) {
552
+ throw new Error(`rpc operation '${op}' is already served; a second declaration would depend on wiring order`);
553
+ }
554
+ routeRegistry.register(new OperationRoute(
555
+ 'system',
556
+ address,
557
+ (call) => answer(call.invocation, call.address.surface),
558
+ ));
559
+ },
560
+ extensions: () => appLifecycle.names(),
561
+ observe(observer) {
562
+ return dispatchLifecycle.add(observer);
563
+ },
564
+ use(...args: [AppMiddleware] | [string, AppMiddleware]): void {
565
+ return typeof args[0] === 'string'
566
+ ? use(args[1] as AppMiddleware, args[0])
567
+ : use(args[0] as AppMiddleware);
568
+ },
569
+ auth: authRuntime,
570
+ };
791
571
 
792
- // The last thing the boot does, and the first thing a release undoes. An extension may
793
- // await here which is what a provider needing to OPEN something could never do.
794
- //
795
- // A refusal here has to release what the boot took, because the caller cannot: it handed
796
- // `onDispose` over BEFORE this line and never receives the app that would carry it back.
797
- // Whoever opened a connection to give it to us would otherwise leak it on every failed boot.
798
- try {
572
+ // The card is an rpc op like any other, so one registry answers and one refusal names
573
+ // what is served. A package's op is declared the same way, from outside.
574
+ app.serveRpc('discover', (_invocation, surface) => identityCardOf(app, surface));
575
+
576
+ // The last thing the boot does, and the first thing a release undoes. An extension may
577
+ // await here which is what a provider needing to OPEN something could never do.
578
+ built = app;
799
579
  await appLifecycle.up(app);
580
+
581
+ return app;
800
582
  } catch (cause) {
583
+ // The caller cannot release what a failed boot took: it handed `onDispose` over before
584
+ // this call and never receives the app that would carry it back. Whoever opened a
585
+ // connection to give us would otherwise leak it on every refusal — a source, a scan
586
+ // that threw, a port bound twice, and not only an extension that refused to rise.
801
587
  try {
802
588
  await release();
803
589
  } catch (refused) {
@@ -808,6 +594,4 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
808
594
  }
809
595
  throw cause;
810
596
  }
811
-
812
- return app;
813
- }
597
+ }