@fougere/core 0.12.1-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 (177) 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 +2 -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 +3 -1
  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.map +1 -1
  45. package/dist/boot/seed.js +1 -1
  46. package/dist/boot/seed.js.map +1 -1
  47. package/dist/boot/statement-drift.d.ts +6 -0
  48. package/dist/boot/statement-drift.d.ts.map +1 -1
  49. package/dist/boot/statement-drift.js +19 -0
  50. package/dist/boot/statement-drift.js.map +1 -1
  51. package/dist/boot/together.d.ts.map +1 -1
  52. package/dist/boot/together.js.map +1 -1
  53. package/dist/builtin/Carry.d.ts.map +1 -1
  54. package/dist/builtin/Carry.js.map +1 -1
  55. package/dist/builtin/Logger.d.ts.map +1 -1
  56. package/dist/builtin/Logger.js +3 -1
  57. package/dist/builtin/Logger.js.map +1 -1
  58. package/dist/builtin/config.js.map +1 -1
  59. package/dist/contract.d.ts.map +1 -1
  60. package/dist/contract.js.map +1 -1
  61. package/dist/crypto/encoding.d.ts.map +1 -1
  62. package/dist/crypto/encoding.js.map +1 -1
  63. package/dist/crypto/node.d.ts.map +1 -1
  64. package/dist/crypto/node.js.map +1 -1
  65. package/dist/crypto/webcrypto.d.ts.map +1 -1
  66. package/dist/crypto/webcrypto.js.map +1 -1
  67. package/dist/descriptor/Fronds.d.ts.map +1 -1
  68. package/dist/descriptor/Fronds.js.map +1 -1
  69. package/dist/dispatch/ArrayResult.d.ts.map +1 -1
  70. package/dist/dispatch/ArrayResult.js.map +1 -1
  71. package/dist/dispatch/Dispatcher.d.ts.map +1 -1
  72. package/dist/dispatch/Dispatcher.js.map +1 -1
  73. package/dist/dispatch/HandlerFacade.d.ts +6 -1
  74. package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
  75. package/dist/dispatch/HandlerFacade.js +11 -3
  76. package/dist/dispatch/HandlerFacade.js.map +1 -1
  77. package/dist/dispatch/InFlight.d.ts.map +1 -1
  78. package/dist/dispatch/InFlight.js.map +1 -1
  79. package/dist/dispatch/LocalRoutePolicy.d.ts.map +1 -1
  80. package/dist/dispatch/LocalRoutePolicy.js.map +1 -1
  81. package/dist/dispatch/RouteRegistry.d.ts.map +1 -1
  82. package/dist/dispatch/RouteRegistry.js.map +1 -1
  83. package/dist/dispatch/StorageGuard.d.ts.map +1 -1
  84. package/dist/dispatch/StorageGuard.js +4 -1
  85. package/dist/dispatch/StorageGuard.js.map +1 -1
  86. package/dist/identity-keys.d.ts.map +1 -1
  87. package/dist/identity-keys.js.map +1 -1
  88. package/dist/loader.d.ts.map +1 -1
  89. package/dist/loader.js.map +1 -1
  90. package/dist/prefab/CrudConstructor.d.ts.map +1 -1
  91. package/dist/prefab/CrudConstructor.js.map +1 -1
  92. package/dist/prefab/collector.d.ts.map +1 -1
  93. package/dist/prefab/collector.js.map +1 -1
  94. package/dist/prefab/presenter.d.ts.map +1 -1
  95. package/dist/prefab/presenter.js.map +1 -1
  96. package/dist/storage/Storage.d.ts.map +1 -1
  97. package/dist/storage/Storage.js.map +1 -1
  98. package/dist/storage/Store.d.ts.map +1 -1
  99. package/dist/storage/Store.js +1 -1
  100. package/dist/storage/Store.js.map +1 -1
  101. package/dist/verify.d.ts.map +1 -1
  102. package/dist/verify.js.map +1 -1
  103. package/dist/wire/AppMiddleware.d.ts.map +1 -1
  104. package/dist/wire/AppMiddleware.js.map +1 -1
  105. package/dist/wire/FougereError.d.ts.map +1 -1
  106. package/dist/wire/FougereError.js.map +1 -1
  107. package/dist/wire/Invocation.d.ts.map +1 -1
  108. package/dist/wire/Invocation.js.map +1 -1
  109. package/dist/wire/OperationContract.d.ts.map +1 -1
  110. package/dist/wire/OperationContract.js.map +1 -1
  111. package/dist/wire/RouteAddress.d.ts.map +1 -1
  112. package/dist/wire/RouteAddress.js.map +1 -1
  113. package/dist/wire/binding.d.ts.map +1 -1
  114. package/dist/wire/binding.js.map +1 -1
  115. package/dist/wire/card/IdentityCard.d.ts.map +1 -1
  116. package/dist/wire/card/IdentityCard.js.map +1 -1
  117. package/dist/wire/drift.d.ts.map +1 -1
  118. package/dist/wire/drift.js.map +1 -1
  119. package/dist/wire/http-error.d.ts.map +1 -1
  120. package/dist/wire/http-error.js.map +1 -1
  121. package/dist/wire/loggerMiddleware.d.ts.map +1 -1
  122. package/dist/wire/loggerMiddleware.js.map +1 -1
  123. package/package.json +5 -5
  124. package/src/CallIdentity.ts +6 -0
  125. package/src/DomainCluster.ts +2 -1
  126. package/src/EffectiveOperationModel.ts +19 -1
  127. package/src/FougereConfig.ts +3 -0
  128. package/src/FrondConfig.ts +2 -0
  129. package/src/Source.ts +2 -2
  130. package/src/boot/AppLifecycle.ts +1 -0
  131. package/src/boot/Emissions.ts +4 -0
  132. package/src/boot/ambient.als.ts +2 -0
  133. package/src/boot/apply.ts +1 -0
  134. package/src/boot/arity.ts +42 -0
  135. package/src/boot/bootstrap.ts +9 -2
  136. package/src/boot/card.ts +2 -0
  137. package/src/boot/frame.ts +6 -0
  138. package/src/boot/install.ts +4 -1
  139. package/src/boot/ownership.ts +1 -0
  140. package/src/boot/relations.ts +39 -2
  141. package/src/boot/remote.ts +1 -0
  142. package/src/boot/seed.ts +1 -1
  143. package/src/boot/statement-drift.ts +25 -0
  144. package/src/boot/together.ts +3 -0
  145. package/src/builtin/Carry.ts +1 -0
  146. package/src/builtin/Logger.ts +9 -1
  147. package/src/builtin/config.ts +1 -0
  148. package/src/contract.ts +1 -0
  149. package/src/crypto/encoding.ts +4 -0
  150. package/src/crypto/node.ts +2 -0
  151. package/src/crypto/webcrypto.ts +2 -0
  152. package/src/descriptor/Fronds.ts +2 -0
  153. package/src/dispatch/ArrayResult.ts +1 -0
  154. package/src/dispatch/Dispatcher.ts +1 -0
  155. package/src/dispatch/HandlerFacade.ts +16 -4
  156. package/src/dispatch/InFlight.ts +2 -0
  157. package/src/dispatch/LocalRoutePolicy.ts +1 -0
  158. package/src/dispatch/RouteRegistry.ts +4 -0
  159. package/src/dispatch/StorageGuard.ts +9 -1
  160. package/src/identity-keys.ts +2 -0
  161. package/src/loader.ts +1 -0
  162. package/src/prefab/CrudConstructor.ts +1 -0
  163. package/src/prefab/collector.ts +1 -0
  164. package/src/prefab/presenter.ts +1 -0
  165. package/src/storage/Storage.ts +2 -0
  166. package/src/storage/Store.ts +9 -1
  167. package/src/verify.ts +2 -0
  168. package/src/wire/AppMiddleware.ts +2 -0
  169. package/src/wire/FougereError.ts +2 -0
  170. package/src/wire/Invocation.ts +2 -0
  171. package/src/wire/OperationContract.ts +1 -0
  172. package/src/wire/RouteAddress.ts +2 -0
  173. package/src/wire/binding.ts +1 -0
  174. package/src/wire/card/IdentityCard.ts +1 -0
  175. package/src/wire/drift.ts +3 -0
  176. package/src/wire/http-error.ts +2 -0
  177. package/src/wire/loggerMiddleware.ts +1 -0
@@ -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';
@@ -307,7 +308,7 @@ function registerProviders(
307
308
  // of: the container resolves a dep by NAME, so wrapping is a substituted key and
308
309
  // needs nothing of the container itself. The outermost answers under the port.
309
310
  let inner = nameOf(chain.at(-1)!);
310
- for (const wrapper of [...chain.slice(0, -1)].reverse()) {
311
+ for (const wrapper of chain.slice(0, -1).reverse()) {
311
312
  const deps = wrapper.deps.map((dep) => (dep === port ? inner : dep));
312
313
  inner = nameOf(wrapper);
313
314
  scope.register(inner, wrapper.ctor, { deps });
@@ -475,6 +476,7 @@ export async function installFrond(frond: FrondDescriptor, assembly: Assembly):
475
476
  emissions.note(contractsOf(operations), key);
476
477
 
477
478
  }
479
+
478
480
  return;
479
481
  }
480
482
  // A child hangs off its parent, so everything the parent registered answers here too and
@@ -499,6 +501,7 @@ export async function installFrond(frond: FrondDescriptor, assembly: Assembly):
499
501
  // Who owns what, and the rule that makes owning mean something. Before anything is
500
502
  // registered, so a bad line is named by this refusal rather than by the container's.
501
503
  sharedNames(frond, refused);
504
+ refuseArityDrift(frond, refused);
502
505
  const owners = ownersOf(frond.providers, frond.name, refused);
503
506
  storageInUserCode(frond, owners, (entity) => entityByName.has(entity), refused);
504
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
@@ -54,6 +54,7 @@ export function createRemoteRouter(
54
54
  transports.set(url, transport);
55
55
  try {
56
56
  const answer = await transport({ entity: RPC_ENTITY, op: 'discover' }, Invocation.empty);
57
+
57
58
  // Judged below and not here: this catch means "unreachable, retry", and a
58
59
  // refusal thrown inside it would be swallowed into another silent retry.
59
60
  return { label, url, transport, answer };
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();
@@ -24,6 +24,7 @@ export class Carry {
24
24
  try {
25
25
  this.take(record);
26
26
  } catch { /* announcing never breaks logging, for the same reason a sink does not */ }
27
+
27
28
  return;
28
29
  }
29
30
  this.held.push(record);
@@ -12,6 +12,7 @@ let threshold: number = LEVELS[envLevel() ?? 'info'];
12
12
  /** The level the PROCESS was started with. */
13
13
  export function envLevel(): LogLevel | undefined {
14
14
  const raw = typeof process === 'undefined' ? undefined : process.env.FOUGERE_LOG_LEVEL;
15
+
15
16
  return raw !== undefined && raw in LEVELS ? (raw as LogLevel) : undefined;
16
17
  }
17
18
 
@@ -51,8 +52,10 @@ function supportsColor(): boolean {
51
52
  if (typeof process !== 'undefined') {
52
53
  if (process.env.NO_COLOR) return false;
53
54
  if (process.env.FORCE_COLOR === '1') return true;
55
+
54
56
  return process.stdout?.isTTY === true;
55
57
  }
58
+
56
59
  return false;
57
60
  }
58
61
 
@@ -62,6 +65,7 @@ function stamp(at: number | Date): string {
62
65
  const m = String(d.getMinutes()).padStart(2, '0');
63
66
  const s = String(d.getSeconds()).padStart(2, '0');
64
67
  const ms = String(d.getMilliseconds()).padStart(3, '0');
68
+
65
69
  return `${h}:${m}:${s}.${ms}`;
66
70
  }
67
71
 
@@ -92,7 +96,7 @@ export class Logger {
92
96
  level: level as LogRecord['level'], name: this.name, message: msg, args, at: Date.now(),
93
97
  };
94
98
 
95
- this.carry?.push(record);
99
+ this.carry?.push({ ...record, args: args.map(objectOf) });
96
100
 
97
101
  // The console ALWAYS, whoever else took the line. Skipping it once a destination
98
102
  // existed made `calls()` — a devtools ring that prints nothing — silence the
@@ -103,6 +107,10 @@ export class Logger {
103
107
  }
104
108
  }
105
109
 
110
+ /** A line travels as a fact, where `args` states `json()` — an object. */
111
+ const objectOf = (arg: unknown): object =>
112
+ typeof arg === 'object' && arg !== null && !Array.isArray(arg) ? arg : { value: arg };
113
+
106
114
  export function formatted(
107
115
  record: Rendered,
108
116
  color = supportsColor(),
@@ -9,5 +9,6 @@ export class Config {
9
9
 
10
10
  function envOfProcess(): Record<string, string | undefined> {
11
11
  const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
12
+
12
13
  return proc?.env ?? {};
13
14
  }
package/src/contract.ts CHANGED
@@ -80,5 +80,6 @@ export function callValueOf(
80
80
  typeof opOrInput === 'string'
81
81
  ? [{ entity: lowerFirst((target as { name: string }).name), op: opOrInput }, input]
82
82
  : [target as FrondCall, opOrInput];
83
+
83
84
  return { call, invocation: Invocation.from(given) };
84
85
  }
@@ -13,6 +13,7 @@ export function b64url(input: Uint8Array | string): string {
13
13
  // One character at a time: `String.fromCharCode(...bytes)` overflows the argument
14
14
  // limit on a key, and a signature is small enough that the loop costs nothing.
15
15
  for (const byte of bytes) binary += String.fromCharCode(byte);
16
+
16
17
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
17
18
  }
18
19
 
@@ -20,6 +21,7 @@ export function unb64url(input: string): Uint8Array {
20
21
  const binary = atob(input.replace(/-/g, '+').replace(/_/g, '/'));
21
22
  const bytes = new Uint8Array(binary.length);
22
23
  for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
24
+
23
25
  return bytes;
24
26
  }
25
27
 
@@ -28,6 +30,7 @@ export const unb64 = (input: string): Uint8Array => {
28
30
  const binary = atob(input);
29
31
  const bytes = new Uint8Array(binary.length);
30
32
  for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
33
+
31
34
  return bytes;
32
35
  };
33
36
 
@@ -35,5 +38,6 @@ export const unb64 = (input: string): Uint8Array => {
35
38
  export function derOf(pem: string): Uint8Array {
36
39
  const body = pem.replace(/-----(BEGIN|END)[^-]+-----/g, '').replace(/\s+/g, '');
37
40
  if (body.length === 0) throw new Error('Not a PEM: no base64 body between the armour');
41
+
38
42
  return unb64(body);
39
43
  }
@@ -13,6 +13,7 @@ export const crypto: CryptoPort = {
13
13
  async signerOf(privateKeyPem): Promise<Signer> {
14
14
  // Parsed here and captured, so a call signs against a KeyObject and never a string.
15
15
  const key = createPrivateKey(privateKeyPem);
16
+
16
17
  return { async sign(data) { return new Uint8Array(sign(null, data, key)); } };
17
18
  },
18
19
 
@@ -20,6 +21,7 @@ export const crypto: CryptoPort = {
20
21
  const parsed = typeof key === 'string'
21
22
  ? createPublicKey(key)
22
23
  : createPublicKey({ key: key as never, format: 'jwk' });
24
+
23
25
  return { async verify(data, signature) { return verify(null, data, parsed, signature); } };
24
26
  },
25
27
  };
@@ -33,6 +33,7 @@ export const crypto: CryptoPort = {
33
33
  // Imported once, at boot. Doing it per call would put an async key parse inside
34
34
  // every outgoing request — the reason the port hands back a signer at all.
35
35
  const key = await subtle().importKey('pkcs8', derOf(privateKeyPem), ED25519, false, ['sign']);
36
+
36
37
  return { async sign(data) { return new Uint8Array(await subtle().sign(ED25519, key, data)); } };
37
38
  },
38
39
 
@@ -40,6 +41,7 @@ export const crypto: CryptoPort = {
40
41
  const imported = typeof key === 'string'
41
42
  ? await subtle().importKey('spki', derOf(key), ED25519, false, ['verify'])
42
43
  : await subtle().importKey('jwk', key, ED25519, false, ['verify']);
44
+
43
45
  return { verify: (data, signature) => subtle().verify(ED25519, imported, signature, data) };
44
46
  },
45
47
  };
@@ -12,6 +12,7 @@ export class Fronds extends Array<FrondDescriptor> {
12
12
  static hosting(fronds: readonly FrondDescriptor[]): Fronds {
13
13
  const all = new Fronds();
14
14
  all.push(...fronds);
15
+
15
16
  return all;
16
17
  }
17
18
 
@@ -26,6 +27,7 @@ export class Fronds extends Array<FrondDescriptor> {
26
27
  const found = frond.entities.find((e) => e.name === name);
27
28
  if (found) return found;
28
29
  }
30
+
29
31
  return undefined;
30
32
  }
31
33
 
@@ -6,5 +6,6 @@ export function preserveArrayProperties<T extends unknown[]>(source: unknown[],
6
6
  (source as unknown as Record<string, unknown>)[key];
7
7
  }
8
8
  }
9
+
9
10
  return target;
10
11
  }
@@ -71,6 +71,7 @@ export class Dispatcher implements DispatchPort {
71
71
  this.lifecycle.publish(DispatchEvent.resolved(call, route.kind));
72
72
  const result = await route.execute(call);
73
73
  this.lifecycle.publish(DispatchEvent.completed(call, route.kind));
74
+
74
75
  return result;
75
76
  } catch (error) {
76
77
  this.lifecycle.publish(DispatchEvent.failed(call, error, route?.kind));
@@ -116,15 +116,23 @@ export class HandlerFacade {
116
116
 
117
117
  const args = contract.binding ? await this.arguments.resolve(contract.binding, validated) : [];
118
118
  const { instance, method } = this.resolveImplementation(op);
119
- const view = this.viewOf(op);
120
- const output = view.project(await instance[method](...args));
119
+ const answered = await instance[method](...args);
121
120
 
121
+ const view = this.viewOf(op);
122
122
  const { presenter } = this.facade;
123
+ const computed = view.closed || !presenter
124
+ ? answered
125
+ : await this.present(op, presenter, answered, validated);
123
126
 
124
- return view.closed || !presenter ? output : this.present(op, presenter, output, validated);
127
+ return view.project(computed);
125
128
  }
126
129
 
127
- /** The computed fields a presenter adds, over the page the façade just projected. */
130
+ /**
131
+ * The computed fields a presenter adds, over the rows the handler answered and BEFORE the
132
+ * view projects them. A presenter runs on the server, so what it reads is the row the
133
+ * storage holds: a `writeOnly` field is gone by the time the client sees it, and deriving
134
+ * from one is the whole reason to declare it.
135
+ */
128
136
  private present(
129
137
  op: string,
130
138
  presenter: PresenterEntry,
@@ -180,12 +188,14 @@ export class HandlerFacade {
180
188
  operation?.outputClosed ?? false,
181
189
  );
182
190
  this.cachedViews.set(op, resolved);
191
+
183
192
  return resolved;
184
193
  }
185
194
 
186
195
  /** The handler itself, resolved on first call — never at boot. */
187
196
  private resolveHandler(): any {
188
197
  if (!this.instance) this.instance = this.scope.resolve(this.handlerKey);
198
+
189
199
  return this.instance;
190
200
  }
191
201
 
@@ -208,6 +218,7 @@ export class HandlerFacade {
208
218
  + `${operation.implementation.method}, but boot found ${matches.length} matching handlers.`,
209
219
  );
210
220
  }
221
+
211
222
  return matches[0]!;
212
223
  }
213
224
 
@@ -225,6 +236,7 @@ export class HandlerFacade {
225
236
  instance = this.scope.resolve(key);
226
237
  this.implementationInstances.set(key, instance);
227
238
  }
239
+
228
240
  return { instance, method: operation.implementation.method };
229
241
  }
230
242
  }
@@ -23,6 +23,7 @@ export class InFlight {
23
23
 
24
24
  this.running++;
25
25
  let released = false;
26
+
26
27
  return () => {
27
28
  if (released) return;
28
29
  released = true;
@@ -41,6 +42,7 @@ export class InFlight {
41
42
 
42
43
  whenIdle(): Promise<void> {
43
44
  if (this.running === 0) return Promise.resolve();
45
+
44
46
  return new Promise((resolve) => this.idle.push(resolve));
45
47
  }
46
48
  }