@fougere/core 0.11.0-alpha.0 → 0.12.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 (146) hide show
  1. package/dist/FougereConfig.d.ts +10 -6
  2. package/dist/FougereConfig.d.ts.map +1 -1
  3. package/dist/FougereConfig.js +19 -4
  4. package/dist/FougereConfig.js.map +1 -1
  5. package/dist/FrondConfig.d.ts +0 -6
  6. package/dist/FrondConfig.d.ts.map +1 -1
  7. package/dist/FrondConfig.js.map +1 -1
  8. package/dist/FrondDeclaration.d.ts +2 -9
  9. package/dist/FrondDeclaration.d.ts.map +1 -1
  10. package/dist/FrondDeclaration.js +0 -1
  11. package/dist/FrondDeclaration.js.map +1 -1
  12. package/dist/FrondsStated.d.ts +54 -0
  13. package/dist/FrondsStated.d.ts.map +1 -0
  14. package/dist/FrondsStated.js +26 -0
  15. package/dist/FrondsStated.js.map +1 -0
  16. package/dist/StatedFrond.d.ts +21 -0
  17. package/dist/StatedFrond.d.ts.map +1 -0
  18. package/dist/StatedFrond.js +34 -0
  19. package/dist/StatedFrond.js.map +1 -0
  20. package/dist/StatedModules.d.ts +19 -0
  21. package/dist/StatedModules.d.ts.map +1 -0
  22. package/dist/StatedModules.js +37 -0
  23. package/dist/StatedModules.js.map +1 -0
  24. package/dist/boot/CreateAppOptions.d.ts +19 -2
  25. package/dist/boot/CreateAppOptions.d.ts.map +1 -1
  26. package/dist/boot/Emissions.d.ts +9 -0
  27. package/dist/boot/Emissions.d.ts.map +1 -1
  28. package/dist/boot/Emissions.js +11 -5
  29. package/dist/boot/Emissions.js.map +1 -1
  30. package/dist/boot/bootstrap.d.ts.map +1 -1
  31. package/dist/boot/bootstrap.js +342 -307
  32. package/dist/boot/bootstrap.js.map +1 -1
  33. package/dist/boot/declared.d.ts.map +1 -1
  34. package/dist/boot/declared.js +3 -0
  35. package/dist/boot/declared.js.map +1 -1
  36. package/dist/boot/frame.d.ts +0 -4
  37. package/dist/boot/frame.d.ts.map +1 -1
  38. package/dist/boot/frame.js +31 -14
  39. package/dist/boot/frame.js.map +1 -1
  40. package/dist/boot/hosted.d.ts.map +1 -1
  41. package/dist/boot/hosted.js +4 -3
  42. package/dist/boot/hosted.js.map +1 -1
  43. package/dist/boot/install.d.ts +9 -0
  44. package/dist/boot/install.d.ts.map +1 -1
  45. package/dist/boot/install.js +271 -185
  46. package/dist/boot/install.js.map +1 -1
  47. package/dist/boot/nesting.d.ts +33 -0
  48. package/dist/boot/nesting.d.ts.map +1 -0
  49. package/dist/boot/nesting.js +164 -0
  50. package/dist/boot/nesting.js.map +1 -0
  51. package/dist/boot/ownership.d.ts +0 -1
  52. package/dist/boot/ownership.d.ts.map +1 -1
  53. package/dist/boot/ownership.js +66 -51
  54. package/dist/boot/ownership.js.map +1 -1
  55. package/dist/boot/ports.js +30 -25
  56. package/dist/boot/ports.js.map +1 -1
  57. package/dist/boot/remote.d.ts.map +1 -1
  58. package/dist/boot/remote.js +31 -28
  59. package/dist/boot/remote.js.map +1 -1
  60. package/dist/boot/seed.d.ts.map +1 -1
  61. package/dist/boot/seed.js +27 -20
  62. package/dist/boot/seed.js.map +1 -1
  63. package/dist/boot/together.d.ts.map +1 -1
  64. package/dist/boot/together.js +71 -56
  65. package/dist/boot/together.js.map +1 -1
  66. package/dist/descriptor/FrondDescriptor.d.ts +7 -0
  67. package/dist/descriptor/FrondDescriptor.d.ts.map +1 -1
  68. package/dist/descriptor/MiddlewareEntry.d.ts +6 -5
  69. package/dist/descriptor/MiddlewareEntry.d.ts.map +1 -1
  70. package/dist/descriptor/ProviderEntry.d.ts +7 -0
  71. package/dist/descriptor/ProviderEntry.d.ts.map +1 -1
  72. package/dist/dispatch/HandlerFacade.d.ts +6 -1
  73. package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
  74. package/dist/dispatch/HandlerFacade.js +23 -20
  75. package/dist/dispatch/HandlerFacade.js.map +1 -1
  76. package/dist/dispatch/PresenterExecutor.d.ts +6 -0
  77. package/dist/dispatch/PresenterExecutor.d.ts.map +1 -1
  78. package/dist/dispatch/PresenterExecutor.js +30 -22
  79. package/dist/dispatch/PresenterExecutor.js.map +1 -1
  80. package/dist/dispatch/Release.js +53 -47
  81. package/dist/dispatch/Release.js.map +1 -1
  82. package/dist/dispatch/StorageGuard.d.ts +21 -0
  83. package/dist/dispatch/StorageGuard.d.ts.map +1 -1
  84. package/dist/dispatch/StorageGuard.js +84 -66
  85. package/dist/dispatch/StorageGuard.js.map +1 -1
  86. package/dist/index.d.ts +3 -0
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +3 -0
  89. package/dist/index.js.map +1 -1
  90. package/dist/node.d.ts +2 -1
  91. package/dist/node.d.ts.map +1 -1
  92. package/dist/node.js +2 -1
  93. package/dist/node.js.map +1 -1
  94. package/dist/storage/Storage.js +1 -1
  95. package/dist/storage/Storage.js.map +1 -1
  96. package/dist/storage/Store.d.ts.map +1 -1
  97. package/dist/storage/Store.js +29 -21
  98. package/dist/storage/Store.js.map +1 -1
  99. package/dist/verify.d.ts.map +1 -1
  100. package/dist/verify.js +14 -0
  101. package/dist/verify.js.map +1 -1
  102. package/dist/wire/Invocation.d.ts +1 -1
  103. package/dist/wire/Invocation.d.ts.map +1 -1
  104. package/dist/wire/Invocation.js +1 -1
  105. package/dist/wire/Invocation.js.map +1 -1
  106. package/dist/wire/binding.d.ts.map +1 -1
  107. package/dist/wire/binding.js +29 -57
  108. package/dist/wire/binding.js.map +1 -1
  109. package/dist/wire/drift.d.ts.map +1 -1
  110. package/dist/wire/drift.js +22 -19
  111. package/dist/wire/drift.js.map +1 -1
  112. package/package.json +4 -4
  113. package/src/FougereConfig.ts +24 -10
  114. package/src/FrondConfig.ts +0 -6
  115. package/src/FrondDeclaration.ts +2 -7
  116. package/src/FrondsStated.ts +72 -0
  117. package/src/StatedFrond.ts +51 -0
  118. package/src/StatedModules.ts +58 -0
  119. package/src/boot/CreateAppOptions.ts +19 -2
  120. package/src/boot/Emissions.ts +16 -5
  121. package/src/boot/bootstrap.ts +478 -359
  122. package/src/boot/declared.ts +3 -0
  123. package/src/boot/frame.ts +42 -11
  124. package/src/boot/hosted.ts +4 -3
  125. package/src/boot/install.ts +388 -212
  126. package/src/boot/nesting.ts +196 -0
  127. package/src/boot/ownership.ts +79 -51
  128. package/src/boot/ports.ts +45 -31
  129. package/src/boot/remote.ts +47 -29
  130. package/src/boot/seed.ts +36 -27
  131. package/src/boot/together.ts +98 -63
  132. package/src/descriptor/FrondDescriptor.ts +7 -0
  133. package/src/descriptor/MiddlewareEntry.ts +6 -5
  134. package/src/descriptor/ProviderEntry.ts +7 -0
  135. package/src/dispatch/HandlerFacade.ts +27 -17
  136. package/src/dispatch/PresenterExecutor.ts +33 -22
  137. package/src/dispatch/Release.ts +68 -44
  138. package/src/dispatch/StorageGuard.ts +90 -69
  139. package/src/index.ts +3 -0
  140. package/src/node.ts +2 -1
  141. package/src/storage/Storage.ts +2 -2
  142. package/src/storage/Store.ts +39 -18
  143. package/src/verify.ts +15 -0
  144. package/src/wire/Invocation.ts +2 -1
  145. package/src/wire/binding.ts +26 -58
  146. package/src/wire/drift.ts +48 -21
@@ -1,5 +1,7 @@
1
1
  /** Putting one frond into the app being built: its scope, what it serves, what it takes. */
2
2
  import type { Container } from '@fougere/container';
3
+ import type { ProviderEntry } from '../descriptor/ProviderEntry.js';
4
+ import type { PresenterEntry } from '../descriptor/PresenterEntry.js';
3
5
  import { lowerFirst, type Fields, type SchemaView } from '@fougere/schema';
4
6
  import type { Logger } from '../builtin/Logger.js';
5
7
  import type { Dispatcher } from '../dispatch/Dispatcher.js';
@@ -71,84 +73,220 @@ export interface Assembly {
71
73
  getMiddlewares: (entity: string) => AppMiddleware[];
72
74
  /** Take a middleware on — every entity when no entity is named. */
73
75
  use: (middleware: AppMiddleware, entity?: string) => void;
76
+ /**
77
+ * What runs around each frond, its ancestors' included — filled as fronds install, read by
78
+ * their children. A middleware is not a container key, so inheriting one is a list to carry
79
+ * and not a scope to walk.
80
+ */
81
+ middlewaresOf: Map<string, AppMiddleware[]>;
82
+ /** What wraps each frond's seams, its ancestors' included — read the same way. */
83
+ seamsOf: Map<string, Map<string, ProviderEntry[]>>;
74
84
  log: Logger;
75
85
  options: CreateAppOptions;
76
86
  }
77
87
 
78
- export async function installFrond(frond: FrondDescriptor, assembly: Assembly): Promise<void> {
79
- const {
80
- container, routeRegistry, emissions, dispatcher, localDispatcher, effectiveByKey,
81
- boundPorts, refused, operationModel, entityByName, frondOf, contractsOf, getMiddlewares, use,
82
- log, options,
83
- } = assembly;
88
+ /**
89
+ * A cross-source reader over the entities this frond NAMED — resolved across the whole app,
90
+ * because such a query joins entities of different fronds by definition. Naming one IS the
91
+ * authorization; that is what the declaration is for.
92
+ */
93
+ async function registerReads(
94
+ frond: FrondDescriptor,
95
+ scope: Container,
96
+ entityByName: Map<string, unknown>,
97
+ sourcesFactory: CreateAppOptions['sourcesFactory'],
98
+ frondLog: Logger,
99
+ ): Promise<void> {
100
+ if (!frond.reads?.length) return;
84
101
 
85
- // Declared remote: keep the scanned metadata (bridges route with it),
86
- // register nothing locally — resolve() falls through to the remote façade.
87
- if (options.remotes && frond.name in options.remotes) {
88
- log.child(frond.name).info('declared remote — not hosted locally');
89
- // Its facades answer elsewhere, but what they LISTEN to was read here.
90
- for (const handler of frond.handlers) {
91
- const key = facadeKeyOf(handler.address, handler.surface);
92
- const operations = operationModel.forHandler(handler);
93
- effectiveByKey.set(key, operations);
94
- emissions.note(contractsOf(operations), key);
102
+ if (!sourcesFactory) {
103
+ frondLog.warn(
104
+ `[reads] ${frond.reads.join(', ')} — declared in frond.config.ts, but this boot passes no `
105
+ + '`sourcesFactory`, so no reader is registered and a handler asking for `Reads` will fail '
106
+ + 'at its first call. Pass one (`@fougere/adapter-duckdb`), or drop the clause.',
107
+ );
95
108
 
96
- }
97
109
  return;
98
110
  }
99
- const scope = container.createScope();
100
- const frondLog = log.child(frond.name);
101
- // The frond's own voice, a child of the APP logger and never of `log` — which is the
102
- // boot's, so a service's line would have claimed `boot:` long after the boot was over.
103
- scope.registerValue('Logger', container.resolve<Logger>('Logger').child(frond.name));
104
111
 
105
- // `reads:` is what makes a cross-source reader exist here, and the list IS its
106
- // environment — a source holding none of these is never opened. Registered under
107
- // the type's own name, which is the key `depKeyOf` already derives for a plain
108
- // parameter: `constructor(private reads: Reads)` and nothing else to say.
109
- // Declaring `reads:` with nothing to build the reader is a boot that ignores a
110
- // clause: the handler asking for `Reads` then dies at its first call, on a
111
- // container message that names neither the clause nor what is missing.
112
- if (frond.reads?.length && !options.sourcesFactory) {
112
+ const named = frond.reads
113
+ .map((name) => entityByName.get(lowerFirst(name)))
114
+ .filter((entity): entity is NonNullable<typeof entity> => entity !== undefined);
115
+ if (named.length !== frond.reads.length) {
116
+ const missing = frond.reads.filter((name) => !entityByName.has(lowerFirst(name)));
113
117
  frondLog.warn(
114
- `[reads] ${frond.reads.join(', ')} — declared in frond.config.ts, but this boot passes no `
115
- + '`sourcesFactory`, so no reader is registered and a handler asking for `Reads` will fail '
116
- + 'at its first call. Pass one (`@fougere/adapter-duckdb`), or drop the clause.',
118
+ `[reads] ${missing.join(', ')} — named in frond.config.ts but scanned nowhere in this app, `
119
+ + 'so a query naming one would find no table. Check the spelling, or the entity file.',
117
120
  );
118
121
  }
119
- if (frond.reads?.length && options.sourcesFactory) {
120
- // Resolved across the WHOLE app, not this frond's own entities: a cross-source
121
- // query joins entities from different fronds by definition — `Progress` here,
122
- // `Book` next facade — so restricting the list to its own would make it useless.
123
- // Naming one IS the authorization; that is what the declaration is for.
124
- const named = frond.reads
125
- .map((name) => entityByName.get(lowerFirst(name)))
126
- .filter((entity): entity is NonNullable<typeof entity> => entity !== undefined);
127
- if (named.length !== frond.reads.length) {
128
- const missing = frond.reads.filter((name) => !entityByName.has(lowerFirst(name)));
129
- frondLog.warn(
130
- `[reads] ${missing.join(', ')} — named in frond.config.ts but scanned nowhere in this app, `
131
- + 'so a query naming one would find no table. Check the spelling, or the entity file.',
132
- );
133
- }
134
- scope.registerValue('Reads', await options.sourcesFactory(named, frond.name));
135
- frondLog.debug(`cross-source reader over ${named.length} entit(ies)`);
122
+
123
+ scope.registerValue('Reads', await sourcesFactory(named, frond.name));
124
+ frondLog.debug(`cross-source reader over ${named.length} entit(ies)`);
125
+ }
126
+
127
+ /**
128
+ * ONE instance per frond scope, built at its first call and closed with its frond.
129
+ *
130
+ * Its only consumer is the dispatch, which lives as long as the app, so its lifetime is not a
131
+ * choice. Resolved at the call and never at boot: a dependency may be registered by an
132
+ * extension's `up`, which rises after every frond is installed.
133
+ */
134
+ function registerMiddlewares(
135
+ frond: FrondDescriptor,
136
+ scope: Container,
137
+ assembly: Assembly,
138
+ frondLog: Logger,
139
+ ): void {
140
+ const { use, middlewaresOf } = assembly;
141
+ // Its own frond means every address its handlers answer to — wider than its entities, since
142
+ // a handler without one runs behind it too. A frond that serves nothing has none, which is
143
+ // why what it declares only ever reaches its family.
144
+ const addresses = new Set(frond.handlers.map((h) => h.address));
145
+ // What the parent took on, its own ancestors included — one level up is the whole chain,
146
+ // because the boot installs parents first and each of them left theirs here. The closures
147
+ // resolve in the scope that built them, so an inherited middleware is handed the services of
148
+ // the frond that declared it.
149
+ const inherited = frond.extends ? middlewaresOf.get(frond.extends) ?? [] : [];
150
+ const mine: AppMiddleware[] = [];
151
+
152
+ for (const middleware of frond.middlewares) {
153
+ scope.register(middleware.name, middleware.ctor, { deps: middleware.deps, lifetime: 'singleton' });
154
+ mine.push((context, next) =>
155
+ scope.resolve<{ around: AppMiddleware }>(middleware.name).around(context, next));
136
156
  }
137
157
 
138
- // Who owns what, and the rule that makes owning mean something. Before anything is
139
- // registered, so a bad line is named by this refusal rather than by the container's.
140
- sharedNames(frond, refused);
141
- const owners = ownersOf(frond.providers, frond.name, refused);
142
- storageInUserCode(frond, owners, (entity) => entityByName.has(entity), refused);
143
- crudOnOwned(frond, owners, refused);
158
+ // Ancestors first, so they stand OUTSIDE — `getMiddlewares` hands back insertion order.
159
+ for (const around of [...inherited, ...mine]) {
160
+ for (const address of addresses) use(around, address);
161
+ }
162
+ middlewaresOf.set(frond.name, [...inherited, ...mine]);
163
+
164
+ if (frond.middlewares.length > 0) {
165
+ frondLog.debug(`${frond.middlewares.length} middleware(s): ${frond.middlewares.map((m) => m.name).join(', ')}`);
166
+ }
167
+ }
168
+
169
+ /** What wraps a seam here: what the parent already put in front, then this frond's own. */
170
+ function inheritedSeams(
171
+ frond: FrondDescriptor,
172
+ seamsOf: Map<string, Map<string, ProviderEntry[]>>,
173
+ declared: Map<string, ProviderEntry[]>,
174
+ ): Map<string, ProviderEntry[]> {
175
+ const above = frond.extends ? seamsOf.get(frond.extends) : undefined;
176
+ if (!above) return declared;
177
+
178
+ const all = new Map(declared);
179
+ for (const [seam, links] of above) all.set(seam, [...links, ...(declared.get(seam) ?? [])]);
180
+
181
+ return all;
182
+ }
183
+
184
+ /**
185
+ * One storage per entity, and the default repository that IS it.
186
+ *
187
+ * The declared chain first, then the guard OUTSIDE it: the guard hands on the value it parsed,
188
+ * so a link reads what the entity says a row is rather than what arrived.
189
+ */
190
+ function registerStorages(
191
+ frond: FrondDescriptor,
192
+ assembly: Assembly,
193
+ scope: Container,
194
+ seams: Map<string, ProviderEntry[]>,
195
+ owners: Map<string, string>,
196
+ frondLog: Logger,
197
+ ): void {
198
+ const { options, refused } = assembly;
199
+ if (!options.storageFactory) return;
200
+
201
+ const unenforced: string[] = [];
202
+ const release = releasing(assembly.hosting);
203
+ for (const entity of frond.entities) {
204
+ const key = storageKeyOf(entity.name);
205
+ const source = options.sourceOf?.(entity.name) ?? 'db';
206
+ if (declares(entity.entityClass, 'unique') && options.enforces?.(source, 'unique') === false) {
207
+ unenforced.push(`${entity.name} in '${source}'`);
208
+ }
209
+ const baseStorage = options.storageFactory(entity.entityClass, entity.name);
210
+
211
+ // Check if the default handler (no surface) declares an output override
212
+ const defaultHandler = frond.handlers.find((h) => h.address === entity.name && !h.surface);
213
+ const outputSchema = defaultHandler?.outputOverride ?? (defaultHandler?.ctor as { __output?: SchemaView } | undefined)?.__output;
214
+ const scoped = outputSchema && outputSchema !== entity.entityClass
215
+ ? baseStorage.output(outputSchema)
216
+ : baseStorage;
217
+
218
+ // The declared chain first, then the guard OUTSIDE it: the guard hands on the value
219
+ // it parsed, so a wrapper reads what the entity says a row is rather than what
220
+ // arrived. Same order the client facade has held since `StorageGuard` existed.
221
+ const linked = wrapping('Storage', seams.get('Storage') ?? [], scoped, (dep) => scope.resolve(dep));
222
+ // Storage is a way out like the client surface — see `StorageGuard`.
223
+ refused.push(...refuseUnwritableNull(entity.entityClass, entity.name, entity.filePath));
224
+ const relations = heldBy(entity.entityClass, entity.name, assembly.hosting);
225
+ assembly.relations.push(...relations);
226
+ const guarded = new StorageGuard(
227
+ entity.entityClass.getFields(), entity.name, {}, relations, release,
228
+ ).guard(linked);
229
+ scope.registerValue(key, guarded);
144
230
 
231
+ // The default repository IS the guarded port — it already answers every gesture a
232
+ // declared one forwards, so the two forms have the same shape and a handler reads
233
+ // `repo.list()` either way. The wrapper that used to sit here (`{ storage: guarded }`)
234
+ // existed to make `repo.storage` true in both, back when `.storage` was the way in.
235
+ //
236
+ // Not registered for an OWNED entity: an aggregate's members are reached through it
237
+ // and nowhere else, and the default would be a second facade under a name a handler
238
+ // can spell. Every member is skipped, not just the one the key is named after —
239
+ // that asymmetry was the whole hole.
240
+ const repoKey = repositoryKeyOf(entity.name);
241
+ const owner = owners.get(entity.name);
242
+ if (owner) {
243
+ frondLog.debug(`${entity.name} — owned by ${owner}, no default repository`);
244
+ } else if (!scope.has(repoKey)) {
245
+ scope.registerValue(repoKey, guarded);
246
+ }
247
+ }
248
+ if (frond.entities.length > 0) {
249
+ frondLog.debug(`${frond.entities.length} entity storage(s): ${frond.entities.map((e) => e.name).join(', ')}`);
250
+ }
251
+ // The judge refuses a duplicate it can SEE — the row already stored. Two writes arriving
252
+ // together see the same absence, and only the place they land can refuse the second.
253
+ if (unenforced.length > 0) {
254
+ frondLog.warn(
255
+ `unique declared, and the source does not enforce it: ${unenforced.join(', ')} — `
256
+ + 'two concurrent writes can both pass',
257
+ );
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Every provider under its own name, then under the PORT each one extends — so
263
+ * `private payment: Payment` reaches the realization instead of the base it is declared
264
+ * against. The port key is registered AFTER, so it always wins over the base's own.
265
+ *
266
+ * Gives back the seam chains: a seam is bound where its realization is BUILT, not under a
267
+ * container key, so `registerStorages` is the one that puts them in front of something.
268
+ */
269
+ function registerProviders(
270
+ frond: FrondDescriptor,
271
+ scope: Container,
272
+ ports: CreateAppOptions['ports'],
273
+ boundPorts: Set<string>,
274
+ refused: Diagnostic[],
275
+ frondLog: Logger,
276
+ ): Map<string, ProviderEntry[]> {
145
277
  for (const provider of frond.providers) {
146
- scope.register(nameOf(provider), provider.ctor, { deps: provider.deps });
278
+ // `kept` is `implements AsyncDisposable` — one per frond scope, closed when it closes.
279
+ // Without it a provider is built per consumer and closed by nobody, which is what a
280
+ // service holding nothing wants.
281
+ scope.register(nameOf(provider), provider.ctor, {
282
+ deps: provider.deps,
283
+ ...(provider.kept ? { lifetime: 'singleton' as const } : {}),
284
+ });
147
285
  }
148
286
  // What this frond puts in front of one of the framework's own ports. Its own, like every
149
287
  // provider — a link goes where its frond goes, which is what a frond behind `remotes:`
150
288
  // takes with it.
151
- const seams = seamChains(frond.providers, options.ports, refused);
289
+ const seams = seamChains(frond.providers, ports, refused);
152
290
  for (const [seam, links] of seams) {
153
291
  boundPorts.add(seam);
154
292
  frondLog.debug(`seam ${seam} → ${links.map((one) => one.ctor.name).join(' → ')} → the realization`);
@@ -157,7 +295,7 @@ export async function installFrond(frond: FrondDescriptor, assembly: Assembly):
157
295
  // reaches the realization instead of the base class it is declared against.
158
296
  // Registered AFTER the loop above so a port key always wins over the base's
159
297
  // own registration — same precedence as a declared repository over its default.
160
- for (const [port, chain] of portBindings(frond.providers, (n) => scope.has(n), options.ports, refused)) {
298
+ for (const [port, chain] of portBindings(frond.providers, (n) => scope.has(n), ports, refused)) {
161
299
  // A chain a refusal emptied — `ports:` named a class that extends nothing. Already
162
300
  // reported, and there is no realization left to put a key in front of.
163
301
  if (chain.length === 0) continue;
@@ -184,68 +322,193 @@ export async function installFrond(frond: FrondDescriptor, assembly: Assembly):
184
322
  frondLog.debug(`${frond.providers.length} provider(s): ${frond.providers.map(nameOf).join(', ')}`);
185
323
  }
186
324
 
187
- // Register Storage for each entity — PascalCase type name (e.g. 'PostStorage')
188
- // When a handler declares Crud(Entity, Output), scope the storage via .output(Output)
189
- if (options.storageFactory) {
190
- const unenforced: string[] = [];
191
- const release = releasing(assembly.hosting);
192
- for (const entity of frond.entities) {
193
- const key = storageKeyOf(entity.name);
194
- const source = options.sourceOf?.(entity.name) ?? 'db';
195
- if (declares(entity.entityClass, 'unique') && options.enforces?.(source, 'unique') === false) {
196
- unenforced.push(`${entity.name} in '${source}'`);
197
- }
198
- const baseStorage = options.storageFactory(entity.entityClass, entity.name);
199
-
200
- // Check if the default handler (no surface) declares an output override
201
- const defaultHandler = frond.handlers.find((h) => h.address === entity.name && !h.surface);
202
- const outputSchema = defaultHandler?.outputOverride ?? (defaultHandler?.ctor as any)?.__output;
203
- const scoped = outputSchema && outputSchema !== entity.entityClass
204
- ? baseStorage.output(outputSchema)
205
- : baseStorage;
206
-
207
- // The declared chain first, then the guard OUTSIDE it: the guard hands on the value
208
- // it parsed, so a wrapper reads what the entity says a row is rather than what
209
- // arrived. Same order the client facade has held since `StorageGuard` existed.
210
- const linked = wrapping('Storage', seams.get('Storage') ?? [], scoped, (dep) => scope.resolve(dep));
211
- // Storage is a way out like the client surface — see `StorageGuard`.
212
- refused.push(...refuseUnwritableNull(entity.entityClass, entity.name, entity.filePath));
213
- const relations = heldBy(entity.entityClass, entity.name, assembly.hosting);
214
- assembly.relations.push(...relations);
215
- const guarded = new StorageGuard(
216
- entity.entityClass.getFields(), entity.name, {}, relations, release,
217
- ).guard(linked);
218
- scope.registerValue(key, guarded);
219
-
220
- // The default repository IS the guarded port — it already answers every gesture a
221
- // declared one forwards, so the two forms have the same shape and a handler reads
222
- // `repo.list()` either way. The wrapper that used to sit here (`{ storage: guarded }`)
223
- // existed to make `repo.storage` true in both, back when `.storage` was the way in.
224
- //
225
- // Not registered for an OWNED entity: an aggregate's members are reached through it
226
- // and nowhere else, and the default would be a second facade under a name a handler
227
- // can spell. Every member is skipped, not just the one the key is named after —
228
- // that asymmetry was the whole hole.
229
- const repoKey = repositoryKeyOf(entity.name);
230
- const owner = owners.get(entity.name);
231
- if (owner) {
232
- frondLog.debug(`${entity.name} — owned by ${owner}, no default repository`);
233
- } else if (!scope.has(repoKey)) {
234
- scope.registerValue(repoKey, guarded);
235
- }
236
- }
237
- if (frond.entities.length > 0) {
238
- frondLog.debug(`${frond.entities.length} entity storage(s): ${frond.entities.map((e) => e.name).join(', ')}`);
325
+ return seams;
326
+ }
327
+
328
+ /**
329
+ * A named surface's own storage — the same rows, scoped to what this facade shows.
330
+ *
331
+ * Under the REPOSITORY key, which is what a Crud handler asks for, and under the port's own for
332
+ * a holder that legitimately names it: registering only the latter left a named surface with no
333
+ * facade at all once the façade stopped spelling the storage.
334
+ */
335
+ function registerSurfaceStorage(
336
+ entity: EntityEntry,
337
+ handler: HandlerEntry,
338
+ surfaceScope: Container,
339
+ assembly: Assembly,
340
+ frondLog: Logger,
341
+ ): void {
342
+ const { options } = assembly;
343
+ if (!options.storageFactory) return;
344
+
345
+ const baseStorage = options.storageFactory(entity.entityClass, entity.name);
346
+ const outputSchema = handler.outputOverride ?? (handler.ctor as { __output?: SchemaView }).__output;
347
+ const narrowed = outputSchema && outputSchema !== entity.entityClass;
348
+ const scoped = narrowed ? baseStorage.output(outputSchema) : baseStorage;
349
+ // The view is handed over so a filter on a field this facade hides is SAID. The guard holds
350
+ // no logger — a warning is the boot's to voice, as a seed's report is.
351
+ const guarded = new StorageGuard(entity.entityClass.getFields(), entity.name, {
352
+ ...(narrowed ? { view: (outputSchema as { getFields(): Fields }).getFields() } : {}),
353
+ outOfView: (message) => frondLog.warn(message),
354
+ }, heldBy(entity.entityClass, entity.name, assembly.hosting), releasing(assembly.hosting)).guard(scoped);
355
+
356
+ surfaceScope.registerValue(storageKeyOf(entity.name), guarded);
357
+ surfaceScope.registerValue(repositoryKeyOf(entity.name), guarded);
358
+ }
359
+
360
+ /**
361
+ * A presenter is about an entity — computed fields sit on a shape — so this walks entities.
362
+ * Exposed lazily: the bridge resolves the instance on first access.
363
+ */
364
+ function exposePresenters(
365
+ frond: FrondDescriptor,
366
+ presenterMap: Map<string, unknown>,
367
+ container: Container,
368
+ scope: Container,
369
+ ): void {
370
+ for (const entity of frond.entities) {
371
+ if (!presenterMap.has(entity.name)) continue;
372
+
373
+ const presenterKey = presenterKeyOf(entity.name);
374
+ let instance: Record<string | symbol, unknown> | undefined;
375
+ container.registerValue(presenterKey, new Proxy({} as Record<string, unknown>, {
376
+ get(_target, prop) {
377
+ instance ??= scope.resolve<Record<string | symbol, unknown>>(presenterKey);
378
+
379
+ return instance[prop];
380
+ },
381
+ }));
382
+ }
383
+ }
384
+
385
+ /** What a facade is built out of, for one frond. */
386
+ interface Building {
387
+ frond: FrondDescriptor;
388
+ assembly: Assembly;
389
+ /** The frond's own, which a presenter is resolved through whatever audience the facade serves. */
390
+ scope: Container;
391
+ collectorTypeNames: Set<string>;
392
+ presenterMap: Map<string, PresenterEntry>;
393
+ frondLog: Logger;
394
+ }
395
+
396
+ /** Build the facade of a handler, and register it under the audience it serves. */
397
+ function buildFacadeInto(
398
+ { frond, assembly, scope, collectorTypeNames, presenterMap, frondLog }: Building,
399
+ entity: EntityEntry | undefined,
400
+ handler: HandlerEntry,
401
+ targetScope: Container,
402
+ facadeKey: string,
403
+ ): void {
404
+ const {
405
+ container, routeRegistry, emissions, dispatcher, localDispatcher, effectiveByKey,
406
+ operationModel, getMiddlewares,
407
+ } = assembly;
408
+
409
+ if (inheritsCrud(handler.ctor) && !entity) {
410
+ // An installed Crud subject may be absent from the local scan.
411
+ frondLog.debug(`${handler.ctor.name} extends Crud() and no scanned entity is named `
412
+ + `'${subjectOf(handler.ctor, handler.address)}' — installed entity, or a missing `
413
+ + `one: no storage will be injected`);
414
+ }
415
+
416
+ const facade = new HandlerFacade(handler, targetScope, {
417
+ key: facadeKey,
418
+ frond: frond.name,
419
+ handlers: frond.handlers,
420
+ operations: operationModel.forHandler(handler),
421
+ collectors: collectorTypeNames,
422
+ presenter: presenterMap.get(handler.address),
423
+ presenterScope: scope,
424
+ middlewares: () => getMiddlewares(handler.address),
425
+ });
426
+
427
+ // Emissions use the same contracts and execution path as direct calls, and the terms sit
428
+ // under the same audience — a surface that serves fewer ops describes fewer ops.
429
+ emissions.note(facade.contracts, facadeKey);
430
+ container.registerValue(contractsKeyOf(handler.address, handler.surface), facade.contracts);
431
+ effectiveByKey.set(facadeKey, facade.effectiveOperations);
432
+
433
+ for (const operation of facade.contracts.keys()) {
434
+ for (const surface of servedSurfaces(frond, handler)) {
435
+ routeRegistry.register(new OperationRoute(
436
+ 'local',
437
+ new RouteAddress({
438
+ entity: handler.address,
439
+ operation,
440
+ ...(surface !== undefined ? { surface } : {}),
441
+ }),
442
+ (call) => facade.execute(operation, call.invocation),
443
+ ));
239
444
  }
240
- // The judge refuses a duplicate it can SEE — the row already stored. Two writes arriving
241
- // together see the same absence, and only the place they land can refuse the second.
242
- if (unenforced.length > 0) {
243
- frondLog.warn(
244
- `unique declared, and the source does not enforce it: ${unenforced.join(', ')} — `
245
- + 'two concurrent writes can both pass',
246
- );
445
+ }
446
+
447
+ container.registerValue(facadeKey, facadeOperations(
448
+ handler.surface ? localDispatcher : dispatcher,
449
+ handler.address,
450
+ routeRegistry.operationNames(handler.address, handler.surface),
451
+ handler.surface,
452
+ ));
453
+ }
454
+
455
+ export async function installFrond(frond: FrondDescriptor, assembly: Assembly): Promise<void> {
456
+ const {
457
+ container, routeRegistry, emissions, dispatcher, localDispatcher, effectiveByKey,
458
+ boundPorts, refused, operationModel, entityByName, frondOf, contractsOf, getMiddlewares, use,
459
+ log, options,
460
+ } = assembly;
461
+
462
+ // Declared remote: keep the scanned metadata (bridges route with it),
463
+ // register nothing locally — resolve() falls through to the remote façade.
464
+ if (options.remotes && frond.name in options.remotes) {
465
+ log.child(frond.name).info('declared remote — not hosted locally');
466
+ // Its facades answer elsewhere, but what they LISTEN to was read here.
467
+ for (const handler of frond.handlers) {
468
+ const key = facadeKeyOf(handler.address, handler.surface);
469
+ const operations = operationModel.forHandler(handler);
470
+ effectiveByKey.set(key, operations);
471
+ emissions.note(contractsOf(operations), key);
472
+
247
473
  }
474
+ return;
248
475
  }
476
+ // A child hangs off its parent, so everything the parent registered answers here too and
477
+ // nothing else has to know: `ScopeContainer.resolve` already walks up. The boot installs
478
+ // parents first, which is what makes the key below resolvable.
479
+ const above = frond.extends ? container.resolve<Container>(`frond:${frond.extends}`) : container;
480
+ const scope = above.createScope();
481
+ const frondLog = log.child(frond.name);
482
+ // The frond's own voice, a child of the APP logger and never of `log` — which is the
483
+ // boot's, so a service's line would have claimed `boot:` long after the boot was over.
484
+ scope.registerValue('Logger', container.resolve<Logger>('Logger').child(frond.name));
485
+
486
+ // `reads:` is what makes a cross-source reader exist here, and the list IS its
487
+ // environment — a source holding none of these is never opened. Registered under
488
+ // the type's own name, which is the key `depKeyOf` already derives for a plain
489
+ // parameter: `constructor(private reads: Reads)` and nothing else to say.
490
+ // Declaring `reads:` with nothing to build the reader is a boot that ignores a
491
+ // clause: the handler asking for `Reads` then dies at its first call, on a
492
+ // container message that names neither the clause nor what is missing.
493
+ await registerReads(frond, scope, entityByName, options.sourcesFactory, frondLog);
494
+
495
+ // Who owns what, and the rule that makes owning mean something. Before anything is
496
+ // registered, so a bad line is named by this refusal rather than by the container's.
497
+ sharedNames(frond, refused);
498
+ const owners = ownersOf(frond.providers, frond.name, refused);
499
+ storageInUserCode(frond, owners, (entity) => entityByName.has(entity), refused);
500
+ crudOnOwned(frond, owners, refused);
501
+
502
+ const declared = registerProviders(frond, scope, options.ports, boundPorts, refused, frondLog);
503
+ // An ancestor's links stand OUTSIDE this frond's own, the order `wrapping` reads — and
504
+ // like a middleware, a link cannot be inherited through a container key, since a seam has
505
+ // none: its realization is built rather than resolved.
506
+ const seams = inheritedSeams(frond, assembly.seamsOf, declared);
507
+ assembly.seamsOf.set(frond.name, seams);
508
+
509
+ // Register Storage for each entity — PascalCase type name (e.g. 'PostStorage')
510
+ // When a handler declares Crud(Entity, Output), scope the storage via .output(Output)
511
+ registerStorages(frond, assembly, scope, seams, owners, frondLog);
249
512
 
250
513
  // Frames, after the ORMs and before anything that may ask for one. A frame is read
251
514
  // from the same `deps` every other port is read from — asking for it IS declaring it,
@@ -291,95 +554,24 @@ export async function installFrond(frond: FrondDescriptor, assembly: Assembly):
291
554
  // Register middlewares in scope, then take them on. Resolved per call and never here:
292
555
  // a middleware asking for something request-scoped would otherwise be handed the one
293
556
  // instance the boot built — the same reason `getMiddlewares` is read at call time.
294
- for (const middleware of frond.middlewares) {
295
- scope.register(middleware.name, middleware.ctor, { deps: middleware.deps });
296
- const around: AppMiddleware = (context, next) =>
297
- scope.resolve<{ around: AppMiddleware }>(middleware.name).around(context, next);
298
-
299
- if (middleware.scope === 'app') use(around);
300
- // Its own frond means every address its handlers answer to — wider than its entities,
301
- // since a handler without one runs behind it too.
302
- else for (const address of new Set(frond.handlers.map((h) => h.address))) use(around, address);
303
- }
304
- if (frond.middlewares.length > 0) {
305
- frondLog.debug(`${frond.middlewares.length} middleware(s): ${frond.middlewares.map((m) => `${m.name} (${m.scope})`).join(', ')}`);
306
- }
557
+ registerMiddlewares(frond, scope, assembly, frondLog);
307
558
 
308
559
  // Build handler facades → registered in ROOT container (public contract)
309
560
  const defaultHandlers = frond.handlers.filter((h) => !h.surface);
310
561
  const surfaceHandlers = frond.handlers.filter((h) => h.surface);
311
562
  const defaultHandlerMap = new Map(defaultHandlers.map((h) => [h.address, h]));
312
563
 
313
- /** Build the facade of a handler and register it under the audience it serves. */
564
+ const building: Building = { frond, assembly, scope, collectorTypeNames, presenterMap, frondLog };
314
565
  const buildFacade = (
315
566
  entity: EntityEntry | undefined,
316
567
  handler: HandlerEntry,
317
568
  targetScope: Container,
318
569
  facadeKey: string,
319
- ) => {
320
- if (inheritsCrud(handler.ctor) && !entity) {
321
- // An installed Crud subject may be absent from the local scan.
322
- frondLog.debug(`${handler.ctor.name} extends Crud() and no scanned entity is named `
323
- + `'${subjectOf(handler.ctor, handler.address)}' — installed entity, or a missing `
324
- + `one: no storage will be injected`);
325
- }
326
-
327
- const facade = new HandlerFacade(handler, targetScope, {
328
- key: facadeKey,
329
- frond: frond.name,
330
- handlers: frond.handlers,
331
- operations: operationModel.forHandler(handler),
332
- collectors: collectorTypeNames,
333
- presenter: presenterMap.get(handler.address),
334
- presenterScope: scope,
335
- middlewares: () => getMiddlewares(handler.address),
336
- });
337
- // Emissions use the same contracts and execution path as direct calls.
338
- emissions.note(facade.contracts, facadeKey);
339
- // The terms alongside the facade, under the same audience — a surface that serves
340
- // fewer ops describes fewer ops.
341
- container.registerValue(contractsKeyOf(handler.address, handler.surface), facade.contracts);
342
- effectiveByKey.set(facadeKey, facade.effectiveOperations);
343
-
344
- const surfaces = servedSurfaces(frond, handler);
345
-
346
- for (const operation of facade.contracts.keys()) {
347
- for (const surface of surfaces) {
348
- const address = new RouteAddress({
349
- entity: handler.address,
350
- operation,
351
- ...(surface !== undefined ? { surface } : {}),
352
- });
353
- routeRegistry.register(new OperationRoute(
354
- 'local',
355
- address,
356
- (call) => facade.execute(operation, call.invocation),
357
- ));
358
- }
359
- }
360
-
361
- const operations = facadeOperations(
362
- handler.surface ? localDispatcher : dispatcher,
363
- handler.address,
364
- routeRegistry.operationNames(handler.address, handler.surface),
365
- handler.surface,
366
- );
367
- container.registerValue(facadeKey, operations);
368
- };
570
+ ) => buildFacadeInto(building, entity, handler, targetScope, facadeKey);
369
571
 
370
572
  // A presenter is about an entity — computed fields sit on a shape — so this walks
371
573
  // entities. Exposing the instance lazily; the bridge resolves it on first access.
372
- for (const entity of frond.entities) {
373
- if (!presenterMap.has(entity.name)) continue;
374
- const presenterKey = presenterKeyOf(entity.name);
375
- let presenterInstance: any;
376
- container.registerValue(presenterKey, new Proxy({} as any, {
377
- get(_target, prop) {
378
- if (!presenterInstance) presenterInstance = scope.resolve(presenterKey);
379
- return presenterInstance[prop];
380
- },
381
- }));
382
- }
574
+ exposePresenters(frond, presenterMap, container, scope);
383
575
 
384
576
  // A facade is about a handler, so this walks HANDLERS. It walked entities before,
385
577
  // which made an entity a precondition for being callable at all: a handler naming
@@ -397,7 +589,7 @@ export async function installFrond(frond: FrondDescriptor, assembly: Assembly):
397
589
  const entity = frond.entities.find((e) => e.name === subject);
398
590
  const facadeKey = facadeKeyOf(handler.address);
399
591
  buildFacade(entity, handler, scope, facadeKey);
400
- frondLog.debug(`${facadeKey} [${Object.keys(container.resolve(facadeKey) as any).join(', ')}]`
592
+ frondLog.debug(`${facadeKey} [${Object.keys(container.resolve(facadeKey) as object).join(', ')}]`
401
593
  + (entity ? '' : ' — no entity of that name: no storage, no projection, no presenter'));
402
594
  }
403
595
 
@@ -423,27 +615,11 @@ export async function installFrond(frond: FrondDescriptor, assembly: Assembly):
423
615
  // key, which is what a Crud handler asks for, and under the port's own for a holder
424
616
  // that legitimately names it. Registering only the latter left a named surface with
425
617
  // no facade at all once the façade stopped spelling the storage.
426
- if (entity && options.storageFactory) {
427
- const baseStorage = options.storageFactory(entity.entityClass, entity.name);
428
- const outputSchema = handler.outputOverride ?? (handler.ctor as any).__output;
429
- const scoped = outputSchema && outputSchema !== entity.entityClass
430
- ? baseStorage.output(outputSchema)
431
- : baseStorage;
432
- // The view is handed over so a filter on a field this facade hides is SAID. The
433
- // guard holds no logger — a warning is the boot's to voice, as a seed's report is.
434
- const guarded = new StorageGuard(entity.entityClass.getFields(), entity.name, {
435
- ...(outputSchema && outputSchema !== entity.entityClass
436
- ? { view: (outputSchema as { getFields(): Fields }).getFields() }
437
- : {}),
438
- outOfView: (message) => frondLog.warn(message),
439
- }, heldBy(entity.entityClass, entity.name, assembly.hosting), releasing(assembly.hosting)).guard(scoped);
440
- surfaceScope.registerValue(storageKeyOf(entity.name), guarded);
441
- surfaceScope.registerValue(repositoryKeyOf(entity.name), guarded);
442
- }
618
+ if (entity) registerSurfaceStorage(entity, handler, surfaceScope, assembly, frondLog);
443
619
 
444
620
  const facadeKey = facadeKeyOf(handler.address, handler.surface);
445
621
  buildFacade(entity, handler, surfaceScope, facadeKey);
446
- frondLog.debug(`${facadeKey} [${Object.keys(container.resolve(facadeKey) as any).join(', ')}]`
622
+ frondLog.debug(`${facadeKey} [${Object.keys(container.resolve(facadeKey) as object).join(', ')}]`
447
623
  + (entity ? '' : ' — no entity of that name: no storage, no projection, no presenter'));
448
624
  }
449
625