@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.
- package/README.md +1 -1
- package/dist/BootRefusal.d.ts +9 -0
- package/dist/BootRefusal.d.ts.map +1 -0
- package/dist/BootRefusal.js +12 -0
- package/dist/BootRefusal.js.map +1 -0
- package/dist/CallIdentity.d.ts.map +1 -1
- package/dist/CallIdentity.js +2 -1
- package/dist/CallIdentity.js.map +1 -1
- package/dist/boot/AppLifecycle.d.ts.map +1 -1
- package/dist/boot/AppLifecycle.js +2 -1
- package/dist/boot/AppLifecycle.js.map +1 -1
- package/dist/boot/Emissions.d.ts +8 -1
- package/dist/boot/Emissions.d.ts.map +1 -1
- package/dist/boot/Emissions.js +16 -11
- package/dist/boot/Emissions.js.map +1 -1
- package/dist/boot/bootstrap.d.ts.map +1 -1
- package/dist/boot/bootstrap.js +22 -9
- package/dist/boot/bootstrap.js.map +1 -1
- package/dist/boot/card.d.ts.map +1 -1
- package/dist/boot/card.js.map +1 -1
- 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/ownership.d.ts.map +1 -1
- package/dist/boot/ownership.js +26 -14
- package/dist/boot/ownership.js.map +1 -1
- package/dist/diagnostic.d.ts +2 -1
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +2 -1
- package/dist/diagnostic.js.map +1 -1
- package/dist/dispatch/ArgumentResolver.d.ts +13 -1
- package/dist/dispatch/ArgumentResolver.d.ts.map +1 -1
- package/dist/dispatch/ArgumentResolver.js +48 -2
- package/dist/dispatch/ArgumentResolver.js.map +1 -1
- package/dist/dispatch/EntityLookup.d.ts +4 -0
- package/dist/dispatch/EntityLookup.d.ts.map +1 -0
- package/dist/dispatch/EntityLookup.js +2 -0
- package/dist/dispatch/EntityLookup.js.map +1 -0
- package/dist/dispatch/Facade.d.ts +3 -0
- package/dist/dispatch/Facade.d.ts.map +1 -1
- package/dist/dispatch/HandlerFacade.d.ts +2 -0
- package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
- package/dist/dispatch/HandlerFacade.js +14 -4
- package/dist/dispatch/HandlerFacade.js.map +1 -1
- package/dist/entry/facade.d.ts +10 -1
- package/dist/entry/facade.d.ts.map +1 -1
- package/dist/entry/facade.js +26 -2
- package/dist/entry/facade.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/prefab/CrudConstructor.d.ts.map +1 -1
- package/dist/prefab/CrudConstructor.js +5 -4
- package/dist/prefab/CrudConstructor.js.map +1 -1
- package/dist/prefab/Middleware.d.ts +13 -0
- package/dist/prefab/Middleware.d.ts.map +1 -0
- package/dist/prefab/Middleware.js +10 -0
- package/dist/prefab/Middleware.js.map +1 -0
- package/dist/wire/Facade.d.ts +5 -7
- package/dist/wire/Facade.d.ts.map +1 -1
- package/dist/wire/Facade.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 +6 -0
- package/dist/wire/InvocationContext.d.ts.map +1 -1
- package/dist/wire/OperationContract.js +2 -2
- package/dist/wire/OperationContract.js.map +1 -1
- package/dist/wire/SignedCall.d.ts +2 -0
- package/dist/wire/SignedCall.d.ts.map +1 -1
- package/dist/wire/TypeRef.d.ts +5 -3
- package/dist/wire/TypeRef.d.ts.map +1 -1
- package/dist/wire/binding.d.ts +8 -1
- package/dist/wire/binding.d.ts.map +1 -1
- package/dist/wire/binding.js +2 -2
- package/dist/wire/binding.js.map +1 -1
- package/package.json +3 -3
- package/src/BootRefusal.ts +11 -0
- package/src/CallIdentity.ts +2 -1
- package/src/boot/AppLifecycle.ts +2 -1
- package/src/boot/Emissions.ts +18 -11
- package/src/boot/bootstrap.ts +25 -10
- package/src/boot/card.ts +1 -2
- package/src/boot/install.ts +1 -0
- package/src/boot/ownership.ts +35 -16
- package/src/diagnostic.ts +4 -2
- package/src/dispatch/ArgumentResolver.ts +54 -2
- package/src/dispatch/EntityLookup.ts +4 -0
- package/src/dispatch/Facade.ts +3 -0
- package/src/dispatch/HandlerFacade.ts +14 -6
- package/src/entry/facade.ts +38 -2
- package/src/index.ts +2 -0
- package/src/prefab/CrudConstructor.ts +6 -5
- package/src/prefab/Middleware.ts +13 -0
- package/src/wire/Facade.ts +6 -9
- package/src/wire/Invocation.ts +3 -1
- package/src/wire/InvocationContext.ts +6 -0
- package/src/wire/OperationContract.ts +2 -2
- package/src/wire/SignedCall.ts +2 -0
- package/src/wire/TypeRef.ts +4 -2
- package/src/wire/binding.ts +7 -4
package/src/boot/Emissions.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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);
|
package/src/boot/bootstrap.ts
CHANGED
|
@@ -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
|
-
?
|
|
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, (
|
|
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 {
|
package/src/boot/install.ts
CHANGED
|
@@ -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,
|
package/src/boot/ownership.ts
CHANGED
|
@@ -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
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
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):
|
|
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
|
|
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(
|
|
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;
|
package/src/dispatch/Facade.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
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
|
package/src/entry/facade.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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',
|
|
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
|
|
56
|
-
const
|
|
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
|
+
}
|
package/src/wire/Facade.ts
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* promise comes back where the handler may answer a bare value
|
|
11
|
-
* crossing
|
|
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:
|
|
20
|
-
? (
|
|
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
|
|
package/src/wire/Invocation.ts
CHANGED
|
@@ -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
|
|
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', '
|
|
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
|
/**
|