@fougere/core 0.12.0-alpha.0 → 0.12.2-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 (236) hide show
  1. package/dist/CallIdentity.d.ts.map +1 -1
  2. package/dist/CallIdentity.js.map +1 -1
  3. package/dist/DomainCluster.d.ts.map +1 -1
  4. package/dist/DomainCluster.js +1 -1
  5. package/dist/DomainCluster.js.map +1 -1
  6. package/dist/EffectiveOperationModel.d.ts.map +1 -1
  7. package/dist/EffectiveOperationModel.js +20 -1
  8. package/dist/EffectiveOperationModel.js.map +1 -1
  9. package/dist/FougereConfig.d.ts.map +1 -1
  10. package/dist/FougereConfig.js.map +1 -1
  11. package/dist/FrondConfig.d.ts.map +1 -1
  12. package/dist/FrondConfig.js.map +1 -1
  13. package/dist/Source.js +2 -2
  14. package/dist/Source.js.map +1 -1
  15. package/dist/boot/AppLifecycle.d.ts.map +1 -1
  16. package/dist/boot/AppLifecycle.js.map +1 -1
  17. package/dist/boot/Emissions.d.ts.map +1 -1
  18. package/dist/boot/Emissions.js.map +1 -1
  19. package/dist/boot/ambient.als.d.ts.map +1 -1
  20. package/dist/boot/ambient.als.js.map +1 -1
  21. package/dist/boot/apply.d.ts.map +1 -1
  22. package/dist/boot/apply.js.map +1 -1
  23. package/dist/boot/arity.d.ts +13 -0
  24. package/dist/boot/arity.d.ts.map +1 -0
  25. package/dist/boot/arity.js +33 -0
  26. package/dist/boot/arity.js.map +1 -0
  27. package/dist/boot/bootstrap.d.ts.map +1 -1
  28. package/dist/boot/bootstrap.js +3 -2
  29. package/dist/boot/bootstrap.js.map +1 -1
  30. package/dist/boot/card.d.ts.map +1 -1
  31. package/dist/boot/card.js.map +1 -1
  32. package/dist/boot/frame.d.ts.map +1 -1
  33. package/dist/boot/frame.js.map +1 -1
  34. package/dist/boot/install.d.ts.map +1 -1
  35. package/dist/boot/install.js +8 -2
  36. package/dist/boot/install.js.map +1 -1
  37. package/dist/boot/ownership.d.ts.map +1 -1
  38. package/dist/boot/ownership.js.map +1 -1
  39. package/dist/boot/relations.d.ts +10 -0
  40. package/dist/boot/relations.d.ts.map +1 -1
  41. package/dist/boot/relations.js +35 -2
  42. package/dist/boot/relations.js.map +1 -1
  43. package/dist/boot/remote.d.ts.map +1 -1
  44. package/dist/boot/remote.js +8 -2
  45. package/dist/boot/remote.js.map +1 -1
  46. package/dist/boot/seed.js +1 -1
  47. package/dist/boot/seed.js.map +1 -1
  48. package/dist/boot/statement-drift.d.ts +6 -0
  49. package/dist/boot/statement-drift.d.ts.map +1 -1
  50. package/dist/boot/statement-drift.js +19 -0
  51. package/dist/boot/statement-drift.js.map +1 -1
  52. package/dist/boot/together.d.ts.map +1 -1
  53. package/dist/boot/together.js.map +1 -1
  54. package/dist/builtin/Carry.d.ts.map +1 -1
  55. package/dist/builtin/Carry.js.map +1 -1
  56. package/dist/builtin/Logger.d.ts.map +1 -1
  57. package/dist/builtin/Logger.js +3 -1
  58. package/dist/builtin/Logger.js.map +1 -1
  59. package/dist/builtin/config.js.map +1 -1
  60. package/dist/contract.d.ts +2 -0
  61. package/dist/contract.d.ts.map +1 -1
  62. package/dist/contract.js +2 -0
  63. package/dist/contract.js.map +1 -1
  64. package/dist/crypto/encoding.d.ts.map +1 -1
  65. package/dist/crypto/encoding.js.map +1 -1
  66. package/dist/crypto/node.d.ts.map +1 -1
  67. package/dist/crypto/node.js.map +1 -1
  68. package/dist/crypto/webcrypto.d.ts.map +1 -1
  69. package/dist/crypto/webcrypto.js.map +1 -1
  70. package/dist/descriptor/Fronds.d.ts.map +1 -1
  71. package/dist/descriptor/Fronds.js.map +1 -1
  72. package/dist/dispatch/ArrayResult.d.ts.map +1 -1
  73. package/dist/dispatch/ArrayResult.js.map +1 -1
  74. package/dist/dispatch/Dispatcher.d.ts.map +1 -1
  75. package/dist/dispatch/Dispatcher.js.map +1 -1
  76. package/dist/dispatch/HandlerFacade.d.ts +6 -1
  77. package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
  78. package/dist/dispatch/HandlerFacade.js +11 -3
  79. package/dist/dispatch/HandlerFacade.js.map +1 -1
  80. package/dist/dispatch/InFlight.d.ts.map +1 -1
  81. package/dist/dispatch/InFlight.js.map +1 -1
  82. package/dist/dispatch/LocalRoutePolicy.d.ts.map +1 -1
  83. package/dist/dispatch/LocalRoutePolicy.js.map +1 -1
  84. package/dist/dispatch/OutputView.d.ts.map +1 -1
  85. package/dist/dispatch/OutputView.js +7 -0
  86. package/dist/dispatch/OutputView.js.map +1 -1
  87. package/dist/dispatch/PresenterExecutor.d.ts.map +1 -1
  88. package/dist/dispatch/PresenterExecutor.js +7 -0
  89. package/dist/dispatch/PresenterExecutor.js.map +1 -1
  90. package/dist/dispatch/Received.d.ts +3 -0
  91. package/dist/dispatch/Received.d.ts.map +1 -0
  92. package/dist/dispatch/Received.js +2 -0
  93. package/dist/dispatch/Received.js.map +1 -0
  94. package/dist/dispatch/RouteRegistry.d.ts.map +1 -1
  95. package/dist/dispatch/RouteRegistry.js.map +1 -1
  96. package/dist/dispatch/StorageGuard.d.ts.map +1 -1
  97. package/dist/dispatch/StorageGuard.js +4 -1
  98. package/dist/dispatch/StorageGuard.js.map +1 -1
  99. package/dist/dispatch/decoded.d.ts +19 -0
  100. package/dist/dispatch/decoded.d.ts.map +1 -0
  101. package/dist/dispatch/decoded.js +36 -0
  102. package/dist/dispatch/decoded.js.map +1 -0
  103. package/dist/entry/facade.d.ts +9 -2
  104. package/dist/entry/facade.d.ts.map +1 -1
  105. package/dist/entry/facade.js +16 -7
  106. package/dist/entry/facade.js.map +1 -1
  107. package/dist/identity-keys.d.ts.map +1 -1
  108. package/dist/identity-keys.js.map +1 -1
  109. package/dist/index.d.ts +1 -0
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +1 -0
  112. package/dist/index.js.map +1 -1
  113. package/dist/loader.d.ts.map +1 -1
  114. package/dist/loader.js.map +1 -1
  115. package/dist/prefab/CrudConstructor.d.ts.map +1 -1
  116. package/dist/prefab/CrudConstructor.js +3 -2
  117. package/dist/prefab/CrudConstructor.js.map +1 -1
  118. package/dist/prefab/CrudOps.d.ts +2 -2
  119. package/dist/prefab/CrudOps.d.ts.map +1 -1
  120. package/dist/prefab/MirrorConstructor.js +1 -1
  121. package/dist/prefab/MirrorConstructor.js.map +1 -1
  122. package/dist/prefab/Refreshed.d.ts +7 -2
  123. package/dist/prefab/Refreshed.d.ts.map +1 -1
  124. package/dist/prefab/collector.d.ts.map +1 -1
  125. package/dist/prefab/collector.js.map +1 -1
  126. package/dist/prefab/presenter.d.ts.map +1 -1
  127. package/dist/prefab/presenter.js.map +1 -1
  128. package/dist/storage/Storage.d.ts.map +1 -1
  129. package/dist/storage/Storage.js.map +1 -1
  130. package/dist/storage/Store.d.ts.map +1 -1
  131. package/dist/storage/Store.js +7 -11
  132. package/dist/storage/Store.js.map +1 -1
  133. package/dist/verify.d.ts.map +1 -1
  134. package/dist/verify.js.map +1 -1
  135. package/dist/wire/AppMiddleware.d.ts +10 -1
  136. package/dist/wire/AppMiddleware.d.ts.map +1 -1
  137. package/dist/wire/AppMiddleware.js.map +1 -1
  138. package/dist/wire/AppNext.d.ts +2 -1
  139. package/dist/wire/AppNext.d.ts.map +1 -1
  140. package/dist/wire/Facade.d.ts +14 -2
  141. package/dist/wire/Facade.d.ts.map +1 -1
  142. package/dist/wire/Facade.js.map +1 -1
  143. package/dist/wire/FougereError.d.ts.map +1 -1
  144. package/dist/wire/FougereError.js +1 -1
  145. package/dist/wire/FougereError.js.map +1 -1
  146. package/dist/wire/Invocation.d.ts.map +1 -1
  147. package/dist/wire/Invocation.js.map +1 -1
  148. package/dist/wire/OperationContract.d.ts.map +1 -1
  149. package/dist/wire/OperationContract.js +1 -1
  150. package/dist/wire/OperationContract.js.map +1 -1
  151. package/dist/wire/Page.d.ts +32 -0
  152. package/dist/wire/Page.d.ts.map +1 -0
  153. package/dist/wire/Page.js +32 -0
  154. package/dist/wire/Page.js.map +1 -0
  155. package/dist/wire/RouteAddress.d.ts.map +1 -1
  156. package/dist/wire/RouteAddress.js.map +1 -1
  157. package/dist/wire/Signature.d.ts +7 -0
  158. package/dist/wire/Signature.d.ts.map +1 -1
  159. package/dist/wire/binding.d.ts.map +1 -1
  160. package/dist/wire/binding.js.map +1 -1
  161. package/dist/wire/card/IdentityCard.d.ts.map +1 -1
  162. package/dist/wire/card/IdentityCard.js.map +1 -1
  163. package/dist/wire/drift.d.ts.map +1 -1
  164. package/dist/wire/drift.js.map +1 -1
  165. package/dist/wire/http-error.d.ts.map +1 -1
  166. package/dist/wire/http-error.js.map +1 -1
  167. package/dist/wire/loggerMiddleware.d.ts.map +1 -1
  168. package/dist/wire/loggerMiddleware.js.map +1 -1
  169. package/package.json +5 -5
  170. package/src/CallIdentity.ts +6 -0
  171. package/src/DomainCluster.ts +2 -1
  172. package/src/EffectiveOperationModel.ts +38 -1
  173. package/src/FougereConfig.ts +3 -0
  174. package/src/FrondConfig.ts +2 -0
  175. package/src/Source.ts +2 -2
  176. package/src/boot/AppLifecycle.ts +1 -0
  177. package/src/boot/Emissions.ts +4 -0
  178. package/src/boot/ambient.als.ts +2 -0
  179. package/src/boot/apply.ts +1 -0
  180. package/src/boot/arity.ts +42 -0
  181. package/src/boot/bootstrap.ts +9 -2
  182. package/src/boot/card.ts +2 -0
  183. package/src/boot/frame.ts +6 -0
  184. package/src/boot/install.ts +8 -1
  185. package/src/boot/ownership.ts +1 -0
  186. package/src/boot/relations.ts +39 -2
  187. package/src/boot/remote.ts +10 -2
  188. package/src/boot/seed.ts +1 -1
  189. package/src/boot/statement-drift.ts +25 -0
  190. package/src/boot/together.ts +3 -0
  191. package/src/builtin/Carry.ts +1 -0
  192. package/src/builtin/Logger.ts +9 -1
  193. package/src/builtin/config.ts +1 -0
  194. package/src/contract.ts +3 -0
  195. package/src/crypto/encoding.ts +4 -0
  196. package/src/crypto/node.ts +2 -0
  197. package/src/crypto/webcrypto.ts +2 -0
  198. package/src/descriptor/Fronds.ts +2 -0
  199. package/src/dispatch/ArrayResult.ts +1 -0
  200. package/src/dispatch/Dispatcher.ts +1 -0
  201. package/src/dispatch/HandlerFacade.ts +16 -4
  202. package/src/dispatch/InFlight.ts +2 -0
  203. package/src/dispatch/LocalRoutePolicy.ts +1 -0
  204. package/src/dispatch/OutputView.ts +7 -0
  205. package/src/dispatch/PresenterExecutor.ts +7 -0
  206. package/src/dispatch/Received.ts +2 -0
  207. package/src/dispatch/RouteRegistry.ts +4 -0
  208. package/src/dispatch/StorageGuard.ts +9 -1
  209. package/src/dispatch/decoded.ts +37 -0
  210. package/src/entry/facade.ts +21 -9
  211. package/src/identity-keys.ts +2 -0
  212. package/src/index.ts +1 -0
  213. package/src/loader.ts +1 -0
  214. package/src/prefab/CrudConstructor.ts +4 -2
  215. package/src/prefab/CrudOps.ts +2 -2
  216. package/src/prefab/MirrorConstructor.ts +1 -1
  217. package/src/prefab/Refreshed.ts +7 -2
  218. package/src/prefab/collector.ts +1 -0
  219. package/src/prefab/presenter.ts +1 -0
  220. package/src/storage/Storage.ts +2 -0
  221. package/src/storage/Store.ts +15 -10
  222. package/src/verify.ts +2 -0
  223. package/src/wire/AppMiddleware.ts +12 -1
  224. package/src/wire/AppNext.ts +2 -1
  225. package/src/wire/Facade.ts +14 -2
  226. package/src/wire/FougereError.ts +3 -1
  227. package/src/wire/Invocation.ts +2 -0
  228. package/src/wire/OperationContract.ts +2 -1
  229. package/src/wire/Page.ts +50 -0
  230. package/src/wire/RouteAddress.ts +2 -0
  231. package/src/wire/Signature.ts +7 -0
  232. package/src/wire/binding.ts +1 -0
  233. package/src/wire/card/IdentityCard.ts +1 -0
  234. package/src/wire/drift.ts +3 -0
  235. package/src/wire/http-error.ts +2 -0
  236. package/src/wire/loggerMiddleware.ts +1 -0
@@ -1,5 +1,5 @@
1
1
  import { lowerFirst, type SchemaView } from '@fougere/schema';
2
- import { statementDrift } from './boot/statement-drift.js';
2
+ import { statementDrift, unansweredOperations } from './boot/statement-drift.js';
3
3
  import { reachedBy, servedBy } from './boot/declared.js';
4
4
  import { computeBindingPlan, type BindingPlan } from './wire/binding.js';
5
5
  import { targetOf } from './prefab/prefab.js';
@@ -111,12 +111,32 @@ export function resolveEffectiveOperations(
111
111
  // A statement wins over the scan on purpose; saying so out loud is what keeps the
112
112
  // win from hiding a rename. Compared here, where both readings are in hand.
113
113
  resolutionDiagnostics.push(...statementDrift(frond, handler));
114
+ resolutionDiagnostics.push(...unansweredOperations(frond, handler, contracts.keys()));
114
115
 
115
116
  for (const [name, rawContract] of contracts) {
116
117
  const subject = `${handler.ctor.name}.${name}`;
117
118
  const contract = normalizeBinding(rawContract, handler, frond, name, resolutionDiagnostics);
118
119
  if (!contract) continue;
119
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
+
120
140
  const override = frond.operationsOverrides?.[name];
121
141
  const inference = inferOperationKind(name);
122
142
  const inferredKinds = new Set<OperationKind>();
@@ -155,6 +175,7 @@ export function resolveEffectiveOperations(
155
175
  const undefinable = binding.optional
156
176
  || param?.optional === true
157
177
  || param?.type.undefined === true;
178
+
158
179
  return {
159
180
  position,
160
181
  name: param?.name ?? binding.name,
@@ -219,6 +240,7 @@ export function resolveEffectiveOperations(
219
240
  collectors: parameters.flatMap((parameter) => {
220
241
  if (parameter.binding.source.kind !== 'collector') return [];
221
242
  const collector = collectorsByType.get(parameter.binding.source.typeName)?.[0];
243
+
222
244
  return collector ? [{
223
245
  parameter: parameter.name,
224
246
  typeName: collector.typeName,
@@ -260,6 +282,7 @@ export function resolveEffectiveOperations(
260
282
  }
261
283
 
262
284
  const resolution = uniqueDiagnostics(resolutionDiagnostics);
285
+
263
286
  return new EffectiveOperationModel(
264
287
  operations,
265
288
  uniqueDiagnostics([...scanDiagnostics, ...resolution]),
@@ -275,6 +298,7 @@ function groupedCollectors(collectors: readonly CollectorEntry[]): Map<string, C
275
298
  sameName.push(collector);
276
299
  grouped.set(collector.typeName, sameName);
277
300
  }
301
+
278
302
  return grouped;
279
303
  }
280
304
 
@@ -296,6 +320,7 @@ function normalizeBinding(
296
320
  subject: `${handler.ctor.name}.${name}`,
297
321
  message: `${handler.ctor.name}.${name} declares ${params.length} parameter(s) and has no binding plan.`,
298
322
  });
323
+
299
324
  return undefined;
300
325
  }
301
326
  if (params.length === 0) return { ...contract, binding: contract.binding };
@@ -322,8 +347,10 @@ function normalizeBinding(
322
347
  + `${invalid.length ? `Invalid: ${invalid.map((param) => param.name).join(', ')}. ` : ''}`
323
348
  + `${extras.length ? `Unknown: ${extras.join(', ')}.` : ''}`,
324
349
  });
350
+
325
351
  return undefined;
326
352
  }
353
+
327
354
  return {
328
355
  ...contract,
329
356
  binding: params.map((param) => byName.get(param.name)![0]!),
@@ -368,6 +395,7 @@ function validateProvenance(
368
395
  + `${matches.map((parameter) => `${parameter.name}: ${parameter.type ?? 'unknown'}`).join('; ')} `
369
396
  + 'all match the resolved input contract. Declare an explicit binding plan.',
370
397
  });
398
+
371
399
  return false;
372
400
  }
373
401
  if (matches.length === 1) inferredBody.add(matches[0]!);
@@ -429,6 +457,7 @@ function validateProvenance(
429
457
  + 'local collector or resolved input schema. Declare an explicit binding.',
430
458
  });
431
459
  }
460
+
432
461
  return valid;
433
462
  }
434
463
 
@@ -439,6 +468,7 @@ function structural(type: TypeRef | undefined): boolean {
439
468
  function schemaMatches(schema: SchemaView, type: TypeRef | undefined): boolean {
440
469
  const names = new Set([schema.name, schema.derivation?.sourceName].filter(Boolean));
441
470
  if (type?.name && names.has(type.name)) return true;
471
+
442
472
  return type?.generics?.some((generic) => generic.name && names.has(generic.name)) ?? false;
443
473
  }
444
474
 
@@ -462,6 +492,7 @@ function implementationOf(
462
492
  message: `Operation ${handler.ctor.name}.${name} names handler '${override?.handlerName}', `
463
493
  + `but ${candidates.length} matching handlers were found.`,
464
494
  });
495
+
465
496
  return undefined;
466
497
  }
467
498
  const implementation = candidates[0]!;
@@ -479,8 +510,10 @@ function implementationOf(
479
510
  message: `Operation ${handler.ctor.name}.${name} resolves to `
480
511
  + `${implementation.ctor.name}.${method}, but that method does not exist.`,
481
512
  });
513
+
482
514
  return undefined;
483
515
  }
516
+
484
517
  return {
485
518
  className: implementation.ctor.name,
486
519
  address: implementation.address,
@@ -505,6 +538,7 @@ function effectiveOutput(
505
538
  const target = targetOf(handler.ctor) as { name?: string } | undefined;
506
539
  const address = target?.name ? lowerFirst(target.name) : handler.address;
507
540
  const entity = frond.entities.find((candidate) => candidate.name === address);
541
+
508
542
  return { ...(entity ? { schema: entity.entityClass } : {}), closed: false };
509
543
  }
510
544
 
@@ -513,6 +547,7 @@ function exposedAdapters(
513
547
  adapters: Record<string, boolean | undefined> | undefined,
514
548
  ): string[] {
515
549
  if (handler.exposed === false && !handler.surface) return [];
550
+
516
551
  return Object.entries(adapters ?? {})
517
552
  .filter(([, enabled]) => enabled === true)
518
553
  .map(([adapter]) => adapter)
@@ -521,10 +556,12 @@ function exposedAdapters(
521
556
 
522
557
  function uniqueDiagnostics(diagnostics: readonly Diagnostic[]): Diagnostic[] {
523
558
  const seen = new Set<string>();
559
+
524
560
  return diagnostics.filter((diagnostic) => {
525
561
  const key = [diagnostic.code, diagnostic.filePath, diagnostic.subject, diagnostic.message].join('\0');
526
562
  if (seen.has(key)) return false;
527
563
  seen.add(key);
564
+
528
565
  return true;
529
566
  });
530
567
  }
@@ -47,9 +47,11 @@ async function loadConfigFrom(dir: string, fresh?: boolean): Promise<FougereConf
47
47
  // return the config already in force. The loader owns its own cache, so it is the
48
48
  // one told; the old module stays in memory, re-reading being for a change.
49
49
  const mod = await loader(path, fresh ? { fresh } : undefined);
50
+
50
51
  return ((mod as { default?: FougereConfig }).default ?? mod) as FougereConfig;
51
52
  }
52
53
  }
54
+
53
55
  return {};
54
56
  }
55
57
 
@@ -92,5 +94,6 @@ function mergeGlobal(base: FougereConfig, override: Partial<FougereConfig>): Fou
92
94
  export async function loadCascadedConfig(workspaceRoot: string, appRoot: string): Promise<FougereConfig> {
93
95
  const base = await loadConfig(workspaceRoot);
94
96
  if (resolve(workspaceRoot) === resolve(appRoot)) return base;
97
+
95
98
  return mergeGlobal(base, await loadConfig(appRoot));
96
99
  }
@@ -44,8 +44,10 @@ export async function loadFrondConfig(frondPath: string): Promise<FrondConfig |
44
44
  const path = resolve(frondPath, file);
45
45
  if (existsSync(path)) {
46
46
  const mod = await import(path);
47
+
47
48
  return mod.default ?? mod;
48
49
  }
49
50
  }
51
+
50
52
  return undefined;
51
53
  }
package/src/Source.ts CHANGED
@@ -7,10 +7,10 @@ import type { SourceConfig } from './SourceConfig.js';
7
7
  /** Whether an entity asks for one, anywhere but on its key. */
8
8
  export function declares(schema: SchemaView, constraint: Constraint): boolean {
9
9
  const fields = Object.values(schema.getFields());
10
- if (constraint === 'relation') return fields.some((field) => Role.of(field).isReference);
10
+ if (constraint === 'relation') return fields.some((field) => Role.of(field).isReference());
11
11
  if ((schema.getUnique() ?? []).length > 0) return true;
12
12
 
13
- return fields.some((field) => Role.of(field).isUnique);
13
+ return fields.some((field) => Role.of(field).isUnique());
14
14
  }
15
15
 
16
16
  /**
@@ -12,6 +12,7 @@ export class AppLifecycle {
12
12
  if (at === -1) this.members.push(extension);
13
13
  else this.members[at] = extension;
14
14
  }
15
+
15
16
  return this;
16
17
  }
17
18
 
@@ -318,6 +318,7 @@ export class Emissions {
318
318
  */
319
319
  private stamped(fact: string, raw: unknown): unknown {
320
320
  const shape = this.shapes.get(fact);
321
+
321
322
  return shape && raw !== null && typeof raw === 'object' && !Array.isArray(raw)
322
323
  ? applyCreate(shape.getFields(), raw as Record<string, unknown>)
323
324
  : raw;
@@ -337,6 +338,7 @@ export class Emissions {
337
338
  const listeners = this.subscribers.get(fact) ?? [];
338
339
  if (listeners.length === 0) {
339
340
  this.log.debug(`${fact} — nobody listens in this process`);
341
+
340
342
  return [];
341
343
  }
342
344
 
@@ -350,6 +352,7 @@ export class Emissions {
350
352
  } catch (cause) {
351
353
  throw new Error(`${fact} → ${facade} could not be reached`, { cause });
352
354
  }
355
+
353
356
  return handler[op]({ ...Invocation.empty, input: payload });
354
357
  }),
355
358
  }));
@@ -359,6 +362,7 @@ export class Emissions {
359
362
  private describeRefusal(fact: string, cause: unknown): string | undefined {
360
363
  const refusals = validationErrorsOf(cause);
361
364
  if (!refusals?.length) return undefined;
365
+
362
366
  return `refused the shape — ${refusals.map((d) => `${dotted(d.path)}: ${d.message}`).join(', ')}.`
363
367
  + ` If '${fact}' gained a field, this copy is older than the sender's: re-run \`fougere sync\`.`;
364
368
  }
@@ -17,12 +17,14 @@ export const ambient: Ambient = {
17
17
  + `are ONE frame — declare a single Together naming every member.`,
18
18
  ));
19
19
  }
20
+
20
21
  return frame.run(key, fn);
21
22
  },
22
23
 
23
24
  beforeAnnounce(fact: string): Promise<void> {
24
25
  const open = frame.getStore();
25
26
  if (open === undefined) return Promise.resolve();
27
+
26
28
  return Promise.reject(new Error(
27
29
  `${fact} cannot be announced inside Together<[…]> (${open}): announcing is dispatch, `
28
30
  + `so subscribers and the carrier would have it while these writes can still be taken `
package/src/boot/apply.ts CHANGED
@@ -40,5 +40,6 @@ export function applyConfig(next: FougereConfig, inForce?: FougereConfig): Confi
40
40
  pending.push(key);
41
41
  }
42
42
  }
43
+
43
44
  return { applied, pending };
44
45
  }
@@ -0,0 +1,42 @@
1
+ import type { Diagnostic } from '../diagnostic.js';
2
+ import type { FrondDescriptor } from '../descriptor/FrondDescriptor.js';
3
+
4
+ interface Constructed {
5
+ ctor: { name: string; length: number };
6
+ deps: string[];
7
+ filePath: string;
8
+ }
9
+
10
+ /**
11
+ * What the container hands a constructor is `deps`, one argument per key and in order — so a
12
+ * constructor asking for more is handed `undefined`. The scan reads `deps` off the signature and
13
+ * cannot miss; a frond declared by hand can, and `Function.length` is the one thing a
14
+ * constructor says about itself once the types are gone.
15
+ *
16
+ * `length` is a floor and never a count: a class that writes no constructor inherits
17
+ * `(...args) => super(...args)`, which is 0, while the scan read its parent's dependencies.
18
+ */
19
+ export function refuseArityDrift(frond: FrondDescriptor, refused: Diagnostic[]): void {
20
+ const constructed: Constructed[] = [
21
+ ...frond.handlers,
22
+ ...frond.presenters,
23
+ ...frond.collectors,
24
+ ...frond.providers,
25
+ ...frond.middlewares,
26
+ ];
27
+
28
+ for (const { ctor, deps, filePath } of constructed) {
29
+ if (ctor.length <= deps.length) continue;
30
+
31
+ refused.push({
32
+ severity: 'blocking',
33
+ code: 'constructor-arity',
34
+ filePath,
35
+ frond: frond.name,
36
+ subject: ctor.name,
37
+ message: `${ctor.name}'s constructor takes ${ctor.length} parameter(s) and ${deps.length} `
38
+ + `dependenc${deps.length === 1 ? 'y is' : 'ies are'} declared (${deps.join(', ') || 'none'}) — `
39
+ + 'the rest would be handed `undefined`.',
40
+ });
41
+ }
42
+ }
@@ -7,7 +7,7 @@ import { hostedBy } from './hosted.js';
7
7
  import { nestingOf, parentsFirst } from './nesting.js';
8
8
  import type { Fronds } from '../descriptor/Fronds.js';
9
9
  import { installFrond, type Assembly } from './install.js';
10
- import { dependentsOf, releasing, unfinishable, unheldAmong } from './relations.js';
10
+ import { dependentsOf, releasing, unfinishable, unheldAmong, unpaired } from './relations.js';
11
11
  import { release as releaseRow } from '../dispatch/Release.js';
12
12
  import type { Hosting } from './Hosting.js';
13
13
  import { peerOver } from './peerOver.js';
@@ -448,6 +448,7 @@ function readings(
448
448
  + `Call its operations through the façade instead.`,
449
449
  );
450
450
  }
451
+
451
452
  return route.schema;
452
453
  }
453
454
  throw new Error(notLoaded(entity));
@@ -489,6 +490,7 @@ function readings(
489
490
  const owner = fronds.owner(entity);
490
491
  if (!owner) {
491
492
  sayNoSurfaceAcross(entity, surface);
493
+
492
494
  return own;
493
495
  }
494
496
 
@@ -498,6 +500,7 @@ function readings(
498
500
  if (own) return own;
499
501
 
500
502
  const fallback = facadeAt(facadeKeyOf(entity), false);
503
+
501
504
  return fallback
502
505
  ? facadeOperations(
503
506
  localDispatcher,
@@ -515,6 +518,7 @@ function readings(
515
518
  const own = effectiveByKey.get(facadeKeyOf(entity, surface));
516
519
  const declared = fronds.owner(entity)?.surfaces?.[surface];
517
520
  if (!declared) return own;
521
+
518
522
  return declared.some((name) => name.toLowerCase() === entity.toLowerCase())
519
523
  ? (own ?? effectiveByKey.get(facadeKeyOf(entity)))
520
524
  : undefined;
@@ -699,6 +703,7 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
699
703
  for (const frond of fronds) await installFrond(frond, assembly);
700
704
 
701
705
  warnAboutRelations(relations, hosting, log);
706
+ refused.push(...unpaired(fronds.flatMap((frond) => frond.entities), hosting));
702
707
 
703
708
  // Every check of the install, said at once — a boot that stops at the first makes the
704
709
  // next one visible only after a fix and a restart.
@@ -735,6 +740,7 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
735
740
  container.setFallback?.((name) => {
736
741
  if (!remoteRouter) return undefined;
737
742
  if (!name.endsWith('Handler') || name.includes(':')) return undefined;
743
+
738
744
  // Façade-shaped stand-in; routing happens lazily at the first call. Through
739
745
  // `lowerFirst` because a DEPENDENCY names the type as written — `ProductHandler`,
740
746
  // PascalCase — while a card declares `product`, so the raw strip asked the router for
@@ -752,6 +758,7 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
752
758
  if (known) return known;
753
759
  const facade = createRemoteFacade(entity, remoteRouter, getMiddlewares);
754
760
  remoteFacades.set(entity, facade);
761
+
755
762
  return facade;
756
763
  }));
757
764
  }
@@ -769,7 +776,7 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
769
776
  // Where a call goes, as DECLARED. Kept because a reader needs it beside what the
770
777
  // runtime OBSERVED — `rpc.topology` calls a frond remote because it answered, never
771
778
  // because a key said so, and the two disagree exactly when something is misconfigured.
772
- remotes: Object.freeze({ ...(options.remotes ?? {}) }),
779
+ remotes: Object.freeze({ ...options.remotes }),
773
780
  dispatch: (call) => dispatcher.dispatch(call),
774
781
  local: localDispatcher,
775
782
  resolve,
package/src/boot/card.ts CHANGED
@@ -37,6 +37,7 @@ export function identityCardOf(app: App, surface?: string): IdentityCard {
37
37
  const ops = facadeOps(app, address, surface);
38
38
  if (ops.length === 0) return [];
39
39
  const entity = byEntity.get(address);
40
+
40
41
  return [{
41
42
  name: address,
42
43
  ops,
@@ -47,6 +48,7 @@ export function identityCardOf(app: App, surface?: string): IdentityCard {
47
48
  /** What leaves on its own — the same list on every surface, deliberately. */
48
49
  facts: factsAnnouncedBy(frond.handlers).map((name) => {
49
50
  const entityClass = declared.get(name);
51
+
50
52
  return { name, ...(entityClass ? { schema: Card.fromSchema(entityClass, name).descriptor } : {}) };
51
53
  }),
52
54
  };
package/src/boot/frame.ts CHANGED
@@ -83,6 +83,7 @@ function recordCreate({ base, recorded, entity, key, journal }: Recording): void
83
83
  const row = await base.create.apply(this, args);
84
84
  const id = String(row[key]);
85
85
  journal.push({ what: `create ${entity}#${id}`, run: () => base.delete.call(this, id).then(() => undefined) });
86
+
86
87
  return row;
87
88
  };
88
89
  }
@@ -119,6 +120,7 @@ function recordUpdate({ base, recorded, entity, journal }: Recording): void {
119
120
  await base.update.call(this, id, pick(before, touched));
120
121
  },
121
122
  });
123
+
122
124
  return row;
123
125
  };
124
126
  }
@@ -131,6 +133,7 @@ function recordDelete({ base, recorded, entity, journal }: Recording): void {
131
133
  if (removed && before) {
132
134
  journal.push({ what: `delete ${entity}#${id}`, run: () => base.create.call(this, before).then(() => undefined) });
133
135
  }
136
+
134
137
  return removed;
135
138
  };
136
139
  }
@@ -144,6 +147,7 @@ function recordUpserts({ base, recorded, entity, key, journal }: Recording, sche
144
147
  const undoUpsert = async function (this: unknown, rows: readonly Record<string, unknown>[]): Promise<Undo> {
145
148
  const ids = rows.map((row) => String(row[key]));
146
149
  const before = await base.findByKeys.call(this, ids);
150
+
147
151
  return {
148
152
  what: `upsert ${entity} (${ids.length} row(s))`,
149
153
  run: async () => {
@@ -163,6 +167,7 @@ function recordUpserts({ base, recorded, entity, key, journal }: Recording, sche
163
167
  const undo = await undoUpsert.call(this, [input]);
164
168
  const row = await upsert.call(this, input, ...rest);
165
169
  journal.push(undo);
170
+
166
171
  return row;
167
172
  };
168
173
  }
@@ -174,6 +179,7 @@ function recordUpserts({ base, recorded, entity, key, journal }: Recording, sche
174
179
  const undo = await undoUpsert.call(this, inputs);
175
180
  const written = await upsertAll.call(this, inputs, ...rest);
176
181
  journal.push(undo);
182
+
177
183
  return written;
178
184
  };
179
185
  }
@@ -21,6 +21,7 @@ import type { Diagnostic } from '../diagnostic.js';
21
21
  import { HandlerFacade } from '../dispatch/HandlerFacade.js';
22
22
  import { targetOf } from '../prefab/prefab.js';
23
23
  import { ownersOf, sharedNames, storageInUserCode, crudOnOwned } from './ownership.js';
24
+ import { refuseArityDrift } from './arity.js';
24
25
  import { StorageGuard } from '../dispatch/StorageGuard.js';
25
26
  import { portBindings, seamChains, wrapping, SEAMS } from './ports.js';
26
27
  import { contractsKeyOf, facadeKeyOf } from '../wire/Facade.js';
@@ -37,6 +38,7 @@ import { RouteAddress } from '../wire/RouteAddress.js';
37
38
  import { servedSurfaces } from '../descriptor/surface.js';
38
39
  import { OperationRoute } from '../dispatch/OperationRoute.js';
39
40
  import { facadeOperations } from '../entry/facade.js';
41
+ import { decoded } from '../dispatch/decoded.js';
40
42
 
41
43
  /**
42
44
  * What the app is made of while it is still being made. `createApp` builds these, hands
@@ -306,7 +308,7 @@ function registerProviders(
306
308
  // of: the container resolves a dep by NAME, so wrapping is a substituted key and
307
309
  // needs nothing of the container itself. The outermost answers under the port.
308
310
  let inner = nameOf(chain.at(-1)!);
309
- for (const wrapper of [...chain.slice(0, -1)].reverse()) {
311
+ for (const wrapper of chain.slice(0, -1).reverse()) {
310
312
  const deps = wrapper.deps.map((dep) => (dep === port ? inner : dep));
311
313
  inner = nameOf(wrapper);
312
314
  scope.register(inner, wrapper.ctor, { deps });
@@ -449,6 +451,9 @@ function buildFacadeInto(
449
451
  handler.address,
450
452
  routeRegistry.operationNames(handler.address, handler.surface),
451
453
  handler.surface,
454
+ // The output SCHEMA, per op: `Crud(Post, { list: PostCard })` names a different one for
455
+ // one operation, so the answer is read back through the view that op actually serves.
456
+ (operation, answer) => decoded(facade.effectiveOperations.get(operation)?.output, answer),
452
457
  ));
453
458
  }
454
459
 
@@ -471,6 +476,7 @@ export async function installFrond(frond: FrondDescriptor, assembly: Assembly):
471
476
  emissions.note(contractsOf(operations), key);
472
477
 
473
478
  }
479
+
474
480
  return;
475
481
  }
476
482
  // A child hangs off its parent, so everything the parent registered answers here too and
@@ -495,6 +501,7 @@ export async function installFrond(frond: FrondDescriptor, assembly: Assembly):
495
501
  // Who owns what, and the rule that makes owning mean something. Before anything is
496
502
  // registered, so a bad line is named by this refusal rather than by the container's.
497
503
  sharedNames(frond, refused);
504
+ refuseArityDrift(frond, refused);
498
505
  const owners = ownersOf(frond.providers, frond.name, refused);
499
506
  storageInUserCode(frond, owners, (entity) => entityByName.has(entity), refused);
500
507
  crudOnOwned(frond, owners, refused);
@@ -80,6 +80,7 @@ function builtOn(ctor: unknown): string[] {
80
80
  const owned = ownedBy(ctor);
81
81
  if (owned.length > 0) return owned.map((e) => lowerFirst((e as { name: string }).name));
82
82
  const target = targetOf(ctor) as { name?: string } | undefined;
83
+
83
84
  return target?.name ? [lowerFirst(target.name)] : [];
84
85
  }
85
86
 
@@ -5,6 +5,7 @@ import type { RelationCheck } from '../dispatch/RelationCheck.js';
5
5
  import type { Hosting } from './Hosting.js';
6
6
  import type { Diagnostic } from '../diagnostic.js';
7
7
  import type { Releasing, Rows } from '../dispatch/Release.js';
8
+ import type { EntityEntry } from '../descriptor/EntityEntry.js';
8
9
 
9
10
  /**
10
11
  * Does a foreign key hold this reference? Both sides in ONE source that keeps relations — two
@@ -34,7 +35,7 @@ function referencesTo(target: string, hosting: Hosting): { entity: string; field
34
35
  for (const [entity, schema] of hosting.entities()) {
35
36
  for (const [field, declared] of Object.entries(schema.getFields())) {
36
37
  const role = Role.of(declared);
37
- if (role.isReference && role.target?.name && lowerFirst(role.target.name) === target) {
38
+ if (role.isReference() && role.target?.name && lowerFirst(role.target.name) === target) {
38
39
  found.push({ entity, field, role });
39
40
  }
40
41
  }
@@ -58,7 +59,7 @@ export function heldBy(entity: SchemaView, name: string, hosting: Hosting): Rela
58
59
 
59
60
  for (const [field, declared] of Object.entries(entity.getFields())) {
60
61
  const role = Role.of(declared);
61
- if (!role.isReference || !role.target?.name) continue;
62
+ if (!role.isReference() || !role.target?.name) continue;
62
63
 
63
64
  const target = lowerFirst(role.target.name);
64
65
  if (keyed(name, target, hosting)) continue;
@@ -122,6 +123,42 @@ export function refuseUnwritableNull(entity: SchemaView, name: string, filePath:
122
123
  return refused;
123
124
  }
124
125
 
126
+ /**
127
+ * A `many()` whose target declares no `ref()` back. The key lives on the far row, so a
128
+ * relation neither side states can never be filled: SQL emits no column, GraphQL drops the
129
+ * field when it finds no reverse key, and nothing says why.
130
+ *
131
+ * Asked once every frond is installed, since the target may belong to another one — and left
132
+ * alone when it answers elsewhere, where its fields are declared and cannot be read here.
133
+ */
134
+ export function unpaired(entities: readonly EntityEntry[], hosting: Hosting): Diagnostic[] {
135
+ const refused: Diagnostic[] = [];
136
+
137
+ for (const entry of entities) {
138
+ for (const [field, declared] of Object.entries(entry.entityClass.getFields())) {
139
+ const role = Role.of(declared);
140
+ const target = role.isCollection() ? role.target?.name : undefined;
141
+ if (!target || !hosting.hostedHere(lowerFirst(target))) continue;
142
+
143
+ const held = referencesTo(lowerFirst(entry.name), hosting)
144
+ .some((one) => one.entity === lowerFirst(target));
145
+ if (held) continue;
146
+
147
+ refused.push({
148
+ severity: 'blocking',
149
+ code: 'many-without-reference',
150
+ filePath: entry.filePath,
151
+ subject: `${entry.name}.${field}`,
152
+ message: `${entry.entityClass.name}.${field} states many(${target}), and no field of `
153
+ + `${target} references ${entry.entityClass.name}: the relation is declared on neither `
154
+ + `side, so nothing can fill it. Declare the ref() on ${target}.`,
155
+ });
156
+ }
157
+ }
158
+
159
+ return refused;
160
+ }
161
+
125
162
  /** The checks nothing in this process can answer — asked once every frond has registered. */
126
163
  export function unheldAmong(checks: readonly RelationCheck[], hosting: Hosting): string[] {
127
164
  return checks
@@ -15,6 +15,7 @@ import { ErrorCode } from '../wire/ErrorCode.js';
15
15
  import { FougereError } from '../wire/FougereError.js';
16
16
  import { Card, type SchemaView, type SchemaDescriptor } from '@fougere/schema';
17
17
  import { dynamicOperations } from '../entry/facade.js';
18
+ import { decoded } from '../dispatch/decoded.js';
18
19
 
19
20
  interface Route {
20
21
  frond: string;
@@ -53,6 +54,7 @@ export function createRemoteRouter(
53
54
  transports.set(url, transport);
54
55
  try {
55
56
  const answer = await transport({ entity: RPC_ENTITY, op: 'discover' }, Invocation.empty);
57
+
56
58
  // Judged below and not here: this catch means "unreachable, retry", and a
57
59
  // refusal thrown inside it would be swallowed into another silent retry.
58
60
  return { label, url, transport, answer };
@@ -103,13 +105,19 @@ export function createRemoteFacade(
103
105
  middlewaresFor: (address: string) => AppMiddleware[],
104
106
  ): Facade {
105
107
  const opFn = (op: string) => async (invocation: InvocationContext = Invocation.empty) => {
106
- const { frond, transport } = await router.route(entity);
108
+ const { frond, transport, schema } = await router.route(entity);
107
109
  const call: FrondCall = { frond, entity, op };
108
110
  const ctx: OperationContext = {
109
111
  entity, frond, operation: op, args: [], state: invocation.state, invocation,
110
112
  };
111
- return runMiddlewares(middlewaresFor(entity), ctx, () =>
113
+ const answer = await runMiddlewares(middlewaresFor(entity), ctx, () =>
112
114
  transport(call, ctx.invocation ?? invocation));
115
+
116
+ // The schema the card carried, put to work: a row crosses as data and comes back through
117
+ // the same codecs a local facade applies, so a placement does not decide what a caller
118
+ // holds. It is the ENTITY's — an op serving a narrower view is the far side's business,
119
+ // and what this card names is the shape it publishes.
120
+ return decoded(schema, answer);
113
121
  };
114
122
 
115
123
  return dynamicOperations(opFn) as Facade;
package/src/boot/seed.ts CHANGED
@@ -47,7 +47,7 @@ function whatEachWaitsFor(
47
47
  for (const entity of frond.entities) {
48
48
  const targets = new Set<string>();
49
49
  for (const field of Object.values(entity.entityClass.getFields())) {
50
- if (!Role.of(field).isReference) continue;
50
+ if (!Role.of(field).isReference()) continue;
51
51
  const target = (Role.of(field).target as { name?: string }).name?.toLowerCase();
52
52
  if (target && target !== entity.name.toLowerCase()) targets.add(target);
53
53
  }
@@ -41,3 +41,28 @@ export function statementDrift(frond: FrondDescriptor, handler: HandlerEntry): D
41
41
 
42
42
  return found;
43
43
  }
44
+
45
+ /**
46
+ * A contract with no method under its name. The scan cannot produce one; a declaration can, and
47
+ * its first call used to fail as `instance[method] is not a function` — a server error for what
48
+ * is a typo in the declaration.
49
+ */
50
+ export function unansweredOperations(
51
+ frond: FrondDescriptor,
52
+ handler: HandlerEntry,
53
+ names: Iterable<string>,
54
+ ): Diagnostic[] {
55
+ const prototype = handler.ctor.prototype as Record<string, unknown>;
56
+
57
+ return [...names]
58
+ .filter((name) => typeof prototype[name] !== 'function')
59
+ .map((name) => ({
60
+ severity: 'blocking',
61
+ code: 'operation-without-method',
62
+ filePath: handler.filePath,
63
+ frond: frond.name,
64
+ subject: `${handler.ctor.name}.${name}`,
65
+ message: `${handler.ctor.name}.${name} is declared as an operation, and ${handler.ctor.name} `
66
+ + 'has no method of that name — its first call would fail as a server error.',
67
+ }));
68
+ }
@@ -154,13 +154,16 @@ async function inScope<R>(
154
154
  const storages = members.entities.map((member) => {
155
155
  const storage = wrap(factory(member.schema, member.name), member.name, member.schema);
156
156
  scope.registerValue(storageKeyOf(member.name), storage);
157
+
157
158
  return storage;
158
159
  });
159
160
  // Providers after every storage is in place: one may depend on another member's.
160
161
  const built = members.providers.map((provider) => {
161
162
  scope.register(provider.ctor.name, provider.ctor, { deps: provider.deps });
163
+
162
164
  return scope.resolve(provider.ctor.name);
163
165
  });
166
+
164
167
  return await fn(storages, built);
165
168
  } finally {
166
169
  await scope.dispose();