@fougere/core 0.12.2-alpha.0 → 0.12.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 (126) hide show
  1. package/dist/EffectiveOperationModel.d.ts +1 -4
  2. package/dist/EffectiveOperationModel.d.ts.map +1 -1
  3. package/dist/EffectiveOperationModel.js +181 -173
  4. package/dist/EffectiveOperationModel.js.map +1 -1
  5. package/dist/FougereConfig.d.ts +5 -3
  6. package/dist/FougereConfig.d.ts.map +1 -1
  7. package/dist/FougereConfig.js +11 -13
  8. package/dist/FougereConfig.js.map +1 -1
  9. package/dist/FrondDeclaration.d.ts.map +1 -1
  10. package/dist/FrondDeclaration.js +1 -5
  11. package/dist/FrondDeclaration.js.map +1 -1
  12. package/dist/Source.js +1 -1
  13. package/dist/Source.js.map +1 -1
  14. package/dist/SourceView.d.ts +0 -2
  15. package/dist/SourceView.d.ts.map +1 -1
  16. package/dist/boot/App.d.ts +2 -4
  17. package/dist/boot/App.d.ts.map +1 -1
  18. package/dist/boot/AppLifecycle.d.ts +5 -0
  19. package/dist/boot/AppLifecycle.d.ts.map +1 -1
  20. package/dist/boot/AppLifecycle.js +19 -10
  21. package/dist/boot/AppLifecycle.js.map +1 -1
  22. package/dist/boot/AuthRuntime.d.ts +5 -12
  23. package/dist/boot/AuthRuntime.d.ts.map +1 -1
  24. package/dist/boot/AuthRuntime.js +2 -1
  25. package/dist/boot/AuthRuntime.js.map +1 -1
  26. package/dist/boot/CreateAppOptions.d.ts +0 -11
  27. package/dist/boot/CreateAppOptions.d.ts.map +1 -1
  28. package/dist/boot/Emissions.d.ts.map +1 -1
  29. package/dist/boot/Emissions.js +3 -6
  30. package/dist/boot/Emissions.js.map +1 -1
  31. package/dist/boot/Hosting.d.ts +2 -1
  32. package/dist/boot/Hosting.d.ts.map +1 -1
  33. package/dist/boot/bootstrap.d.ts.map +1 -1
  34. package/dist/boot/bootstrap.js +140 -263
  35. package/dist/boot/bootstrap.js.map +1 -1
  36. package/dist/boot/install.d.ts.map +1 -1
  37. package/dist/boot/install.js +102 -114
  38. package/dist/boot/install.js.map +1 -1
  39. package/dist/boot/relations.d.ts.map +1 -1
  40. package/dist/boot/relations.js.map +1 -1
  41. package/dist/boot/seed.d.ts.map +1 -1
  42. package/dist/boot/seed.js.map +1 -1
  43. package/dist/descriptor/Fronds.d.ts +5 -0
  44. package/dist/descriptor/Fronds.d.ts.map +1 -1
  45. package/dist/descriptor/Fronds.js +8 -0
  46. package/dist/descriptor/Fronds.js.map +1 -1
  47. package/dist/descriptor/index.d.ts +1 -0
  48. package/dist/descriptor/index.d.ts.map +1 -1
  49. package/dist/descriptor/index.js +1 -0
  50. package/dist/descriptor/index.js.map +1 -1
  51. package/dist/dispatch/InFlight.d.ts +3 -2
  52. package/dist/dispatch/InFlight.d.ts.map +1 -1
  53. package/dist/dispatch/InFlight.js +11 -1
  54. package/dist/dispatch/InFlight.js.map +1 -1
  55. package/dist/index.d.ts +2 -4
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +2 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/node.d.ts +1 -1
  60. package/dist/node.d.ts.map +1 -1
  61. package/dist/node.js +1 -1
  62. package/dist/node.js.map +1 -1
  63. package/dist/prefab/CrudConstructor.d.ts.map +1 -1
  64. package/dist/prefab/CrudConstructor.js.map +1 -1
  65. package/dist/storage/Comparison.d.ts +2 -2
  66. package/dist/storage/Comparison.d.ts.map +1 -1
  67. package/dist/storage/Comparison.js +3 -1
  68. package/dist/storage/Comparison.js.map +1 -1
  69. package/dist/wire/Emit.d.ts +0 -2
  70. package/dist/wire/Emit.d.ts.map +1 -1
  71. package/dist/wire/Emit.js +1 -1
  72. package/dist/wire/Emit.js.map +1 -1
  73. package/dist/wire/Facade.d.ts +7 -0
  74. package/dist/wire/Facade.d.ts.map +1 -1
  75. package/dist/wire/Facade.js +13 -0
  76. package/dist/wire/Facade.js.map +1 -1
  77. package/dist/wire/OperationContract.d.ts +0 -4
  78. package/dist/wire/OperationContract.d.ts.map +1 -1
  79. package/dist/wire/OperationContract.js +1 -1
  80. package/dist/wire/OperationContract.js.map +1 -1
  81. package/dist/wire/OperationsMap.d.ts +0 -3
  82. package/dist/wire/OperationsMap.d.ts.map +1 -1
  83. package/dist/wire/OperationsMap.js +0 -5
  84. package/dist/wire/OperationsMap.js.map +1 -1
  85. package/package.json +4 -4
  86. package/src/EffectiveOperationModel.ts +221 -201
  87. package/src/FougereConfig.ts +14 -16
  88. package/src/FrondDeclaration.ts +1 -7
  89. package/src/Source.ts +1 -1
  90. package/src/SourceView.ts +0 -2
  91. package/src/boot/App.ts +2 -4
  92. package/src/boot/AppLifecycle.ts +21 -10
  93. package/src/boot/AuthRuntime.ts +6 -13
  94. package/src/boot/CreateAppOptions.ts +0 -11
  95. package/src/boot/Emissions.ts +3 -7
  96. package/src/boot/Hosting.ts +2 -1
  97. package/src/boot/bootstrap.ts +168 -300
  98. package/src/boot/install.ts +120 -146
  99. package/src/boot/relations.ts +3 -8
  100. package/src/boot/seed.ts +2 -4
  101. package/src/descriptor/Fronds.ts +10 -0
  102. package/src/descriptor/index.ts +1 -0
  103. package/src/dispatch/InFlight.ts +16 -2
  104. package/src/index.ts +2 -4
  105. package/src/node.ts +1 -1
  106. package/src/prefab/CrudConstructor.ts +0 -11
  107. package/src/storage/Comparison.ts +7 -4
  108. package/src/wire/Emit.ts +1 -1
  109. package/src/wire/Facade.ts +17 -10
  110. package/src/wire/OperationContract.ts +1 -1
  111. package/src/wire/OperationsMap.ts +0 -10
  112. package/dist/boot/AuthConfig.d.ts +0 -14
  113. package/dist/boot/AuthConfig.d.ts.map +0 -1
  114. package/dist/boot/AuthConfig.js +0 -2
  115. package/dist/boot/AuthConfig.js.map +0 -1
  116. package/dist/boot/AuthContext.d.ts +0 -12
  117. package/dist/boot/AuthContext.d.ts.map +0 -1
  118. package/dist/boot/AuthContext.js +0 -2
  119. package/dist/boot/AuthContext.js.map +0 -1
  120. package/dist/storage/ComparisonName.d.ts +0 -3
  121. package/dist/storage/ComparisonName.d.ts.map +0 -1
  122. package/dist/storage/ComparisonName.js +0 -2
  123. package/dist/storage/ComparisonName.js.map +0 -1
  124. package/src/boot/AuthConfig.ts +0 -14
  125. package/src/boot/AuthContext.ts +0 -12
  126. package/src/storage/ComparisonName.ts +0 -3
@@ -21,6 +21,9 @@ import type { EffectiveOperationOptions } from './EffectiveOperationOptions.js';
21
21
 
22
22
  type Binding = BindingPlan[number];
23
23
 
24
+ /** A contract whose parameters are all bound. */
25
+ type BoundContract = OperationContract & { binding: BindingPlan };
26
+
24
27
  /**
25
28
  * Where an op's work goes: the fronds its handler reaches, and how many are a process away.
26
29
  *
@@ -47,26 +50,30 @@ function reachOf(
47
50
  * boot never has to find a handler again by an order-dependent name lookup.
48
51
  */
49
52
  export class EffectiveOperationModel {
50
- readonly operations: EffectiveOperation[];
51
- readonly diagnostics: Diagnostic[];
52
- readonly resolutionDiagnostics: Diagnostic[];
53
-
54
53
  constructor(
55
- operations: EffectiveOperation[],
56
- diagnostics: Diagnostic[],
57
- resolutionDiagnostics: Diagnostic[],
54
+ readonly operations: EffectiveOperation[],
55
+ readonly diagnostics: Diagnostic[],
56
+ readonly resolutionDiagnostics: Diagnostic[],
58
57
  private readonly byHandler: Map<HandlerEntry, EffectiveOperationsMap>,
59
- ) {
60
- this.operations = operations;
61
- this.diagnostics = diagnostics;
62
- this.resolutionDiagnostics = resolutionDiagnostics;
63
- }
58
+ ) {}
64
59
 
65
60
  forHandler(handler: HandlerEntry): EffectiveOperationsMap {
66
61
  return this.byHandler.get(handler) ?? new Map();
67
62
  }
68
63
  }
69
64
 
65
+ /** What one handler's operations are resolved against. */
66
+ interface Resolving {
67
+ fronds: readonly FrondDescriptor[];
68
+ frond: FrondDescriptor;
69
+ handler: HandlerEntry;
70
+ schemas: Map<string, SchemaView>;
71
+ served: Map<string, string>;
72
+ collectorsByType: Map<string, CollectorEntry[]>;
73
+ options: EffectiveOperationOptions;
74
+ diagnostics: Diagnostic[];
75
+ }
76
+
70
77
  /** Resolve the complete operation model without starting the application. */
71
78
  export function resolveEffectiveOperations(
72
79
  fronds: readonly FrondDescriptor[],
@@ -74,214 +81,40 @@ export function resolveEffectiveOperations(
74
81
  ): EffectiveOperationModel {
75
82
  const operations: EffectiveOperation[] = [];
76
83
  const byHandler = new Map<HandlerEntry, EffectiveOperationsMap>();
77
- const resolutionDiagnostics: Diagnostic[] = [];
78
84
  const scanDiagnostics = [...(options.diagnostics ?? [])];
79
85
  const schemas = new Map(
80
86
  fronds.flatMap((frond) => frond.entities.map((entity) => [entity.name, entity.entityClass] as const)),
81
87
  );
82
- /** Every address a frond answers, so a dependency naming one is read as the crossing it is. */
83
88
  const served = servedBy(fronds);
84
89
 
85
- // Input ambiguity is produced where schemas are available (the scanner), but it is a
86
- // resolution failure. Lift it into the same refusal table as kind/binding/topology.
87
- resolutionDiagnostics.push(...scanDiagnostics.filter((diagnostic) =>
88
- diagnostic.code === 'input-contract-ambiguous'));
90
+ // Input ambiguity is found by the scanner, where schemas are, but it is a resolution failure.
91
+ const diagnostics = scanDiagnostics.filter((diagnostic) => diagnostic.code === 'input-contract-ambiguous');
89
92
 
90
93
  for (const frond of fronds) {
91
94
  const collectorsByType = groupedCollectors(frond.collectors);
92
- for (const [typeName, collectors] of collectorsByType) {
93
- if (collectors.length < 2) continue;
94
- resolutionDiagnostics.push({
95
- severity: 'blocking',
96
- code: 'collector-ambiguous',
97
- filePath: collectors[0]!.filePath,
98
- frond: frond.name,
99
- subject: typeName,
100
- message: `Frond '${frond.name}' declares ${collectors.length} collectors for '${typeName}' — `
101
- + `${collectors.map((collector) => collector.ctor.name).sort().join(', ')}. `
102
- + 'Exactly one collector must own a parameter type.',
103
- });
104
- }
95
+ refuseAmbiguousCollectors(frond, collectorsByType, diagnostics);
105
96
 
106
97
  const collectorNames = new Set(frond.collectors.map((collector) => collector.typeName));
107
98
  for (const handler of frond.handlers) {
108
99
  const effective = new Map<string, EffectiveOperation>();
109
100
  byHandler.set(handler, effective);
110
101
  const contracts = resolveContracts(handler, frond.operationsOverrides, collectorNames);
111
- // A statement wins over the scan on purpose; saying so out loud is what keeps the
112
- // win from hiding a rename. Compared here, where both readings are in hand.
113
- resolutionDiagnostics.push(...statementDrift(frond, handler));
114
- resolutionDiagnostics.push(...unansweredOperations(frond, handler, contracts.keys()));
115
-
116
- for (const [name, rawContract] of contracts) {
117
- const subject = `${handler.ctor.name}.${name}`;
118
- const contract = normalizeBinding(rawContract, handler, frond, name, resolutionDiagnostics);
119
- if (!contract) continue;
120
-
121
- // An operation answers DATA — what JSON keeps. A DECLARED output is converted by its
122
- // fields, so `created()` leaves as an ISO string and nothing is asked of it here; an
123
- // undeclared one has nothing to convert it, so it must already be data. Measured
124
- // 2026-09-18: `{ at: new Date(0) }` reaches a local caller as a `Date` and a remote one
125
- // as a string, and an object carrying methods loses them on both sides, differently.
126
- if (!contract.output && contract.signature?.notData) {
127
- resolutionDiagnostics.push({
128
- severity: 'blocking',
129
- code: 'operation-output-not-data',
130
- filePath: handler.filePath,
131
- frond: frond.name,
132
- subject,
133
- message: `${subject}() answers ${contract.signature.notData}, which carries methods.\n`
134
- + ' An operation answers data — what JSON keeps.\n'
135
- + ' Declare the output as an entity, whose fields convert it, or answer what it produces.',
136
- });
137
- continue;
138
- }
139
-
140
- const override = frond.operationsOverrides?.[name];
141
- const inference = inferOperationKind(name);
142
- const inferredKinds = new Set<OperationKind>();
143
- if (inference.kind) inferredKinds.add(inference.kind);
144
- if (inference.queryMatches.length > 0) inferredKinds.add('query');
145
- if (inference.commandMatches.length > 0) inferredKinds.add('command');
146
- const kind = override?.kind
147
- ?? (inferredKinds.size === 1 ? [...inferredKinds][0] : undefined);
148
- if (!kind) {
149
- const ambiguous = inferredKinds.size > 1;
150
- resolutionDiagnostics.push({
151
- severity: 'blocking',
152
- code: ambiguous ? 'operation-kind-ambiguous' : 'operation-kind-unknown',
153
- filePath: handler.filePath,
154
- frond: frond.name,
155
- subject,
156
- message: ambiguous
157
- ? `Cannot resolve the kind of ${subject}: its name carries query evidence `
158
- + `(${inference.queryMatches.join(', ')}) and command evidence `
159
- + `(${inference.commandMatches.join(', ')}). `
160
- + `Declare operations.${name}.kind.`
161
- : `Cannot resolve the kind of ${subject}: '${name}' leads with no known verb. `
162
- + `Rename it to lead with one, or declare operations.${name}.kind in `
163
- + `frond.config.ts.\n query: ${knownVerbs().query.join(', ')}`
164
- + `\n command: ${knownVerbs().command.join(', ')}`,
165
- });
166
- continue;
167
- }
168
-
169
- const implementation = implementationOf(frond, handler, name, resolutionDiagnostics);
170
- if (!implementation) continue;
171
-
172
- const params = contract.signature?.params ?? [];
173
- const parameters = contract.binding.map((binding, position) => {
174
- const param = params[position];
175
- const undefinable = binding.optional
176
- || param?.optional === true
177
- || param?.type.undefined === true;
178
-
179
- return {
180
- position,
181
- name: param?.name ?? binding.name,
182
- type: param?.type.raw ?? null,
183
- optional: undefinable,
184
- nullable: param?.type.nullable === true,
185
- undefinable,
186
- binding,
187
- } satisfies EffectiveParameter;
188
- });
189
-
190
- if (!validateProvenance(
191
- fronds,
192
- frond,
193
- handler,
194
- name,
195
- contract,
196
- parameters,
197
- collectorsByType,
198
- resolutionDiagnostics,
199
- )) continue;
200
-
201
- // A fact is validated by the entity it names. This used to be patched into the
202
- // facade after resolution, leaving check/explain with a different input.
203
- const announced = contract.binding.find((binding) =>
204
- binding.source.kind === 'fact' || binding.source.kind === 'pipe');
205
- const fact = announced?.source.kind === 'fact' || announced?.source.kind === 'pipe'
206
- ? schemas.get(announced.source.factName)
207
- : undefined;
208
- const input = contract.input ?? fact;
209
-
210
- // An op that FINISHES a fact answers the fact, so its answer is the fact's shape:
211
- // derived here, where its input already is. Left to the ordinary projection it
212
- // came back stripped — measured, the subscriber got a value with no `id`.
213
- const output = announced?.source.kind === 'pipe' && fact
214
- ? { schema: fact, closed: false }
215
- : effectiveOutput(frond, handler, name, contract);
216
- const className = handler.ctor.name.endsWith('Handler')
217
- ? handler.ctor.name.slice(0, -'Handler'.length)
218
- : handler.ctor.name;
219
- const surface = handler.surface ? `/${handler.surface}` : '/default';
220
- const remote = options.remotes?.[frond.name];
221
- const op: EffectiveOperation = {
222
- ...contract,
223
- ...(input ? { input } : {}),
224
- ...(output.schema ? { output: output.schema } : {}),
225
- id: `${frond.name}${surface}/${className}.${name}`,
226
- operation: `${className}.${name}`,
227
- name,
228
- kind,
229
- kindSource: override?.kind
230
- ? 'explicit'
231
- : 'convention',
232
- handler: {
233
- className: handler.ctor.name,
234
- address: handler.address,
235
- filePath: handler.filePath,
236
- },
237
- implementation,
238
- binding: contract.binding,
239
- parameters,
240
- collectors: parameters.flatMap((parameter) => {
241
- if (parameter.binding.source.kind !== 'collector') return [];
242
- const collector = collectorsByType.get(parameter.binding.source.typeName)?.[0];
243
-
244
- return collector ? [{
245
- parameter: parameter.name,
246
- typeName: collector.typeName,
247
- className: collector.ctor.name,
248
- frond: frond.name,
249
- filePath: collector.filePath,
250
- }] : [];
251
- }),
252
- contexts: parameters
253
- .filter((parameter) => parameter.binding.source.kind === 'context')
254
- .map((parameter) => parameter.name),
255
- placement: {
256
- frond: frond.name,
257
- runtime: remote ? 'remote' : 'local',
258
- ...(remote ? { remote } : {}),
259
- },
260
- reach: reachOf(handler.deps, frond.name, served, options.remotes ?? {}),
261
- exposure: {
262
- surfaces: servedSurfaces(frond, handler).map((surface) => surface ?? 'default'),
263
- adapters: exposedAdapters(handler, options.adapters),
264
- },
265
- outputClosed: output.closed,
266
- semantics: EFFECTIVE_OPERATION_SEMANTICS,
267
- };
102
+ // A statement wins over the scan on purpose; saying so is what keeps the win from hiding a rename.
103
+ diagnostics.push(...statementDrift(frond, handler));
104
+ diagnostics.push(...unansweredOperations(frond, handler, contracts.keys()));
105
+
106
+ const resolving: Resolving = { fronds, frond, handler, schemas, served, collectorsByType, options, diagnostics };
107
+ for (const [name, contract] of contracts) {
108
+ const op = resolveOperation(resolving, name, contract);
109
+ if (!op) continue;
268
110
  effective.set(name, op);
269
111
  operations.push(op);
270
112
  }
271
113
  }
272
114
  }
273
115
 
274
- // Topology and DI are part of the same effective program. A dependency targeting an
275
- // actually remote frond is a refusal, not the warning appropriate to a future split.
276
- for (const misplaced of verify({ fronds })) {
277
- const remoteBoundary = options.remotes?.[misplaced.frond] !== undefined
278
- || options.remotes?.[misplaced.dependsOn.frond] !== undefined;
279
- resolutionDiagnostics.push(remoteBoundary && misplaced.code === 'cross-frond-dependency'
280
- ? { ...misplaced, severity: 'blocking' }
281
- : misplaced);
282
- }
283
-
284
- const resolution = uniqueDiagnostics(resolutionDiagnostics);
116
+ diagnostics.push(...placementRefusals(fronds, options));
117
+ const resolution = uniqueDiagnostics(diagnostics);
285
118
 
286
119
  return new EffectiveOperationModel(
287
120
  operations,
@@ -291,6 +124,193 @@ export function resolveEffectiveOperations(
291
124
  );
292
125
  }
293
126
 
127
+ function refuseAmbiguousCollectors(
128
+ frond: FrondDescriptor,
129
+ collectorsByType: Map<string, CollectorEntry[]>,
130
+ diagnostics: Diagnostic[],
131
+ ): void {
132
+ for (const [typeName, collectors] of collectorsByType) {
133
+ if (collectors.length < 2) continue;
134
+ diagnostics.push({
135
+ severity: 'blocking',
136
+ code: 'collector-ambiguous',
137
+ filePath: collectors[0]!.filePath,
138
+ frond: frond.name,
139
+ subject: typeName,
140
+ message: `Frond '${frond.name}' declares ${collectors.length} collectors for '${typeName}' — `
141
+ + `${collectors.map((collector) => collector.ctor.name).sort().join(', ')}. `
142
+ + 'Exactly one collector must own a parameter type.',
143
+ });
144
+ }
145
+ }
146
+
147
+ /** One operation, or nothing when a refusal was written for it. */
148
+ function resolveOperation(resolving: Resolving, name: string, rawContract: OperationContract): EffectiveOperation | undefined {
149
+ const { fronds, frond, handler, schemas, served, collectorsByType, options, diagnostics } = resolving;
150
+ const contract = normalizeBinding(rawContract, handler, frond, name, diagnostics);
151
+ if (!contract || !answersData(resolving, name, contract)) return undefined;
152
+
153
+ const kind = kindOf(resolving, name);
154
+ if (!kind) return undefined;
155
+
156
+ const implementation = implementationOf(frond, handler, name, diagnostics);
157
+ if (!implementation) return undefined;
158
+
159
+ const parameters = parametersOf(contract);
160
+ if (!validateProvenance(fronds, frond, handler, name, contract, parameters, collectorsByType, diagnostics)) {
161
+ return undefined;
162
+ }
163
+
164
+ // A fact is validated by the entity it names, and an op that FINISHES one answers its shape —
165
+ // left to the ordinary projection, the subscriber got a value with no `id`.
166
+ const announced = contract.binding.find((binding) => binding.source.kind === 'fact' || binding.source.kind === 'pipe');
167
+ const fact = announced?.source.kind === 'fact' || announced?.source.kind === 'pipe'
168
+ ? schemas.get(announced.source.factName)
169
+ : undefined;
170
+ const input = contract.input ?? fact;
171
+ const output = announced?.source.kind === 'pipe' && fact
172
+ ? { schema: fact, closed: false }
173
+ : effectiveOutput(frond, handler, name, contract);
174
+
175
+ const className = handler.ctor.name.replace(/Handler$/, '');
176
+ const surface = handler.surface ? `/${handler.surface}` : '/default';
177
+ const remote = options.remotes?.[frond.name];
178
+
179
+ return {
180
+ ...contract,
181
+ ...(input ? { input } : {}),
182
+ ...(output.schema ? { output: output.schema } : {}),
183
+ id: `${frond.name}${surface}/${className}.${name}`,
184
+ operation: `${className}.${name}`,
185
+ name,
186
+ kind,
187
+ kindSource: frond.operationsOverrides?.[name]?.kind ? 'explicit' : 'convention',
188
+ handler: { className: handler.ctor.name, address: handler.address, filePath: handler.filePath },
189
+ implementation,
190
+ binding: contract.binding,
191
+ parameters,
192
+ collectors: collectorsOf(parameters, collectorsByType, frond),
193
+ contexts: parameters
194
+ .filter((parameter) => parameter.binding.source.kind === 'context')
195
+ .map((parameter) => parameter.name),
196
+ placement: { frond: frond.name, runtime: remote ? 'remote' : 'local', ...(remote ? { remote } : {}) },
197
+ reach: reachOf(handler.deps, frond.name, served, options.remotes ?? {}),
198
+ exposure: {
199
+ surfaces: servedSurfaces(frond, handler).map((surface) => surface ?? 'default'),
200
+ adapters: exposedAdapters(handler, options.adapters),
201
+ },
202
+ outputClosed: output.closed,
203
+ semantics: EFFECTIVE_OPERATION_SEMANTICS,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * An operation answers DATA — what JSON keeps. A declared output is converted by its fields; an
209
+ * undeclared one has nothing to convert it. Measured 2026-09-18: `{ at: new Date(0) }` reaches a
210
+ * local caller as a `Date` and a remote one as a string.
211
+ */
212
+ function answersData({ frond, handler, diagnostics }: Resolving, name: string, contract: OperationContract): boolean {
213
+ if (contract.output || !contract.signature?.notData) return true;
214
+
215
+ const subject = `${handler.ctor.name}.${name}`;
216
+ diagnostics.push({
217
+ severity: 'blocking',
218
+ code: 'operation-output-not-data',
219
+ filePath: handler.filePath,
220
+ frond: frond.name,
221
+ subject,
222
+ message: `${subject}() answers ${contract.signature.notData}, which carries methods.\n`
223
+ + ' An operation answers data — what JSON keeps.\n'
224
+ + ' Declare the output as an entity, whose fields convert it, or answer what it produces.',
225
+ });
226
+
227
+ return false;
228
+ }
229
+
230
+ /** Stated in `frond.config.ts`, or read off the verb the name leads with — never guessed between two. */
231
+ function kindOf({ frond, handler, diagnostics }: Resolving, name: string): OperationKind | undefined {
232
+ const stated = frond.operationsOverrides?.[name]?.kind;
233
+ if (stated) return stated;
234
+
235
+ const inference = inferOperationKind(name);
236
+ const inferred = new Set<OperationKind>();
237
+ if (inference.kind) inferred.add(inference.kind);
238
+ if (inference.queryMatches.length > 0) inferred.add('query');
239
+ if (inference.commandMatches.length > 0) inferred.add('command');
240
+ if (inferred.size === 1) return [...inferred][0];
241
+
242
+ const subject = `${handler.ctor.name}.${name}`;
243
+ const ambiguous = inferred.size > 1;
244
+ diagnostics.push({
245
+ severity: 'blocking',
246
+ code: ambiguous ? 'operation-kind-ambiguous' : 'operation-kind-unknown',
247
+ filePath: handler.filePath,
248
+ frond: frond.name,
249
+ subject,
250
+ message: ambiguous
251
+ ? `Cannot resolve the kind of ${subject}: its name carries query evidence `
252
+ + `(${inference.queryMatches.join(', ')}) and command evidence `
253
+ + `(${inference.commandMatches.join(', ')}). `
254
+ + `Declare operations.${name}.kind.`
255
+ : `Cannot resolve the kind of ${subject}: '${name}' leads with no known verb. `
256
+ + `Rename it to lead with one, or declare operations.${name}.kind in `
257
+ + `frond.config.ts.\n query: ${knownVerbs().query.join(', ')}`
258
+ + `\n command: ${knownVerbs().command.join(', ')}`,
259
+ });
260
+
261
+ return undefined;
262
+ }
263
+
264
+ function parametersOf(contract: BoundContract): EffectiveParameter[] {
265
+ const params = contract.signature?.params ?? [];
266
+
267
+ return contract.binding.map((binding, position) => {
268
+ const param = params[position];
269
+ const undefinable = binding.optional || param?.optional === true || param?.type.undefined === true;
270
+
271
+ return {
272
+ position,
273
+ name: param?.name ?? binding.name,
274
+ type: param?.type.raw ?? null,
275
+ optional: undefinable,
276
+ nullable: param?.type.nullable === true,
277
+ undefinable,
278
+ binding,
279
+ };
280
+ });
281
+ }
282
+
283
+ function collectorsOf(
284
+ parameters: readonly EffectiveParameter[],
285
+ collectorsByType: Map<string, CollectorEntry[]>,
286
+ frond: FrondDescriptor,
287
+ ): EffectiveOperation['collectors'] {
288
+ return parameters.flatMap((parameter) => {
289
+ if (parameter.binding.source.kind !== 'collector') return [];
290
+ const collector = collectorsByType.get(parameter.binding.source.typeName)?.[0];
291
+
292
+ return collector ? [{
293
+ parameter: parameter.name,
294
+ typeName: collector.typeName,
295
+ className: collector.ctor.name,
296
+ frond: frond.name,
297
+ filePath: collector.filePath,
298
+ }] : [];
299
+ });
300
+ }
301
+
302
+ /** A dependency on a frond that actually runs elsewhere is a refusal, not the warning a future split deserves. */
303
+ function placementRefusals(fronds: readonly FrondDescriptor[], options: EffectiveOperationOptions): Diagnostic[] {
304
+ return verify({ fronds }).map((misplaced) => {
305
+ const remoteBoundary = options.remotes?.[misplaced.frond] !== undefined
306
+ || options.remotes?.[misplaced.dependsOn.frond] !== undefined;
307
+
308
+ return remoteBoundary && misplaced.code === 'cross-frond-dependency'
309
+ ? { ...misplaced, severity: 'blocking' as const }
310
+ : misplaced;
311
+ });
312
+ }
313
+
294
314
  function groupedCollectors(collectors: readonly CollectorEntry[]): Map<string, CollectorEntry[]> {
295
315
  const grouped = new Map<string, CollectorEntry[]>();
296
316
  for (const collector of collectors) {
@@ -308,7 +328,7 @@ function normalizeBinding(
308
328
  frond: FrondDescriptor,
309
329
  name: string,
310
330
  diagnostics: Diagnostic[],
311
- ): (OperationContract & { binding: BindingPlan }) | undefined {
331
+ ): BoundContract | undefined {
312
332
  const params = contract.signature?.params ?? [];
313
333
  if (!contract.binding) {
314
334
  if (params.length === 0) return { ...contract, binding: [] };
@@ -567,7 +587,7 @@ function uniqueDiagnostics(diagnostics: readonly Diagnostic[]): Diagnostic[] {
567
587
  }
568
588
 
569
589
  /** The contract of every operation a handler serves — the three producers, merged once. */
570
- export function resolveContracts(
590
+ function resolveContracts(
571
591
  handler: Pick<HandlerEntry, 'ctor' | 'operations'>,
572
592
  overrides: FrondDescriptor['operationsOverrides'],
573
593
  collectorTypeNames: Set<string>,
@@ -1,6 +1,6 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
- import type { AuthConfig } from './boot/AuthConfig.js';
3
+ import type { Extension } from './boot/Extension.js';
4
4
  import type { LogLevel } from './builtin/LogLevel.js';
5
5
  import type { ConventionsInput } from './ConventionsInput.js';
6
6
  import type { NameOf } from './NameOf.js';
@@ -29,30 +29,28 @@ export interface FougereConfig {
29
29
  fronds?: FrondsStated;
30
30
  /** What answers a port — a name, or the chain from the outside in. */
31
31
  ports?: PortChoice;
32
- /** Auth declaration — picks a provider package and forwards options to it. */
33
- auth?: AuthConfig;
32
+ /** The auth provider — an extension, which brings the frond its rows live in. */
33
+ auth?: Extension;
34
34
  /** Which protocol adapters this app serves. */
35
35
  adapters?: AdapterConfig;
36
36
  }
37
37
 
38
38
  const CONFIG_FILES = ['fougere.config.ts', 'fougere.config.js', 'fougere.config.mjs'];
39
39
 
40
+ /** The config file a directory holds, if it holds one. */
41
+ export function configFileIn(dir: string): string | undefined {
42
+ return CONFIG_FILES.map((file) => resolve(dir, file)).find((path) => existsSync(path));
43
+ }
44
+
40
45
  async function loadConfigFrom(dir: string, fresh?: boolean): Promise<FougereConfig> {
41
- const loader = getModuleLoader();
42
- for (const file of CONFIG_FILES) {
43
- const path = resolve(dir, file);
44
- if (existsSync(path)) {
45
- // A module is cached by its specifier, so a second load of an EDITED file hands
46
- // back what was read the first time — measured, and it made re-reading a config
47
- // return the config already in force. The loader owns its own cache, so it is the
48
- // one told; the old module stays in memory, re-reading being for a change.
49
- const mod = await loader(path, fresh ? { fresh } : undefined);
46
+ const path = configFileIn(dir);
47
+ if (!path) return {};
50
48
 
51
- return ((mod as { default?: FougereConfig }).default ?? mod) as FougereConfig;
52
- }
53
- }
49
+ // A module is cached by its specifier, so a second load of an EDITED file hands back what
50
+ // was read the first time. The loader owns its own cache, so it is the one told.
51
+ const mod = await getModuleLoader()(path, fresh ? { fresh } : undefined);
54
52
 
55
- return {};
53
+ return ((mod as { default?: FougereConfig }).default ?? mod) as FougereConfig;
56
54
  }
57
55
 
58
56
  /** Load the root fougere.config.{ts,js,mjs} from the given directory. */
@@ -1,4 +1,5 @@
1
1
  import { lowerFirst, type SchemaView } from '@fougere/schema';
2
+ import { addressOf } from './wire/Facade.js';
2
3
  import type { CollectorEntry } from './descriptor/CollectorEntry.js';
3
4
  import type { EntityEntry } from './descriptor/EntityEntry.js';
4
5
  import type { FrondDescriptor } from './descriptor/FrondDescriptor.js';
@@ -54,13 +55,6 @@ function statedOperations(h: Ctor | DeclaredHandler): Map<string, OperationContr
54
55
  return merged;
55
56
  }
56
57
 
57
- /** `PostHandler` answers at `post` — the same rule the scan applies to a file it found. */
58
- function addressOf(className: string): string {
59
- const base = className.endsWith('Handler') ? className.slice(0, -7) : className;
60
-
61
- return lowerFirst(base);
62
- }
63
-
64
58
  /** What a declaration states about one frond. Everything else is derived from the classes. */
65
59
  export interface FrondDeclaration {
66
60
  entities?: SchemaView[];
package/src/Source.ts CHANGED
@@ -8,7 +8,7 @@ import type { SourceConfig } from './SourceConfig.js';
8
8
  export function declares(schema: SchemaView, constraint: Constraint): boolean {
9
9
  const fields = Object.values(schema.getFields());
10
10
  if (constraint === 'relation') return fields.some((field) => Role.of(field).isReference());
11
- if ((schema.getUnique() ?? []).length > 0) return true;
11
+ if (schema.getUnique().length > 0) return true;
12
12
 
13
13
  return fields.some((field) => Role.of(field).isUnique());
14
14
  }
package/src/SourceView.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  /** The app as ONE source sees it. */
2
2
  export interface SourceView {
3
3
  fronds: readonly { readonly name: string; readonly entities: readonly { readonly name: string }[] }[];
4
- /** The auth provider's own entities, when they ride with this source. */
5
- auth?: unknown;
6
4
  elsewhere: readonly string[];
7
5
  }
package/src/boot/App.ts CHANGED
@@ -3,10 +3,10 @@ import type { Fronds } from '../descriptor/Fronds.js';
3
3
  import type { SchemaView } from '@fougere/schema';
4
4
  import type { AppMiddleware } from '../wire/AppMiddleware.js';
5
5
  import type { RpcAnswer } from '../wire/RpcAnswer.js';
6
- import type { AuthRuntime } from './AuthRuntime.js';
7
6
  import type { EffectiveOperationsMap } from '../EffectiveOperationsMap.js';
8
7
  import type { DispatchObserver } from '../dispatch/DispatchObserver.js';
9
8
  import type { DispatchPort } from '../dispatch/DispatchPort.js';
9
+ import type { Storage } from '../storage/Storage.js';
10
10
 
11
11
  /** The App object returned by createApp(). */
12
12
  export interface App extends DispatchPort {
@@ -42,7 +42,7 @@ export interface App extends DispatchPort {
42
42
  * The storage an entity is backed by, resolved through its owning frond's scope — the dual of
43
43
  * {@link facadeFor}.
44
44
  */
45
- storageFor(entity: string): unknown | undefined;
45
+ storageFor<T = Record<string, unknown>>(entity: string): Storage<T> | undefined;
46
46
  /** The presenter of an entity, resolved through its owning frond's scope. */
47
47
  presenterFor(entity: string): unknown | undefined;
48
48
  /** Dispose the root container. */
@@ -66,6 +66,4 @@ export interface App extends DispatchPort {
66
66
  use(middleware: AppMiddleware): void;
67
67
  /** Register an app middleware scoped to a specific entity. */
68
68
  use(entity: string, middleware: AppMiddleware): void;
69
- /** Auth runtime, present when fougere.config.ts declares `auth`. */
70
- auth?: AuthRuntime;
71
69
  }
@@ -25,18 +25,29 @@ export class AppLifecycle {
25
25
  }
26
26
 
27
27
  async down(app: App): Promise<void> {
28
- const refused: unknown[] = [];
29
- for (const extension of [...this.members].reverse()) {
30
- try {
31
- await extension.down?.(app);
32
- } catch (error) {
33
- refused.push(error);
34
- }
35
- }
36
- if (refused.length > 0) {
37
- throw new AggregateError(refused, `${refused.length} extension(s) refused to release`);
28
+ await closeAll(
29
+ [...this.members].reverse().map((extension) => () => extension.down?.(app)),
30
+ 'extension(s) refused to release',
31
+ );
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Every level told to close even when one refuses, the refusals leaving together in one
37
+ * `AggregateError` — one already carried by a level is flattened into the list.
38
+ */
39
+ export async function closeAll(levels: readonly (() => unknown)[], refusals: string): Promise<void> {
40
+ const refused: unknown[] = [];
41
+ for (const level of levels) {
42
+ try {
43
+ await level();
44
+ } catch (error) {
45
+ if (error instanceof AggregateError) refused.push(...error.errors);
46
+ else refused.push(error);
38
47
  }
39
48
  }
49
+
50
+ if (refused.length > 0) throw new AggregateError(refused, `${refused.length} ${refusals}`);
40
51
  }
41
52
 
42
53
  /** The replaceable migration slot of the application lifecycle. */