@fougere/core 0.12.4-alpha.0 → 0.12.5-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/dist/FougereConfig.d.ts +0 -3
  2. package/dist/FougereConfig.d.ts.map +1 -1
  3. package/dist/FougereConfig.js.map +1 -1
  4. package/dist/FrondDeclaration.d.ts +5 -0
  5. package/dist/FrondDeclaration.d.ts.map +1 -1
  6. package/dist/FrondDeclaration.js +3 -0
  7. package/dist/FrondDeclaration.js.map +1 -1
  8. package/dist/FrondsStated.d.ts +4 -2
  9. package/dist/FrondsStated.d.ts.map +1 -1
  10. package/dist/FrondsStated.js.map +1 -1
  11. package/dist/StatedFrond.d.ts +3 -0
  12. package/dist/StatedFrond.d.ts.map +1 -1
  13. package/dist/StatedFrond.js +4 -0
  14. package/dist/StatedFrond.js.map +1 -1
  15. package/dist/StatedModules.d.ts +5 -2
  16. package/dist/StatedModules.d.ts.map +1 -1
  17. package/dist/StatedModules.js +11 -6
  18. package/dist/StatedModules.js.map +1 -1
  19. package/dist/boot/AppLifecycle.d.ts +3 -0
  20. package/dist/boot/AppLifecycle.d.ts.map +1 -1
  21. package/dist/boot/AppLifecycle.js +5 -0
  22. package/dist/boot/AppLifecycle.js.map +1 -1
  23. package/dist/boot/Extension.d.ts +6 -0
  24. package/dist/boot/Extension.d.ts.map +1 -1
  25. package/dist/boot/bootstrap.d.ts.map +1 -1
  26. package/dist/boot/bootstrap.js +14 -5
  27. package/dist/boot/bootstrap.js.map +1 -1
  28. package/dist/boot/install.d.ts +3 -0
  29. package/dist/boot/install.d.ts.map +1 -1
  30. package/dist/boot/install.js +1 -0
  31. package/dist/boot/install.js.map +1 -1
  32. package/dist/boot/nesting.d.ts.map +1 -1
  33. package/dist/boot/nesting.js +2 -1
  34. package/dist/boot/nesting.js.map +1 -1
  35. package/dist/boot/remote.d.ts +2 -1
  36. package/dist/boot/remote.d.ts.map +1 -1
  37. package/dist/boot/remote.js +7 -4
  38. package/dist/boot/remote.js.map +1 -1
  39. package/dist/boot/seed.d.ts +2 -1
  40. package/dist/boot/seed.d.ts.map +1 -1
  41. package/dist/boot/seed.js +10 -29
  42. package/dist/boot/seed.js.map +1 -1
  43. package/dist/descriptor/ExtensionEntry.d.ts +4 -0
  44. package/dist/descriptor/ExtensionEntry.d.ts.map +1 -1
  45. package/dist/descriptor/ExtensionEntry.js +6 -1
  46. package/dist/descriptor/ExtensionEntry.js.map +1 -1
  47. package/dist/descriptor/FrondDescriptor.d.ts +2 -0
  48. package/dist/descriptor/FrondDescriptor.d.ts.map +1 -1
  49. package/dist/descriptor/FrondDescriptor.js +5 -0
  50. package/dist/descriptor/FrondDescriptor.js.map +1 -1
  51. package/dist/dispatch/ArgumentResolver.d.ts.map +1 -1
  52. package/dist/dispatch/ArgumentResolver.js +2 -1
  53. package/dist/dispatch/ArgumentResolver.js.map +1 -1
  54. package/dist/dispatch/Facade.d.ts +3 -0
  55. package/dist/dispatch/Facade.d.ts.map +1 -1
  56. package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
  57. package/dist/dispatch/HandlerFacade.js +8 -4
  58. package/dist/dispatch/HandlerFacade.js.map +1 -1
  59. package/dist/prefab/collector.d.ts +6 -0
  60. package/dist/prefab/collector.d.ts.map +1 -1
  61. package/dist/prefab/collector.js +26 -1
  62. package/dist/prefab/collector.js.map +1 -1
  63. package/dist/storage/Storage.d.ts +3 -2
  64. package/dist/storage/Storage.d.ts.map +1 -1
  65. package/dist/storage/Storage.js +23 -10
  66. package/dist/storage/Storage.js.map +1 -1
  67. package/dist/storage/Store.d.ts.map +1 -1
  68. package/dist/storage/Store.js +4 -2
  69. package/dist/storage/Store.js.map +1 -1
  70. package/dist/wire/Invocation.d.ts +2 -1
  71. package/dist/wire/Invocation.d.ts.map +1 -1
  72. package/dist/wire/Invocation.js +4 -1
  73. package/dist/wire/Invocation.js.map +1 -1
  74. package/dist/wire/InvocationContext.d.ts +2 -0
  75. package/dist/wire/InvocationContext.d.ts.map +1 -1
  76. package/dist/wire/StateShape.d.ts +27 -0
  77. package/dist/wire/StateShape.d.ts.map +1 -0
  78. package/dist/wire/StateShape.js +59 -0
  79. package/dist/wire/StateShape.js.map +1 -0
  80. package/package.json +4 -4
  81. package/src/FougereConfig.ts +0 -3
  82. package/src/FrondDeclaration.ts +6 -0
  83. package/src/FrondsStated.ts +4 -2
  84. package/src/StatedFrond.ts +5 -0
  85. package/src/StatedModules.ts +17 -9
  86. package/src/boot/AppLifecycle.ts +6 -0
  87. package/src/boot/Extension.ts +6 -0
  88. package/src/boot/bootstrap.ts +16 -5
  89. package/src/boot/install.ts +4 -0
  90. package/src/boot/nesting.ts +2 -1
  91. package/src/boot/remote.ts +8 -3
  92. package/src/boot/seed.ts +11 -36
  93. package/src/descriptor/ExtensionEntry.ts +16 -1
  94. package/src/descriptor/FrondDescriptor.ts +7 -0
  95. package/src/dispatch/ArgumentResolver.ts +2 -1
  96. package/src/dispatch/Facade.ts +3 -0
  97. package/src/dispatch/HandlerFacade.ts +8 -3
  98. package/src/prefab/collector.ts +29 -1
  99. package/src/storage/Storage.ts +26 -12
  100. package/src/storage/Store.ts +4 -2
  101. package/src/wire/Invocation.ts +3 -1
  102. package/src/wire/InvocationContext.ts +2 -0
  103. package/src/wire/StateShape.ts +76 -0
@@ -568,16 +568,26 @@ function markLineCarriers(emissions: Emissions): void {
568
568
  * declares `product`.
569
569
  */
570
570
  function answerRemotes(
571
- { container, routeRegistry, dispatcher, getMiddlewares }: Pick<Assembly, 'container' | 'routeRegistry' | 'dispatcher' | 'getMiddlewares'>,
571
+ { container, routeRegistry, dispatcher, getMiddlewares, options, state }: Pick<Assembly, 'container' | 'routeRegistry' | 'dispatcher' | 'getMiddlewares' | 'options' | 'state'>,
572
572
  remoteRouter: RemoteRouter,
573
+ fronds: Fronds,
573
574
  ): void {
574
- container.setFallback((name) => (isFacadeKey(name) ? facadeOperations(dispatcher, addressOf(name)) : undefined));
575
+ // Only for what another process may serve: an entity a frond hosts HERE and gives no handler
576
+ // is served by nobody, and a stand-in for it had GraphQL project an entity with no operation.
577
+ const servedElsewhere = (address: string): boolean => {
578
+ const owner = fronds.owner(address);
579
+
580
+ return owner === undefined || owner.name in (options.remotes ?? {});
581
+ };
582
+ container.setFallback((name) => (isFacadeKey(name) && servedElsewhere(addressOf(name))
583
+ ? facadeOperations(dispatcher, addressOf(name))
584
+ : undefined));
575
585
 
576
586
  const remoteFacades = new Map<string, Record<string, Function>>();
577
587
  routeRegistry.addResolver(remoteRoutes((entity) => {
578
588
  const known = remoteFacades.get(entity);
579
589
  if (known) return known;
580
- const facade = createRemoteFacade(entity, remoteRouter, getMiddlewares);
590
+ const facade = createRemoteFacade(entity, remoteRouter, getMiddlewares, state);
581
591
  remoteFacades.set(entity, facade);
582
592
 
583
593
  return facade;
@@ -650,6 +660,7 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
650
660
  log.debug('builtins registered (Logger, Config)');
651
661
 
652
662
  const { fronds, operationModel } = await readFronds(options, log);
663
+ appLifecycle.add(...frondExtensions(fronds));
653
664
  const remoteRouter = remoteRouterOf(options);
654
665
  const { inflight, routeRegistry, dispatchLifecycle, dispatcher, localDispatcher, getMiddlewares, use } =
655
666
  dispatching(options, log, fronds, () => journalOf());
@@ -672,6 +683,7 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
672
683
  container, routeRegistry, emissions, dispatcher, localDispatcher, effectiveByKey,
673
684
  boundPorts, refused, relations, hosting, operationModel, entityByName, frondOf, contractsOf,
674
685
  getMiddlewares, use, middlewaresOf: new Map(), seamsOf: new Map(), log, options,
686
+ state: appLifecycle.state(),
675
687
  };
676
688
  for (const frond of fronds) await installFrond(frond, assembly);
677
689
 
@@ -685,7 +697,7 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
685
697
  refuseWhatDoesNotHold(refused);
686
698
  markLineCarriers(emissions);
687
699
 
688
- if (remoteRouter) answerRemotes(assembly, remoteRouter);
700
+ if (remoteRouter) answerRemotes(assembly, remoteRouter, fronds);
689
701
 
690
702
  const { resolve, schemaFor, facadeFor, operationsFor, presenterFor } = readings({
691
703
  container, fronds, remoteRouter, localDispatcher, routeRegistry, effectiveByKey, log,
@@ -725,7 +737,6 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
725
737
  serveCoreRpc(app, hosting, storageFor);
726
738
 
727
739
  built = app;
728
- appLifecycle.add(...frondExtensions(fronds));
729
740
  await appLifecycle.up(app);
730
741
  stopAnnouncing = announceLines(emissions, container, carry);
731
742
 
@@ -1,5 +1,6 @@
1
1
  /** Putting one frond into the app being built: its scope, what it serves, what it takes. */
2
2
  import type { Container } from '@fougere/container';
3
+ import type { StateShape } from '../wire/StateShape.js';
3
4
  import type { ProviderEntry } from '../descriptor/ProviderEntry.js';
4
5
  import type { PresenterEntry } from '../descriptor/PresenterEntry.js';
5
6
  import { lowerFirst, type Fields, type SchemaView } from '@fougere/schema';
@@ -85,6 +86,8 @@ export interface Assembly {
85
86
  seamsOf: Map<string, Map<string, ProviderEntry[]>>;
86
87
  log: Logger;
87
88
  options: CreateAppOptions;
89
+ /** What a call's `state` may hold, read off every extension this process mounts. */
90
+ state: StateShape;
88
91
  }
89
92
 
90
93
  /**
@@ -424,6 +427,7 @@ function buildFacadeInto(
424
427
  presenter: presenterMap.get(handler.address),
425
428
  presenterScope: scope,
426
429
  middlewares: () => getMiddlewares(handler.address),
430
+ state: assembly.state,
427
431
  });
428
432
 
429
433
  // Emissions use the same contracts and execution path as direct calls, and the terms sit
@@ -6,6 +6,7 @@
6
6
  * Documented: [fronds](https://fougere.dev/docs/infra/fronds).
7
7
  */
8
8
  import type { Diagnostic } from '../diagnostic.js';
9
+ import { isFrondDescriptor } from '../descriptor/FrondDescriptor.js';
9
10
  import type { FrondDescriptor } from '../descriptor/FrondDescriptor.js';
10
11
  import { Fronds } from '../descriptor/Fronds.js';
11
12
  import { statesModule, type FrondsStated } from '../FrondsStated.js';
@@ -102,7 +103,7 @@ export function nestingOf(
102
103
  if (frond === undefined) continue;
103
104
 
104
105
  const placed = stated[parent];
105
- const address = typeof placed === 'string' ? placed : placed?.remote;
106
+ const address = typeof placed === 'string' ? placed : isFrondDescriptor(placed) ? undefined : placed?.remote;
106
107
  if (address !== undefined || (remotes && parent in remotes)) {
107
108
  refused.push({
108
109
  severity: 'blocking',
@@ -4,6 +4,7 @@
4
4
  * Documented: [the gradient](https://fougere.dev/docs/infra/gradient).
5
5
  */
6
6
  import type { FrondCall } from '../wire/FrondCall.js';
7
+ import type { StateShape } from '../wire/StateShape.js';
7
8
  import type { Transport } from '../wire/Transport.js';
8
9
  import { RPC_ENTITY } from '../wire/RpcAnswer.js';
9
10
  import { assertIdentityCard } from '../wire/card/IdentityCard.js';
@@ -103,15 +104,19 @@ export function createRemoteFacade(
103
104
  entity: string,
104
105
  router: RemoteRouter,
105
106
  middlewaresFor: (address: string) => AppMiddleware[],
107
+ shape: StateShape,
106
108
  ): Facade {
107
- const opFn = (op: string) => async (invocation: InvocationContext = Invocation.empty) => {
109
+ const opFn = (op: string) => async (received: InvocationContext = Invocation.empty) => {
108
110
  const { frond, transport, schema } = await router.route(entity);
109
111
  const call: FrondCall = { frond, entity, op };
112
+ const state = received.crossed ? { ...received.state } : shape.judge(received.state, entity, op);
113
+ const invocation = { ...received, state };
114
+ const entered = { ...state };
110
115
  const ctx: OperationContext = {
111
- entity, frond, operation: op, args: [], state: invocation.state, invocation,
116
+ entity, frond, operation: op, args: [], state, invocation,
112
117
  };
113
118
  const answer = await runMiddlewares(middlewaresFor(entity), ctx, () =>
114
- transport(call, ctx.invocation ?? invocation));
119
+ transport(call, { ...(ctx.invocation ?? invocation), state: shape.judge(ctx.state, entity, op, entered) }));
115
120
 
116
121
  // The schema the card carried, put to work: a row crosses as data and comes back through
117
122
  // the same codecs a local facade applies, so a placement does not decide what a caller
package/src/boot/seed.ts CHANGED
@@ -1,5 +1,5 @@
1
+ import { pageOf } from '../wire/Page.js';
1
2
  import { Role } from '@fougere/schema';
2
- import { facadeKeyOf } from '../wire/Facade.js';
3
3
  import type { FrondDescriptor } from '../descriptor/FrondDescriptor.js';
4
4
  import type { SeedEntry } from '../descriptor/SeedEntry.js';
5
5
  import type { SeedFactory } from '../descriptor/SeedFactory.js';
@@ -64,14 +64,9 @@ function whatEachWaitsFor(
64
64
  }));
65
65
  }
66
66
 
67
- /** Where a seed writes, and what it may skip — resolved per entity. */
68
- interface SeedFacade {
69
- list(): Promise<unknown[]>;
70
- write(item: Record<string, unknown>): Promise<unknown>;
71
- }
72
-
73
67
  /**
74
- * Plant a set of seeds, in the order given.
68
+ * Plant a set of seeds, in the order given — by the storage, as a handler writes: a seed is the
69
+ * frond's own code, not a client.
75
70
  *
76
71
  * Documented: [seeds](https://fougere.dev/docs/business/seeds).
77
72
  */
@@ -81,26 +76,26 @@ export async function runSeeds(
81
76
  report: (message: string) => void = () => {},
82
77
  ): Promise<void> {
83
78
  for (const seed of seeds) {
84
- const resolve = <T>(name: string) => app.resolve<T>(name + 'Handler');
85
- const data = typeof seed.data === 'function' ? await (seed.data as SeedFactory)(resolve) : seed.data;
86
-
87
- const facade = facadeFor(app, seed.entityName);
88
- if (!facade) {
89
- report(` ${seed.entityName}: no handler façade nor storage — skipping seed`);
79
+ const storage = app.storageFor(seed.entityName);
80
+ if (!storage) {
81
+ report(` ${seed.entityName}: no storage in this process — skipping seed`);
90
82
  continue;
91
83
  }
92
84
 
93
- const existing = await facade.list();
85
+ const existing = pageOf(await storage.list()).items;
94
86
  if (existing.length > 0) {
95
87
  report(` ${seed.entityName}: skipped (${existing.length} exist)`);
96
88
  continue;
97
89
  }
98
90
 
91
+ const resolve = <T>(name: string) => app.resolve<T>(name + 'Handler');
92
+ const data = typeof seed.data === 'function' ? await (seed.data as SeedFactory)(resolve) : seed.data;
93
+
99
94
  // The driver's error alone names neither the entity nor the row — and a foreign
100
95
  // key is exactly what a seed gets wrong.
101
96
  for (const item of data) {
102
97
  try {
103
- await facade.write(item);
98
+ await storage.create(item);
104
99
  } catch (cause) {
105
100
  throw new Error(
106
101
  `Seed '${seed.entityName}' failed on ${JSON.stringify(item)}: ${(cause as Error)?.message ?? cause}`,
@@ -112,26 +107,6 @@ export async function runSeeds(
112
107
  }
113
108
  }
114
109
 
115
- /** A seed is not a client. */
116
- function facadeFor(app: App, entityName: string): SeedFacade | undefined {
117
- let handler: Record<string, Function> | undefined;
118
- try { handler = app.resolve<Record<string, Function>>(facadeKeyOf(entityName)); } catch {}
119
-
120
- const list = handler?.list;
121
- const create = handler?.create;
122
- if (typeof list === 'function' && typeof create === 'function') {
123
- return {
124
- list: () => list.call(handler) as Promise<unknown[]>,
125
- write: (item) => create.call(handler, { params: {}, query: {}, input: item, state: {} }),
126
- };
127
- }
128
-
129
- const storage = app.storageFor(entityName);
130
- if (!storage) return undefined;
131
-
132
- return { list: () => storage.list(), write: (item) => storage.create(item) };
133
- }
134
-
135
110
  /**
136
111
  * The framework's own ascent for rows, named — so a host can REPLACE it rather than take over the
137
112
  * whole post-boot to get its own.
@@ -1,3 +1,5 @@
1
+ import type { Field } from '@fougere/schema';
2
+
1
3
  /**
2
4
  * An extension this frond declares — what rises and falls with the process, written beside the
3
5
  * code it instruments.
@@ -10,6 +12,19 @@ export interface ExtensionEntry {
10
12
  /** Its own `name`, or the file's — the ascent REPLACES a name already declared. */
11
13
  name: string;
12
14
  /** The value handed to the ascent, the same shape `CreateAppOptions.extensions` takes. */
13
- extension: { name?: string; up?: (app: never) => void | Promise<void>; down?: (app: never) => void | Promise<void> };
15
+ extension: {
16
+ name?: string;
17
+ state?: Readonly<Record<string, Field>>;
18
+ up?: (app: never) => void | Promise<void>;
19
+ down?: (app: never) => void | Promise<void>;
20
+ };
14
21
  filePath: string;
15
22
  }
23
+
24
+ /** Recognized by its FORM: a value stating `up`, `down`, or the `state` members it declares. */
25
+ export function isExtension(value: unknown): value is ExtensionEntry['extension'] {
26
+ const shape = value as { up?: unknown; down?: unknown; state?: unknown } | null;
27
+
28
+ return typeof shape?.up === 'function' || typeof shape?.down === 'function'
29
+ || (typeof shape?.state === 'object' && shape.state !== null);
30
+ }
@@ -63,3 +63,10 @@ export interface FrondDescriptor {
63
63
  rest?: { method?: string; path?: string; status?: number };
64
64
  }>;
65
65
  }
66
+
67
+ /** Recognized by its FORM, like everything a config holds: a name and the lists every frond carries. */
68
+ export function isFrondDescriptor(value: unknown): value is FrondDescriptor {
69
+ const shape = value as Partial<Record<keyof FrondDescriptor, unknown>> | null;
70
+
71
+ return typeof shape?.name === 'string' && Array.isArray(shape.entities) && Array.isArray(shape.handlers);
72
+ }
@@ -1,6 +1,7 @@
1
1
  import type { InvocationContext } from '../wire/InvocationContext.js';
2
2
  import type { BindingPlan } from '../wire/binding.js';
3
3
  import type { CollectorLookup } from './CollectorLookup.js';
4
+ import { collectedAs } from '../prefab/collector.js';
4
5
 
5
6
  /** Resolves an operation's declared binding plan against one invocation. */
6
7
  export class ArgumentResolver {
@@ -13,7 +14,7 @@ export class ArgumentResolver {
13
14
  switch (binding.source.kind) {
14
15
  case 'collector': {
15
16
  const collector = this.collectors?.(binding.source.typeName);
16
- args.push(collector ? await collector.collect(ctx) : undefined);
17
+ args.push(collector ? collectedAs(collector, await collector.collect(ctx)) : undefined);
17
18
  break;
18
19
  }
19
20
  case 'context': {
@@ -3,6 +3,7 @@ import { type AppMiddleware } from '../wire/AppMiddleware.js';
3
3
  import type { EffectiveOperationsMap } from '../EffectiveOperationsMap.js';
4
4
  import type { HandlerEntry } from '../descriptor/HandlerEntry.js';
5
5
  import type { PresenterEntry } from '../descriptor/PresenterEntry.js';
6
+ import type { StateShape } from '../wire/StateShape.js';
6
7
 
7
8
  /** What boot resolved around one handler, beyond the handler and the scope it resolves in. */
8
9
  export interface Facade {
@@ -22,4 +23,6 @@ export interface Facade {
22
23
  presenterScope: Container;
23
24
  /** The middlewares that apply to this address, read at call time and never at boot. */
24
25
  middlewares: () => AppMiddleware[];
26
+ /** What a call's `state` may hold in this process. */
27
+ state: StateShape;
25
28
  }
@@ -88,7 +88,10 @@ export class HandlerFacade {
88
88
  );
89
89
  }
90
90
 
91
- const invocation = Invocation.from(input);
91
+ const received = Invocation.from(input);
92
+ const state = received.crossed ? { ...received.state } : this.facade.state.judge(received.state, entity, op);
93
+ const invocation = received.withState(state);
94
+ const entered = { ...state };
92
95
  const context: OperationContext = {
93
96
  entity,
94
97
  frond: this.facade.frond,
@@ -98,7 +101,7 @@ export class HandlerFacade {
98
101
  invocation,
99
102
  };
100
103
 
101
- return runMiddlewares(this.facade.middlewares(), context, () => this.answer(op, contract, context, invocation));
104
+ return runMiddlewares(this.facade.middlewares(), context, () => this.answer(op, contract, context, invocation, entered));
102
105
  }
103
106
 
104
107
  /**
@@ -110,8 +113,10 @@ export class HandlerFacade {
110
113
  contract: OperationContract,
111
114
  context: OperationContext,
112
115
  invocation: Invocation,
116
+ entered: Record<string, unknown>,
113
117
  ): Promise<unknown> {
114
- const validated = validateInput(contract.input, invocation, this.handler.address, op);
118
+ const state = this.facade.state.judge(context.state, this.handler.address, op, entered);
119
+ const validated = validateInput(contract.input, { ...invocation, state }, this.handler.address, op);
115
120
  context.invocation = validated;
116
121
 
117
122
  const args = contract.binding ? await this.arguments.resolve(contract.binding, validated) : [];
@@ -4,7 +4,9 @@
4
4
  * Documented: [collectors](https://fougere.dev/docs/business/collectors).
5
5
  */
6
6
 
7
- import { upperFirst } from '@fougere/schema';
7
+ import { FieldValueValidator, dotted, json, upperFirst, type Field, type SchemaView } from '@fougere/schema';
8
+ import { ErrorCode } from '../wire/ErrorCode.js';
9
+ import { FougereError } from '../wire/FougereError.js';
8
10
 
9
11
  export function Collector<T extends abstract new (...args: any[]) => any>(target: T) {
10
12
  class CollectorBase {
@@ -18,3 +20,29 @@ export function Collector<T extends abstract new (...args: any[]) => any>(target
18
20
  export function collectorKeyOf(type: string): string {
19
21
  return `${upperFirst(type)}Collector`;
20
22
  }
23
+
24
+ const readers = new WeakMap<object, Field>();
25
+
26
+ /**
27
+ * What a collector answered, read by the entity it names — judged and rebuilt, so a `User` that
28
+ * crossed a process as JSON reaches the handler with its dates. A collector naming no entity
29
+ * answers what it built.
30
+ */
31
+ export function collectedAs(collector: object, value: unknown): unknown {
32
+ const target = (collector.constructor as { __entity?: Partial<SchemaView> }).__entity;
33
+ if (value === null || value === undefined || typeof target?.getFields !== 'function') return value;
34
+
35
+ let reader = readers.get(target);
36
+ if (!reader) {
37
+ reader = json(target as SchemaView & (new () => unknown));
38
+ readers.set(target, reader);
39
+ }
40
+
41
+ const verdict = FieldValueValidator.of(reader).parse(value);
42
+ if ('value' in verdict) return verdict.value;
43
+
44
+ throw new FougereError({
45
+ code: ErrorCode.VALIDATION_FAILED,
46
+ message: `${collector.constructor.name}: ${verdict.path?.length ? `${dotted(verdict.path)}: ` : ''}${verdict.message}`,
47
+ });
48
+ }
@@ -2,15 +2,21 @@ import { upperFirst, lowerFirst, type SchemaView } from '@fougere/schema';
2
2
  import type { ListOptions } from './ListOptions.js';
3
3
  import type { ListResult } from './ListResult.js';
4
4
  import type { SelectOption } from './SelectOption.js';
5
+ import { ErrorCode } from '../wire/ErrorCode.js';
6
+ import { FougereError } from '../wire/FougereError.js';
5
7
 
6
- /** The keys `list()` answers to. */
7
- const LIST_OPTION_KEYS = [
8
- 'limit', 'offset', 'page', 'after', 'orderBy', 'order', 'count', 'where', 'select',
9
- ] as const;
8
+ /** The keys `list()` answers to, checked against `ListOptions` so neither can drift. */
9
+ const LIST_OPTION_KEYS = Object.keys({
10
+ limit: true, offset: true, page: true, after: true, orderBy: true, order: true, count: true, where: true, select: true,
11
+ } satisfies Record<keyof (ListOptions & SelectOption), true>);
12
+
13
+ /** What a count must be — a text that did not read as a number arrives as `NaN`. */
14
+ const COUNTS = ['limit', 'offset', 'page'] as const;
10
15
 
11
16
  /**
12
- * Refuse an option the port does not answer to, and an order by a field the entity does not
13
- * declare — an unhonoured `orderBy` returns the page in whatever order the engine chose.
17
+ * Refuse an option the port does not answer to, a count that is not one, and an order by a
18
+ * field the entity does not declare — an unhonoured `orderBy` returns the page in whatever order
19
+ * the engine chose. Refused as BAD_REQUEST: a plain Error left as INTERNAL_ERROR, its message hidden.
14
20
  */
15
21
  export function assertListOptions(
16
22
  options: object | undefined,
@@ -18,21 +24,29 @@ export function assertListOptions(
18
24
  declared: readonly string[],
19
25
  ): void {
20
26
  if (!options) return;
21
- const legal = new Set<string>(LIST_OPTION_KEYS);
22
- const strangers = Object.keys(options).filter((key) => !legal.has(key));
27
+ const strangers = Object.keys(options).filter((key) => !LIST_OPTION_KEYS.includes(key));
23
28
  if (strangers.length > 0) {
24
- throw new Error(
29
+ refuse(
25
30
  `${entity}.list(): unknown option ${strangers.map((s) => `\`${s}\``).join(', ')}. ` +
26
31
  `Known options are ${LIST_OPTION_KEYS.join(', ')} — to filter, pass \`where: { ${strangers[0]}: … }\`.`,
27
32
  );
28
33
  }
29
34
 
35
+ for (const count of COUNTS) {
36
+ const value = (options as ListOptions)[count];
37
+ if (value !== undefined && !(Number.isInteger(value) && value >= 0)) {
38
+ refuse(`${entity}.list(): \`${count}\` is ${typeof value === 'number' ? value : JSON.stringify(value)}, not a whole number.`);
39
+ }
40
+ }
41
+
30
42
  const { orderBy } = options as ListOptions;
31
43
  if (orderBy === undefined || declared.includes(orderBy)) return;
32
44
 
33
- throw new Error(
34
- `${entity}.list(): unknown orderBy \`${orderBy}\`. ${entity} declares ${declared.join(', ')}.`,
35
- );
45
+ refuse(`${entity}.list(): unknown orderBy \`${orderBy}\`. ${entity} declares ${declared.join(', ')}.`);
46
+ }
47
+
48
+ function refuse(message: string): never {
49
+ throw new FougereError({ code: ErrorCode.BAD_REQUEST, message });
36
50
  }
37
51
 
38
52
  /** Per-entity storage — scoped CRUD operations on a single entity type. */
@@ -1,6 +1,8 @@
1
1
  import { applyCreate, applyUpdate, Role, type SchemaView } from '@fougere/schema';
2
2
  import { comparisonOf, comparisonsIn, type Comparison } from './Comparison.js';
3
3
  import type { Storage } from './Storage.js';
4
+ import { ErrorCode } from '../wire/ErrorCode.js';
5
+ import { FougereError } from '../wire/FougereError.js';
4
6
  import type { StorageFactory } from './StorageFactory.js';
5
7
  import type { Values } from './Values.js';
6
8
  import type { ListResult } from './ListResult.js';
@@ -128,7 +130,7 @@ export function storageOver(open: (entity: SchemaView, name: string) => Store):
128
130
  // constraint violation, and a store that loses data silently is worse than one
129
131
  // that fails.
130
132
  if (await store.has(keyOf(id))) {
131
- throw new Error(`${name}.create: '${pk}' ${JSON.stringify(id)} already exists.`);
133
+ throw new FougereError({ code: ErrorCode.CONFLICT, message: `${name}.create: '${pk}' ${JSON.stringify(id)} already exists.` });
132
134
  }
133
135
  await store.set(keyOf(id), values);
134
136
 
@@ -136,7 +138,7 @@ export function storageOver(open: (entity: SchemaView, name: string) => Store):
136
138
  },
137
139
  async update(id: string, input: Partial<Record<string, unknown>>) {
138
140
  const existing = await store.get(keyOf(id));
139
- if (!existing) throw new Error(`Not found: ${id}`);
141
+ if (!existing) throw new FougereError({ code: ErrorCode.NOT_FOUND, message: `No row in ${name} for ${JSON.stringify(id)}.` });
140
142
  const updated = { ...existing, ...applyUpdate(fields, input), [pk]: existing[pk] };
141
143
  await store.set(keyOf(id), updated);
142
144
 
@@ -36,8 +36,9 @@ export class Invocation implements InvocationContext {
36
36
  readonly identity?: string;
37
37
  readonly caller?: string;
38
38
  readonly runAt?: number;
39
+ readonly crossed?: true;
39
40
 
40
- /** Gives every entry and every transport the same eight-member value, frozen. */
41
+ /** Gives every entry and every transport the same nine-member value, frozen. */
41
42
  private constructor(context: PartialInvocation) {
42
43
  this.params = canonicalRecord(context.params);
43
44
  this.query = canonicalRecord(context.query);
@@ -49,6 +50,7 @@ export class Invocation implements InvocationContext {
49
50
  if (context.identity !== undefined) this.identity = context.identity;
50
51
  if (context.caller !== undefined) this.caller = context.caller;
51
52
  if (context.runAt !== undefined) this.runAt = context.runAt;
53
+ if (context.crossed) this.crossed = true;
52
54
  Object.freeze(this);
53
55
  }
54
56
 
@@ -8,4 +8,6 @@ export interface InvocationContext {
8
8
  caller?: string;
9
9
  /** Epoch milliseconds before which this call is not to run. */
10
10
  runAt?: number;
11
+ /** Written by the receiver of a call from another process, whose state was judged where it entered. */
12
+ crossed?: true;
11
13
  }
@@ -0,0 +1,76 @@
1
+ import { InputValidator, dotted, optional, type Field, type Fields } from '@fougere/schema';
2
+ import { ErrorCode } from './ErrorCode.js';
3
+ import { FougereError } from './FougereError.js';
4
+
5
+ /** An extension and the members it puts on a call's `state`. */
6
+ export interface StateDeclaration {
7
+ name: string;
8
+ state?: Readonly<Record<string, Field>>;
9
+ }
10
+
11
+ /**
12
+ * What a call's `state` may hold in this process: the members its extensions declare, each one
13
+ * absent until a host fills it. Judged at the facade, so a member arriving over the wire is
14
+ * rebuilt by its field exactly as it would have been handed over in-process.
15
+ *
16
+ * Documented: [collectors](https://fougere.dev/docs/business/collectors).
17
+ */
18
+ export class StateShape {
19
+ static readonly empty = new StateShape({});
20
+
21
+ private readonly validator: InputValidator;
22
+
23
+ private constructor(private readonly fields: Fields) {
24
+ this.validator = InputValidator.of(fields);
25
+ }
26
+
27
+ /** Two extensions declaring one member refuse: the field that judges it would depend on wiring order. */
28
+ static of(declarations: readonly StateDeclaration[]): StateShape {
29
+ const fields: Fields = {};
30
+ const owners = new Map<string, string>();
31
+ for (const { name, state } of declarations) {
32
+ for (const [member, field] of Object.entries(state ?? {})) {
33
+ const owner = owners.get(member);
34
+ if (owner) {
35
+ throw new Error(
36
+ `[state] '${member}' is declared by two extensions, '${owner}' and '${name}'.\n`
37
+ + ' A member has one owner — keep one of them out of `extensions:`.',
38
+ );
39
+ }
40
+ owners.set(member, name);
41
+ fields[member] = optional(field);
42
+ }
43
+ }
44
+
45
+ return new StateShape(fields);
46
+ }
47
+
48
+ /**
49
+ * The members written since `entered` rebuilt by their fields, or a refusal naming the member
50
+ * and what this process declares. What `entered` already held was judged where it entered.
51
+ */
52
+ judge(
53
+ state: Record<string, unknown>,
54
+ entity: string,
55
+ operation: string,
56
+ entered: Record<string, unknown> = {},
57
+ ): Record<string, unknown> {
58
+ const written = Object.fromEntries(Object.entries(state).filter(([member, value]) => entered[member] !== value));
59
+ if (Object.keys(written).length === 0) return state;
60
+
61
+ const result = this.validator.validate(written);
62
+ if (result.success) return { ...state, ...result.data };
63
+
64
+ const declared = Object.keys(this.fields);
65
+ const errors = result.errors.map((error) => ({ ...error, path: ['state', ...error.path] }));
66
+
67
+ throw new FougereError({
68
+ code: ErrorCode.VALIDATION_FAILED,
69
+ message: errors.map((error) => `${dotted(error.path)}: ${error.message}`).join(', ')
70
+ + (declared.length ? ` — this process declares ${declared.join(', ')}` : ' — this process declares no state member'),
71
+ details: errors,
72
+ entity,
73
+ operation,
74
+ });
75
+ }
76
+ }