@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.
- package/dist/FougereConfig.d.ts +0 -3
- package/dist/FougereConfig.d.ts.map +1 -1
- package/dist/FougereConfig.js.map +1 -1
- package/dist/FrondDeclaration.d.ts +5 -0
- package/dist/FrondDeclaration.d.ts.map +1 -1
- package/dist/FrondDeclaration.js +3 -0
- package/dist/FrondDeclaration.js.map +1 -1
- package/dist/FrondsStated.d.ts +4 -2
- package/dist/FrondsStated.d.ts.map +1 -1
- package/dist/FrondsStated.js.map +1 -1
- package/dist/StatedFrond.d.ts +3 -0
- package/dist/StatedFrond.d.ts.map +1 -1
- package/dist/StatedFrond.js +4 -0
- package/dist/StatedFrond.js.map +1 -1
- package/dist/StatedModules.d.ts +5 -2
- package/dist/StatedModules.d.ts.map +1 -1
- package/dist/StatedModules.js +11 -6
- package/dist/StatedModules.js.map +1 -1
- package/dist/boot/AppLifecycle.d.ts +3 -0
- package/dist/boot/AppLifecycle.d.ts.map +1 -1
- package/dist/boot/AppLifecycle.js +5 -0
- package/dist/boot/AppLifecycle.js.map +1 -1
- package/dist/boot/Extension.d.ts +6 -0
- package/dist/boot/Extension.d.ts.map +1 -1
- package/dist/boot/bootstrap.d.ts.map +1 -1
- package/dist/boot/bootstrap.js +14 -5
- package/dist/boot/bootstrap.js.map +1 -1
- package/dist/boot/install.d.ts +3 -0
- package/dist/boot/install.d.ts.map +1 -1
- package/dist/boot/install.js +1 -0
- package/dist/boot/install.js.map +1 -1
- package/dist/boot/nesting.d.ts.map +1 -1
- package/dist/boot/nesting.js +2 -1
- package/dist/boot/nesting.js.map +1 -1
- package/dist/boot/remote.d.ts +2 -1
- package/dist/boot/remote.d.ts.map +1 -1
- package/dist/boot/remote.js +7 -4
- package/dist/boot/remote.js.map +1 -1
- package/dist/boot/seed.d.ts +2 -1
- package/dist/boot/seed.d.ts.map +1 -1
- package/dist/boot/seed.js +10 -29
- package/dist/boot/seed.js.map +1 -1
- package/dist/descriptor/ExtensionEntry.d.ts +4 -0
- package/dist/descriptor/ExtensionEntry.d.ts.map +1 -1
- package/dist/descriptor/ExtensionEntry.js +6 -1
- package/dist/descriptor/ExtensionEntry.js.map +1 -1
- package/dist/descriptor/FrondDescriptor.d.ts +2 -0
- package/dist/descriptor/FrondDescriptor.d.ts.map +1 -1
- package/dist/descriptor/FrondDescriptor.js +5 -0
- package/dist/descriptor/FrondDescriptor.js.map +1 -1
- package/dist/dispatch/ArgumentResolver.d.ts.map +1 -1
- package/dist/dispatch/ArgumentResolver.js +2 -1
- package/dist/dispatch/ArgumentResolver.js.map +1 -1
- package/dist/dispatch/Facade.d.ts +3 -0
- package/dist/dispatch/Facade.d.ts.map +1 -1
- package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
- package/dist/dispatch/HandlerFacade.js +8 -4
- package/dist/dispatch/HandlerFacade.js.map +1 -1
- package/dist/prefab/collector.d.ts +6 -0
- package/dist/prefab/collector.d.ts.map +1 -1
- package/dist/prefab/collector.js +26 -1
- package/dist/prefab/collector.js.map +1 -1
- package/dist/storage/Storage.d.ts +3 -2
- package/dist/storage/Storage.d.ts.map +1 -1
- package/dist/storage/Storage.js +23 -10
- package/dist/storage/Storage.js.map +1 -1
- package/dist/storage/Store.d.ts.map +1 -1
- package/dist/storage/Store.js +4 -2
- package/dist/storage/Store.js.map +1 -1
- package/dist/wire/Invocation.d.ts +2 -1
- package/dist/wire/Invocation.d.ts.map +1 -1
- package/dist/wire/Invocation.js +4 -1
- package/dist/wire/Invocation.js.map +1 -1
- package/dist/wire/InvocationContext.d.ts +2 -0
- package/dist/wire/InvocationContext.d.ts.map +1 -1
- package/dist/wire/StateShape.d.ts +27 -0
- package/dist/wire/StateShape.d.ts.map +1 -0
- package/dist/wire/StateShape.js +59 -0
- package/dist/wire/StateShape.js.map +1 -0
- package/package.json +4 -4
- package/src/FougereConfig.ts +0 -3
- package/src/FrondDeclaration.ts +6 -0
- package/src/FrondsStated.ts +4 -2
- package/src/StatedFrond.ts +5 -0
- package/src/StatedModules.ts +17 -9
- package/src/boot/AppLifecycle.ts +6 -0
- package/src/boot/Extension.ts +6 -0
- package/src/boot/bootstrap.ts +16 -5
- package/src/boot/install.ts +4 -0
- package/src/boot/nesting.ts +2 -1
- package/src/boot/remote.ts +8 -3
- package/src/boot/seed.ts +11 -36
- package/src/descriptor/ExtensionEntry.ts +16 -1
- package/src/descriptor/FrondDescriptor.ts +7 -0
- package/src/dispatch/ArgumentResolver.ts +2 -1
- package/src/dispatch/Facade.ts +3 -0
- package/src/dispatch/HandlerFacade.ts +8 -3
- package/src/prefab/collector.ts +29 -1
- package/src/storage/Storage.ts +26 -12
- package/src/storage/Store.ts +4 -2
- package/src/wire/Invocation.ts +3 -1
- package/src/wire/InvocationContext.ts +2 -0
- package/src/wire/StateShape.ts +76 -0
package/src/boot/bootstrap.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/boot/install.ts
CHANGED
|
@@ -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
|
package/src/boot/nesting.ts
CHANGED
|
@@ -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',
|
package/src/boot/remote.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
85
|
-
|
|
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
|
|
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
|
|
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: {
|
|
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': {
|
package/src/dispatch/Facade.ts
CHANGED
|
@@ -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
|
|
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
|
|
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) : [];
|
package/src/prefab/collector.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/storage/Storage.ts
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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
|
|
13
|
-
* declare — an unhonoured `orderBy` returns the page in whatever order
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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. */
|
package/src/storage/Store.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/src/wire/Invocation.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|