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