@fougere/core 0.18.0-alpha.0 → 0.19.1-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 (103) hide show
  1. package/README.md +1 -1
  2. package/dist/BootRefusal.d.ts +9 -0
  3. package/dist/BootRefusal.d.ts.map +1 -0
  4. package/dist/BootRefusal.js +12 -0
  5. package/dist/BootRefusal.js.map +1 -0
  6. package/dist/CallIdentity.d.ts.map +1 -1
  7. package/dist/CallIdentity.js +2 -1
  8. package/dist/CallIdentity.js.map +1 -1
  9. package/dist/boot/AppLifecycle.d.ts.map +1 -1
  10. package/dist/boot/AppLifecycle.js +2 -1
  11. package/dist/boot/AppLifecycle.js.map +1 -1
  12. package/dist/boot/Emissions.d.ts +8 -1
  13. package/dist/boot/Emissions.d.ts.map +1 -1
  14. package/dist/boot/Emissions.js +16 -11
  15. package/dist/boot/Emissions.js.map +1 -1
  16. package/dist/boot/bootstrap.d.ts.map +1 -1
  17. package/dist/boot/bootstrap.js +22 -9
  18. package/dist/boot/bootstrap.js.map +1 -1
  19. package/dist/boot/card.d.ts.map +1 -1
  20. package/dist/boot/card.js.map +1 -1
  21. package/dist/boot/install.d.ts.map +1 -1
  22. package/dist/boot/install.js +1 -0
  23. package/dist/boot/install.js.map +1 -1
  24. package/dist/boot/ownership.d.ts.map +1 -1
  25. package/dist/boot/ownership.js +26 -14
  26. package/dist/boot/ownership.js.map +1 -1
  27. package/dist/diagnostic.d.ts +2 -1
  28. package/dist/diagnostic.d.ts.map +1 -1
  29. package/dist/diagnostic.js +2 -1
  30. package/dist/diagnostic.js.map +1 -1
  31. package/dist/dispatch/ArgumentResolver.d.ts +13 -1
  32. package/dist/dispatch/ArgumentResolver.d.ts.map +1 -1
  33. package/dist/dispatch/ArgumentResolver.js +48 -2
  34. package/dist/dispatch/ArgumentResolver.js.map +1 -1
  35. package/dist/dispatch/EntityLookup.d.ts +4 -0
  36. package/dist/dispatch/EntityLookup.d.ts.map +1 -0
  37. package/dist/dispatch/EntityLookup.js +2 -0
  38. package/dist/dispatch/EntityLookup.js.map +1 -0
  39. package/dist/dispatch/Facade.d.ts +3 -0
  40. package/dist/dispatch/Facade.d.ts.map +1 -1
  41. package/dist/dispatch/HandlerFacade.d.ts +2 -0
  42. package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
  43. package/dist/dispatch/HandlerFacade.js +14 -4
  44. package/dist/dispatch/HandlerFacade.js.map +1 -1
  45. package/dist/entry/facade.d.ts +10 -1
  46. package/dist/entry/facade.d.ts.map +1 -1
  47. package/dist/entry/facade.js +26 -2
  48. package/dist/entry/facade.js.map +1 -1
  49. package/dist/index.d.ts +2 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +2 -0
  52. package/dist/index.js.map +1 -1
  53. package/dist/prefab/CrudConstructor.d.ts.map +1 -1
  54. package/dist/prefab/CrudConstructor.js +5 -4
  55. package/dist/prefab/CrudConstructor.js.map +1 -1
  56. package/dist/prefab/Middleware.d.ts +13 -0
  57. package/dist/prefab/Middleware.d.ts.map +1 -0
  58. package/dist/prefab/Middleware.js +10 -0
  59. package/dist/prefab/Middleware.js.map +1 -0
  60. package/dist/wire/Facade.d.ts +5 -7
  61. package/dist/wire/Facade.d.ts.map +1 -1
  62. package/dist/wire/Facade.js.map +1 -1
  63. package/dist/wire/Invocation.d.ts +2 -1
  64. package/dist/wire/Invocation.d.ts.map +1 -1
  65. package/dist/wire/Invocation.js +4 -1
  66. package/dist/wire/Invocation.js.map +1 -1
  67. package/dist/wire/InvocationContext.d.ts +6 -0
  68. package/dist/wire/InvocationContext.d.ts.map +1 -1
  69. package/dist/wire/OperationContract.js +2 -2
  70. package/dist/wire/OperationContract.js.map +1 -1
  71. package/dist/wire/SignedCall.d.ts +2 -0
  72. package/dist/wire/SignedCall.d.ts.map +1 -1
  73. package/dist/wire/TypeRef.d.ts +5 -3
  74. package/dist/wire/TypeRef.d.ts.map +1 -1
  75. package/dist/wire/binding.d.ts +8 -1
  76. package/dist/wire/binding.d.ts.map +1 -1
  77. package/dist/wire/binding.js +2 -2
  78. package/dist/wire/binding.js.map +1 -1
  79. package/package.json +3 -3
  80. package/src/BootRefusal.ts +11 -0
  81. package/src/CallIdentity.ts +2 -1
  82. package/src/boot/AppLifecycle.ts +2 -1
  83. package/src/boot/Emissions.ts +18 -11
  84. package/src/boot/bootstrap.ts +25 -10
  85. package/src/boot/card.ts +1 -2
  86. package/src/boot/install.ts +1 -0
  87. package/src/boot/ownership.ts +35 -16
  88. package/src/diagnostic.ts +4 -2
  89. package/src/dispatch/ArgumentResolver.ts +54 -2
  90. package/src/dispatch/EntityLookup.ts +4 -0
  91. package/src/dispatch/Facade.ts +3 -0
  92. package/src/dispatch/HandlerFacade.ts +14 -6
  93. package/src/entry/facade.ts +38 -2
  94. package/src/index.ts +2 -0
  95. package/src/prefab/CrudConstructor.ts +6 -5
  96. package/src/prefab/Middleware.ts +13 -0
  97. package/src/wire/Facade.ts +6 -9
  98. package/src/wire/Invocation.ts +3 -1
  99. package/src/wire/InvocationContext.ts +6 -0
  100. package/src/wire/OperationContract.ts +2 -2
  101. package/src/wire/SignedCall.ts +2 -0
  102. package/src/wire/TypeRef.ts +4 -2
  103. package/src/wire/binding.ts +7 -4
@@ -172,8 +172,16 @@ export class Emissions {
172
172
  return (this.subscribers.get(fact) ?? []).map(({ facade }) => facade);
173
173
  }
174
174
 
175
+ /**
176
+ * What a listener is handed a fact through: the calls a door makes, never the facade code
177
+ * holds. A fact arrives as the invocation's input and the listener's binding plan places it,
178
+ * wherever it sits in the signature.
179
+ */
180
+ private calls: (facade: string) => Record<string, Function> | undefined = () => undefined;
181
+
175
182
  /** Register one emission value per fact — announced here, or merely listened to. */
176
- register(): void {
183
+ register(calls: (facade: string) => Record<string, Function> | undefined): void {
184
+ this.calls = calls;
177
185
  this.orderPipes();
178
186
  for (const fact of new Set([...this.announced, ...this.subscribers.keys()])) {
179
187
  this.container.registerValue(emitKeyOf(fact), (raw: unknown) => this.announce(fact, raw));
@@ -295,8 +303,7 @@ export class Emissions {
295
303
  private async finished(fact: string, payload: unknown): Promise<unknown> {
296
304
  let carried = payload;
297
305
  for (const link of this.pipes.get(fact) ?? []) {
298
- const facade = this.container.resolve<Record<string, Function>>(link.facade);
299
- const answered = await facade[link.op]({ ...Invocation.empty, input: carried });
306
+ const answered = await this.reach(link.facade, fact)[link.op]({ ...Invocation.empty, input: carried });
300
307
 
301
308
  // A link that answers nothing SUPPRESSES the fact — every subscriber was then handed
302
309
  // `null` and crashed reading it, one message each. It is refused rather than named,
@@ -377,18 +384,18 @@ export class Emissions {
377
384
  facade,
378
385
  op,
379
386
  done: ambient.enterChain(fact, async () => {
380
- let handler: Record<string, Function>;
381
- try {
382
- handler = this.container.resolve<Record<string, Function>>(facade);
383
- } catch (cause) {
384
- throw new Error(`${fact} → ${facade} could not be reached`, { cause });
385
- }
386
-
387
- return handler[op]({ ...Invocation.empty, input: payload });
387
+ return this.reach(facade, fact)[op]({ ...Invocation.empty, input: payload });
388
388
  }),
389
389
  }));
390
390
  }
391
391
 
392
+ private reach(facade: string, fact: string): Record<string, Function> {
393
+ const calls = this.calls(facade);
394
+ if (!calls) throw new Error(`${fact} → ${facade} could not be reached`);
395
+
396
+ return calls;
397
+ }
398
+
392
399
  /** A subscriber refusing the SHAPE, said in one line instead of dumped as an error. */
393
400
  private describeRefusal(fact: string, cause: unknown): string | undefined {
394
401
  const refusals = validationErrorsOf(cause);
@@ -57,7 +57,8 @@ import { LocalRoutePolicy } from '../dispatch/LocalRoutePolicy.js';
57
57
  import { OperationRoute } from '../dispatch/OperationRoute.js';
58
58
  import { remoteRoutes } from '../dispatch/remoteRoutes.js';
59
59
  import { RouteRegistry } from '../dispatch/RouteRegistry.js';
60
- import { facadeOperations } from '../entry/facade.js';
60
+ import { callOperations, facadeOperations } from '../entry/facade.js';
61
+ import { decoded } from '../dispatch/decoded.js';
61
62
 
62
63
  const LOG_LINE = lowerFirst(LogLine.name);
63
64
 
@@ -403,6 +404,7 @@ interface Serving {
403
404
  container: Container;
404
405
  fronds: Fronds;
405
406
  remoteRouter: RemoteRouter | undefined;
407
+ dispatcher: Dispatcher;
406
408
  localDispatcher: Dispatcher;
407
409
  routeRegistry: RouteRegistry;
408
410
  effectiveByKey: Map<string, EffectiveOperationsMap>;
@@ -415,7 +417,7 @@ interface Serving {
415
417
  * `facadeFor` is THE rule, stated once: every projection reads it and nothing else.
416
418
  */
417
419
  function readings(
418
- { container, fronds, remoteRouter, localDispatcher, routeRegistry, effectiveByKey, log }: Serving,
420
+ { container, fronds, remoteRouter, dispatcher, localDispatcher, routeRegistry, effectiveByKey, log }: Serving,
419
421
  ): Pick<App, 'resolve' | 'schemaFor' | 'facadeFor' | 'operationsFor' | 'presenterFor'> {
420
422
  const resolve = <T>(name: string): T => {
421
423
  try {
@@ -454,6 +456,19 @@ function readings(
454
456
  }
455
457
  };
456
458
 
459
+ /**
460
+ * What a door is handed for an address it may serve: the calls, not the facade code holds.
461
+ * The names are the routes this process registered; an address served elsewhere has none
462
+ * before it is reached, so its calls are named when they are made.
463
+ */
464
+ const callsAt = (address: string, surface: string | undefined, through: Dispatcher): Record<string, Function> => {
465
+ const names = routeRegistry.operationNames(address, surface);
466
+ if (names.length === 0) return callOperations(through, address, undefined, surface);
467
+ const effective = effectiveByKey.get(facadeKeyOf(address, surface));
468
+
469
+ return callOperations(through, address, names, surface, (operation, answer) => decoded(effective?.get(operation)?.output, answer));
470
+ };
471
+
457
472
  /** Said once per pair, so a facade that registers in a loop says it once. */
458
473
  const saidAbsent = new Set<string>();
459
474
 
@@ -474,9 +489,9 @@ function readings(
474
489
 
475
490
  /** THE membership rule, stated once — every projection reads this and nothing else. */
476
491
  const facadeFor = (address: string, surface?: string): Record<string, Function> | undefined => {
477
- if (!surface) return facadeAt(facadeKeyOf(address), true);
492
+ if (!surface) return facadeAt(facadeKeyOf(address), true) && callsAt(address, undefined, dispatcher);
478
493
 
479
- const own = facadeAt(facadeKeyOf(address, surface), false);
494
+ const own = facadeAt(facadeKeyOf(address, surface), false) && callsAt(address, surface, localDispatcher);
480
495
  if (!fronds.owner(address)) {
481
496
  sayNoSurfaceAcross(address, surface);
482
497
 
@@ -490,7 +505,7 @@ function readings(
490
505
  const fallback = facadeAt(facadeKeyOf(address), false);
491
506
 
492
507
  return fallback
493
- ? facadeOperations(
508
+ ? callOperations(
494
509
  localDispatcher,
495
510
  address,
496
511
  routeRegistry.operationNames(address, surface),
@@ -696,17 +711,17 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
696
711
  refuseWhatDoesNotHold(refused);
697
712
  warnAboutUnboundPorts(options, boundPorts, log);
698
713
 
714
+ const { resolve, schemaFor, facadeFor, operationsFor, presenterFor } = readings({
715
+ container, fronds, remoteRouter, dispatcher, localDispatcher, routeRegistry, effectiveByKey, log,
716
+ });
717
+
699
718
  // A pipe order and an `Emit<T, A>` are read here, once every facade exists.
700
- emissions.register();
719
+ emissions.register((facade) => facadeFor(addressOf(facade)));
701
720
  refuseWhatDoesNotHold(refused);
702
721
  markLineCarriers(emissions);
703
722
 
704
723
  const answeredElsewhere = remoteRouter ? answerRemotes(assembly, remoteRouter, fronds) : () => false;
705
724
 
706
- const { resolve, schemaFor, facadeFor, operationsFor, presenterFor } = readings({
707
- container, fronds, remoteRouter, localDispatcher, routeRegistry, effectiveByKey, log,
708
- });
709
-
710
725
  const app: App = {
711
726
  container,
712
727
  fronds,
package/src/boot/card.ts CHANGED
@@ -2,12 +2,11 @@
2
2
  import { Card } from '@fougere/schema';
3
3
  import type { App } from './App.js';
4
4
  import { factsAnnouncedBy } from '../wire/Emit.js';
5
- import type { InvocationContext } from '../wire/InvocationContext.js';
6
5
  import { type CardOp } from '../wire/card/CardOp.js';
7
6
  import { type IdentityCard } from '../wire/card/IdentityCard.js';
8
7
  import { facadeKeyOf } from '../wire/Facade.js';
9
8
 
10
- type AnyFacade = Record<string, (invocation?: InvocationContext) => Promise<unknown>>;
9
+ type AnyFacade = Record<string, (...args: unknown[]) => Promise<unknown>>;
11
10
 
12
11
  /** Serialize what the app hosts, for one audience. */
13
12
  export function identityCardOf(app: App, surface?: string): IdentityCard {
@@ -434,6 +434,7 @@ function buildFacadeInto(
434
434
  frond: frond.name,
435
435
  handlers: frond.handlers,
436
436
  operations: operationModel.forHandler(handler),
437
+ entities: (name) => assembly.entityByName.get(lowerFirst(name)),
437
438
  collectors: collectorTypeNames,
438
439
  presenter: presenterMap.get(handler.address),
439
440
  presenterScope: scope,
@@ -4,7 +4,7 @@ import { type ProviderEntry } from '../descriptor/ProviderEntry.js';
4
4
  import { inheritsCrud } from '../prefab/CrudConstructor.js';
5
5
  import { targetOf } from '../prefab/prefab.js';
6
6
  import { ownedBy, repositoryKeyOf } from '../prefab/RepositoryConstructor.js';
7
- import { entityOfStorageKey } from '../storage/Storage.js';
7
+ import { entityOfStorageKey, membersOfTogetherKey } from '../storage/Storage.js';
8
8
  import type { Diagnostic } from '../diagnostic.js';
9
9
 
10
10
  /** One name, one file: two providers under one key and the second silently replaces the first. */
@@ -129,23 +129,16 @@ function refuseStorageReached(
129
129
  const allowed = decl.kind === 'provider' ? builtOn(decl.ctor) : [];
130
130
 
131
131
  for (const dep of decl.deps) {
132
- const entity = entityOfStorageKey(dep, known);
133
- if (!entity) continue;
134
-
135
- const owner = owners.get(entity);
136
- if (owner && owner !== decl.ctor.name) {
137
- refused.push({
138
- severity: 'blocking',
139
- code: 'aggregate-storage-reached',
140
- filePath: decl.filePath,
141
- frond,
142
- subject: `${decl.ctor.name}(${dep})`,
143
- message: `${decl.ctor.name} asks for ${dep}, and ${owner} owns ${entity}. Nothing else `
144
- + `reaches an owned entity's storage — name the operation on ${owner} and ask for it: `
145
- + `constructor(private ${entity}: ${owner}) {}`,
146
- });
132
+ const framed = membersOfTogetherKey(dep);
133
+ if (framed) {
134
+ const members = framed.entities.map(lowerFirst).filter(known);
135
+ for (const entity of members) reachesOwned(decl, dep, entity, frond, owners, refused);
147
136
  continue;
148
137
  }
138
+
139
+ const entity = entityOfStorageKey(dep, known);
140
+ if (!entity) continue;
141
+ if (reachesOwned(decl, dep, entity, frond, owners, refused)) continue;
149
142
  if (allowed.includes(entity)) continue;
150
143
 
151
144
  refused.push({
@@ -162,6 +155,32 @@ function refuseStorageReached(
162
155
  }
163
156
  }
164
157
 
158
+ /** Whether `dep` reaches an entity an aggregate other than `decl` owns — a frame as much as a storage. */
159
+ function reachesOwned(
160
+ decl: { ctor: { name: string }; filePath: string },
161
+ dep: string,
162
+ entity: string,
163
+ frond: string,
164
+ owners: Map<string, string>,
165
+ refused: Diagnostic[],
166
+ ): boolean {
167
+ const owner = owners.get(entity);
168
+ if (!owner || owner === decl.ctor.name) return false;
169
+
170
+ refused.push({
171
+ severity: 'blocking',
172
+ code: 'aggregate-storage-reached',
173
+ filePath: decl.filePath,
174
+ frond,
175
+ subject: `${decl.ctor.name}(${dep})`,
176
+ message: `${decl.ctor.name} asks for ${dep}, and ${owner} owns ${entity}. Nothing else `
177
+ + `reaches an owned entity's storage — name the operation on ${owner} and ask for it: `
178
+ + `constructor(private ${entity}: ${owner}) {}`,
179
+ });
180
+
181
+ return true;
182
+ }
183
+
165
184
  export function storageInUserCode(
166
185
  frond: FrondDescriptor,
167
186
  owners: Map<string, string>,
package/src/diagnostic.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { BootRefusal } from './BootRefusal.js';
2
+
1
3
  /**
2
4
  * One thing that does not hold, in the terms of whoever has to fix it — raised by the scan,
3
5
  * by a boot rule, or by `fougere check`, which is why it belongs to no phase.
@@ -30,12 +32,12 @@ export interface Diagnostic {
30
32
  * Every blocking diagnostic in ONE refusal, so a boot names all of them rather than the
31
33
  * first — the shape `fougere check` renders, read back by whoever has to fix it.
32
34
  */
33
- export function refusalOf(diagnostics: readonly Diagnostic[], what: string): Error | undefined {
35
+ export function refusalOf(diagnostics: readonly Diagnostic[], what: string): BootRefusal | undefined {
34
36
  const blocking = diagnostics.filter((one) => one.severity === 'blocking');
35
37
  if (blocking.length === 0) return undefined;
36
38
 
37
39
  const lines = blocking.map((one) =>
38
40
  ` [${one.code}]${one.subject ? ` ${one.subject}` : ''}\n ${one.message}\n ${one.filePath}`);
39
41
 
40
- return new Error(`Fougere boot refused: ${blocking.length} ${what}:\n${lines.join('\n')}`);
42
+ return new BootRefusal(`Fougere boot refused: ${blocking.length} ${what}:\n${lines.join('\n')}`);
41
43
  }
@@ -1,15 +1,21 @@
1
+ import { FieldValueValidator, type SchemaView } from '@fougere/schema';
1
2
  import type { InvocationContext } from '../wire/InvocationContext.js';
2
3
  import type { BindingPlan } from '../wire/binding.js';
3
4
  import { ErrorCode } from '../wire/ErrorCode.js';
4
5
  import { FougereError } from '../wire/FougereError.js';
5
6
  import type { CollectorLookup } from './CollectorLookup.js';
7
+ import type { EntityLookup } from './EntityLookup.js';
6
8
  import { collectedAs } from '../prefab/collector.js';
9
+ import { validateInput } from './validateInput.js';
7
10
 
8
11
  type ParamSource = Extract<BindingPlan[number]['source'], { kind: 'param' }>;
9
12
 
10
13
  /** Resolves an operation's declared binding plan against one invocation. */
11
14
  export class ArgumentResolver {
12
- constructor(private readonly collectors?: CollectorLookup) {}
15
+ constructor(
16
+ private readonly collectors?: CollectorLookup,
17
+ private readonly entities?: EntityLookup,
18
+ ) {}
13
19
 
14
20
  async resolve(plan: BindingPlan, ctx: InvocationContext): Promise<unknown[]> {
15
21
  const args: unknown[] = [];
@@ -26,7 +32,7 @@ export class ArgumentResolver {
26
32
  break;
27
33
  }
28
34
  case 'param': {
29
- args.push(ArgumentResolver.param(binding.source, binding.optional, ctx));
35
+ args.push(this.judged(binding.source, ArgumentResolver.param(binding.source, binding.optional, ctx)));
30
36
  break;
31
37
  }
32
38
  case 'fact':
@@ -56,6 +62,39 @@ export class ArgumentResolver {
56
62
  return args;
57
63
  }
58
64
 
65
+ /**
66
+ * The arguments code wrote, judged where a door would have judged them: the input against
67
+ * the op's view, a value a collector would have produced through that collector's entity —
68
+ * which also puts back a `Date` that crossed a wire as text. Nothing is collected: what the
69
+ * caller left out stays out.
70
+ */
71
+ given(plan: BindingPlan, input: SchemaView | undefined, ctx: InvocationContext): unknown[] {
72
+ const args = ctx.args ?? [];
73
+
74
+ return plan.map((binding, index) => {
75
+ const value = args[index];
76
+ switch (binding.source.kind) {
77
+ case 'context':
78
+ return ctx;
79
+ case 'input':
80
+ case 'fact':
81
+ case 'pipe':
82
+ return validateInput(input, { ...ctx, input: value }).input;
83
+ case 'collector': {
84
+ const collector = this.collectors?.(binding.source.typeName);
85
+
86
+ return collector ? collectedAs(collector, value) : value;
87
+ }
88
+ case 'param':
89
+ if (value === undefined && !binding.optional) throw refused(binding.source.name, 'Required');
90
+
91
+ return this.judged(binding.source, value);
92
+ case 'query':
93
+ return value;
94
+ }
95
+ });
96
+ }
97
+
59
98
  /**
60
99
  * A value the caller typed in a path or a query string. `null` is a value, not a miss: only
61
100
  * `undefined` means absent, so `T | null` stays apart from `T | undefined`.
@@ -77,6 +116,19 @@ export class ArgumentResolver {
77
116
  return coerced;
78
117
  }
79
118
 
119
+ /** `age: Sample['age']` obeys what `Sample.age` declares, after the query string was read. */
120
+ private judged(source: ParamSource, value: unknown): unknown {
121
+ if (value === undefined || !source.field) return value;
122
+
123
+ const field = this.entities?.(source.field.entity)?.getFields()[source.field.name];
124
+ if (!field) return value;
125
+
126
+ const verdict = FieldValueValidator.of(field).parse(value);
127
+ if ('message' in verdict) throw refused(source.name, verdict.message);
128
+
129
+ return verdict.value;
130
+ }
131
+
80
132
  private static number(value: unknown): number | undefined {
81
133
  if (typeof value === 'number') return Number.isNaN(value) ? undefined : value;
82
134
  if (typeof value !== 'string' || value.trim() === '') return undefined;
@@ -0,0 +1,4 @@
1
+ import type { SchemaView } from '@fougere/schema';
2
+
3
+ /** An entity this process knows, by the name a type wrote — `Post` in `Post['id']`. */
4
+ export type EntityLookup = (name: string) => SchemaView | undefined;
@@ -5,6 +5,7 @@ import type { HandlerEntry } from '../descriptor/HandlerEntry.js';
5
5
  import type { PresenterEntry } from '../descriptor/PresenterEntry.js';
6
6
  import type { StateShape } from '../wire/StateShape.js';
7
7
  import type { OwnLogger } from '../builtin/OwnLoggers.js';
8
+ import type { EntityLookup } from './EntityLookup.js';
8
9
 
9
10
  /** What boot resolved around one handler, beyond the handler and the scope it resolves in. */
10
11
  export interface Facade {
@@ -16,6 +17,8 @@ export interface Facade {
16
17
  handlers: readonly HandlerEntry[];
17
18
  /** The canonical operation table resolved before boot performs any side effect. */
18
19
  operations: EffectiveOperationsMap;
20
+ /** Where a parameter typed `Post['title']` finds the rules of `title`. */
21
+ entities: EntityLookup;
19
22
  /** Entity names this frond has a collector for. */
20
23
  collectors: Set<string>;
21
24
  /** The presenter over this handler's entity, when the frond declares one. */
@@ -35,9 +35,7 @@ export class HandlerFacade {
35
35
  private instance: any;
36
36
  private readonly implementationKeys = new Map<string, string>();
37
37
  private readonly implementationInstances = new Map<string, any>();
38
- private readonly arguments = new ArgumentResolver(
39
- (typeName) => this.collectorResolver(typeName),
40
- );
38
+ private readonly arguments: ArgumentResolver;
41
39
  /** A computed field's parameters and the collectors in scope are both boot-time facts. */
42
40
  private readonly presenterPlans: Map<string, BindingPlan>;
43
41
 
@@ -52,6 +50,7 @@ export class HandlerFacade {
52
50
  private readonly facade: Facade,
53
51
  ) {
54
52
  this.refuseCrudWithoutRepository(handler);
53
+ this.arguments = new ArgumentResolver((typeName) => this.collectorResolver(typeName), facade.entities);
55
54
 
56
55
  this.presenterPlans = facade.presenter
57
56
  ? presenterPlans(facade.presenter, facade.collectors)
@@ -124,22 +123,31 @@ export class HandlerFacade {
124
123
  entered: Record<string, unknown>,
125
124
  ): Promise<unknown> {
126
125
  const state = this.facade.state.judge(context.state, this.handler.address, op, entered);
127
- const validated = validateInput(contract.input, { ...invocation, state });
126
+ const written = invocation.args !== undefined;
127
+ const validated = written ? { ...invocation, state } : validateInput(contract.input, { ...invocation, state });
128
128
  context.invocation = validated;
129
129
 
130
- const args = contract.binding ? await this.arguments.resolve(contract.binding, validated) : [];
130
+ const args = await this.argumentsOf(contract, validated);
131
131
  const { instance, method } = this.resolveImplementation(op);
132
132
  const answered = await instance[method](...args);
133
133
 
134
134
  const view = this.viewOf(op);
135
135
  const { presenter } = this.facade;
136
- const computed = view.closed || !presenter
136
+ const computed = written || view.closed || !presenter
137
137
  ? answered
138
138
  : await this.present(op, presenter, answered, validated);
139
139
 
140
140
  return view.project(computed);
141
141
  }
142
142
 
143
+ /** What code wrote, judged; or what a door received, read through the binding plan. */
144
+ private async argumentsOf(contract: OperationContract, invocation: InvocationContext): Promise<unknown[]> {
145
+ if (!contract.binding) return [];
146
+ if (invocation.args !== undefined) return this.arguments.given(contract.binding, contract.input, invocation);
147
+
148
+ return this.arguments.resolve(contract.binding, invocation);
149
+ }
150
+
143
151
  /**
144
152
  * The computed fields a presenter adds, over the rows the handler answered and BEFORE the
145
153
  * view projects them. A presenter runs on the server, so what it reads is the row the
@@ -3,6 +3,7 @@ import { Call } from '../wire/Call.js';
3
3
  import { RouteAddress } from '../wire/RouteAddress.js';
4
4
  import type { DispatchPort } from '../dispatch/DispatchPort.js';
5
5
  import type { Received } from '../dispatch/Received.js';
6
+ import type { PartialInvocation } from '../wire/PartialInvocation.js';
6
7
 
7
8
  type Operation = (...args: any[]) => unknown;
8
9
 
@@ -24,11 +25,14 @@ export function dynamicOperations(operation: (name: string) => Operation): Recor
24
25
  }
25
26
 
26
27
  /**
27
- * Turns facade method calls into canonical dispatches.
28
+ * The facade code calls: each operation takes the handler's own arguments.
28
29
  *
29
30
  * `received` is what this side puts back before handing the answer over: a row leaves as data
30
31
  * and `date-time` means a `Date` on both sides. A facade built without one hands over what the
31
32
  * wire carried, which is what a caller holding no schema can do.
33
+ *
34
+ * The trailing `undefined`s are dropped because a JSON array writes them `null`, and the far
35
+ * side would read a value where the caller left one out. An optional parameter is always last.
32
36
  */
33
37
  export function facadeOperations(
34
38
  dispatcher: DispatchPort,
@@ -37,7 +41,30 @@ export function facadeOperations(
37
41
  surface?: string,
38
42
  received?: Received,
39
43
  ): Record<string, Operation> {
40
- const operation = (name: string): Operation => async (invocation) => {
44
+ const send = sending(dispatcher, address, surface, received);
45
+
46
+ return operationsOf((name) => (...args: unknown[]) => send(name, { args: withoutTrailingAbsence(args) }), operationNames);
47
+ }
48
+
49
+ /**
50
+ * What a door sends: the invocation it received, as a `Call`. A door holds a request, not the
51
+ * handler's arguments, so the binding plan reads it on the far side — collectors and presenter
52
+ * included.
53
+ */
54
+ export function callOperations(
55
+ dispatcher: DispatchPort,
56
+ address: string,
57
+ operationNames?: Iterable<string>,
58
+ surface?: string,
59
+ received?: Received,
60
+ ): Record<string, Operation> {
61
+ const send = sending(dispatcher, address, surface, received);
62
+
63
+ return operationsOf((name) => (invocation?: PartialInvocation) => send(name, invocation), operationNames);
64
+ }
65
+
66
+ function sending(dispatcher: DispatchPort, address: string, surface?: string, received?: Received) {
67
+ return async (name: string, invocation?: PartialInvocation): Promise<unknown> => {
41
68
  const answer = await dispatcher.dispatch(new Call(
42
69
  new RouteAddress({
43
70
  address,
@@ -49,8 +76,17 @@ export function facadeOperations(
49
76
 
50
77
  return received ? received(name, answer) : answer;
51
78
  };
79
+ }
52
80
 
81
+ function operationsOf(operation: (name: string) => Operation, operationNames?: Iterable<string>): Record<string, Operation> {
53
82
  return operationNames
54
83
  ? Object.fromEntries([...operationNames].map((name) => [name, operation(name)]))
55
84
  : dynamicOperations(operation);
56
85
  }
86
+
87
+ function withoutTrailingAbsence(args: unknown[]): unknown[] {
88
+ let length = args.length;
89
+ while (length > 0 && args[length - 1] === undefined) length--;
90
+
91
+ return args.slice(0, length);
92
+ }
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export type { CrudConstructor } from './prefab/CrudConstructor.js';
23
23
  export type { CrudOpName } from './prefab/CrudOpName.js';
24
24
  export type { CrudOps } from './prefab/CrudOps.js';
25
25
  export type { CrudViews } from './prefab/CrudViews.js';
26
+ export { Middleware } from './prefab/Middleware.js';
26
27
  export { Mirror } from './prefab/MirrorConstructor.js';
27
28
  export type { MirrorConstructor } from './prefab/MirrorConstructor.js';
28
29
  export type { MirrorOf } from './prefab/MirrorOf.js';
@@ -140,6 +141,7 @@ export type { App } from './boot/App.js';
140
141
  export type { CreateAppOptions } from './boot/CreateAppOptions.js';
141
142
  export type { ScanResult } from './scan.js';
142
143
  export type { Diagnostic } from './diagnostic.js';
144
+ export { BootRefusal } from './BootRefusal.js';
143
145
  export { DEFAULT_CONVENTIONS, frondDirsOf, frondPackage, providerDirsOf, resolveConventions, type Conventions } from './Conventions.js';
144
146
  export { type ConventionsInput } from './ConventionsInput.js';
145
147
  export type { CollectorEntry } from './descriptor/CollectorEntry.js';
@@ -30,14 +30,14 @@ export function subjectOf(ctor: unknown, address: string): string {
30
30
  }
31
31
 
32
32
  /** The id of the row an op acts on — `id: Post['id']`, which a route places before the op. */
33
- const byId = (entity: string) =>
34
- ({ name: 'id', source: { kind: 'param' as const, name: 'id', identifies: entity }, optional: false });
33
+ const byId = (entity: string, field: string) =>
34
+ ({ name: 'id', source: { kind: 'param' as const, name: 'id', field: { entity, name: field }, identifies: entity }, optional: false });
35
35
 
36
36
  const fromBody = { name: 'input', source: { kind: 'input' as const }, optional: false };
37
37
 
38
38
  /** The same five, written as the scan would have written them. */
39
39
  const idParam = (entity: string, field: string) =>
40
- ({ name: 'id', type: { raw: `${entity}['${field}']`, name: 'string', identifies: { entity, field } } });
40
+ ({ name: 'id', type: { raw: `${entity}['${field}']`, name: 'string', field: { entity, name: field }, identifies: entity } });
41
41
 
42
42
  const inputParam = (entity: string) => ({ name: 'input', type: { raw: `Partial<${entity}>`, name: entity } });
43
43
 
@@ -52,8 +52,9 @@ const returns = (raw: string, name: string, extra?: { array?: boolean; nullable?
52
52
  function crudOps(entity: SchemaView & { partial?: () => SchemaView }): Record<string, OperationContract> {
53
53
  const name = (entity as { name?: string }).name ?? 'Entity';
54
54
  const input = inputParam(name);
55
- const id = byId(name);
56
- const idSignature = idParam(name, FieldSet.of(entity.getFields()).primary ?? 'id');
55
+ const primary = FieldSet.of(entity.getFields()).primary ?? 'id';
56
+ const id = byId(name, primary);
57
+ const idSignature = idParam(name, primary);
57
58
 
58
59
  return {
59
60
  list: {
@@ -0,0 +1,13 @@
1
+ import type { AppNext } from '../wire/AppNext.js';
2
+ import type { OperationContext } from '../wire/OperationContext.js';
3
+
4
+ /**
5
+ * What runs around every operation of its frond, and of the fronds under it — a class in
6
+ * `middlewares/` that extends this one.
7
+ *
8
+ * It answers what `next()` answered, the same type: a caller reads the handler's signature,
9
+ * and nothing tells that signature a middleware stood in the way.
10
+ */
11
+ export abstract class Middleware {
12
+ abstract around<T>(context: OperationContext, next: AppNext<T>): Promise<T>;
13
+ }
@@ -1,23 +1,20 @@
1
1
  import { lowerFirst } from '@fougere/schema';
2
2
 
3
- import type { InvocationContext } from './InvocationContext.js';
4
-
5
3
  /**
6
4
  * The facade built in front of a handler — the framework's second port, after `Storage`.
7
5
  *
8
- * What the handler knows is WHICH operations exist and what each one answers. The two ends are
9
- * the port's own: an invocation goes in where the handler takes positional arguments, and a
10
- * promise comes back where the handler may answer a bare value. A facade is a crossing, and a
11
- * crossing is awaited before any transport — the dispatch resolves a route, runs the middlewares
12
- * and awaits the collectors, so `readLocation(): string` was typed as answering now and never did.
6
+ * It takes what the handler takes: code calling it already holds the values, so nothing is
7
+ * collected for it and nothing is presented — that is a door's work, done on what a door
8
+ * received. A promise comes back where the handler may answer a bare value, because a facade
9
+ * is a crossing and a crossing is awaited, whether or not a transport carries it.
13
10
  *
14
11
  * `Awaited` because a promise does not stack: `Promise.resolve(p)` IS `p`, so writing
15
12
  * `Promise<R>` over an async handler would describe a `Promise<Promise<Post>>` that no value can
16
13
  * have — `.then` would hand its callback a promise the runtime never delivers.
17
14
  */
18
15
  export type Facade<T> = {
19
- [K in keyof T]: T[K] extends (...args: never[]) => infer R
20
- ? (invocation?: InvocationContext) => Promise<Awaited<R>>
16
+ [K in keyof T]: T[K] extends (...args: infer A) => infer R
17
+ ? (...args: A) => Promise<Awaited<R>>
21
18
  : never;
22
19
  };
23
20
 
@@ -31,6 +31,7 @@ export class Invocation implements InvocationContext {
31
31
  readonly params: Record<string, unknown>;
32
32
  readonly query: Record<string, unknown>;
33
33
  readonly input: unknown;
34
+ readonly args?: readonly unknown[];
34
35
  readonly state: Record<string, unknown>;
35
36
  readonly trace?: string;
36
37
  readonly identity?: string;
@@ -38,11 +39,12 @@ export class Invocation implements InvocationContext {
38
39
  readonly runAt?: number;
39
40
  readonly crossed?: true;
40
41
 
41
- /** Gives every entry and every transport the same nine-member value, frozen. */
42
+ /** Gives every entry and every transport the same ten-member value, frozen. */
42
43
  private constructor(context: PartialInvocation) {
43
44
  this.params = canonicalRecord(context.params);
44
45
  this.query = canonicalRecord(context.query);
45
46
  this.input = canonicalValue(context.input);
47
+ if (context.args !== undefined) this.args = canonicalValue(context.args) as readonly unknown[];
46
48
  // State is host-owned and may still be enriched by existing middlewares. Moving it
47
49
  // to immutable lifecycle context is a separate migration.
48
50
  this.state = context.state ?? {};
@@ -2,6 +2,12 @@ export interface InvocationContext {
2
2
  params: Record<string, unknown>;
3
3
  query: Record<string, unknown>;
4
4
  input: unknown;
5
+ /**
6
+ * The handler's own arguments, written by code that called its facade. A door writes
7
+ * `params`, `query` and `input` — what it received — and the binding plan reads them; code
8
+ * already holds the values, so nothing is collected and nothing is presented.
9
+ */
10
+ args?: readonly unknown[];
5
11
  state: Record<string, unknown>;
6
12
  trace?: string;
7
13
  identity?: string;
@@ -49,8 +49,8 @@ const PRIMITIVE_RETURNS = new Set(['boolean', 'string', 'number', 'void', 'undef
49
49
 
50
50
  /** Deliberately finite. */
51
51
  const QUERY_VERBS = new Set([
52
- 'all', 'check', 'count', 'exists', 'fetch', 'find', 'get', 'has', 'health', 'list',
53
- 'load', 'mine', 'ping', 'quote', 'read', 'search', 'stats', 'status', 'who',
52
+ 'all', 'can', 'check', 'count', 'exists', 'fetch', 'find', 'get', 'has', 'health', 'is',
53
+ 'list', 'load', 'mine', 'ping', 'quote', 'read', 'search', 'stats', 'status', 'who',
54
54
  ]);
55
55
 
56
56
  /**