@fougere/core 0.11.0-alpha.0 → 0.12.0-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 +10 -6
- package/dist/FougereConfig.d.ts.map +1 -1
- package/dist/FougereConfig.js +19 -4
- package/dist/FougereConfig.js.map +1 -1
- package/dist/FrondConfig.d.ts +0 -6
- package/dist/FrondConfig.d.ts.map +1 -1
- package/dist/FrondConfig.js.map +1 -1
- package/dist/FrondDeclaration.d.ts +2 -9
- package/dist/FrondDeclaration.d.ts.map +1 -1
- package/dist/FrondDeclaration.js +0 -1
- package/dist/FrondDeclaration.js.map +1 -1
- package/dist/FrondsStated.d.ts +54 -0
- package/dist/FrondsStated.d.ts.map +1 -0
- package/dist/FrondsStated.js +26 -0
- package/dist/FrondsStated.js.map +1 -0
- package/dist/StatedFrond.d.ts +21 -0
- package/dist/StatedFrond.d.ts.map +1 -0
- package/dist/StatedFrond.js +34 -0
- package/dist/StatedFrond.js.map +1 -0
- package/dist/StatedModules.d.ts +19 -0
- package/dist/StatedModules.d.ts.map +1 -0
- package/dist/StatedModules.js +37 -0
- package/dist/StatedModules.js.map +1 -0
- package/dist/boot/CreateAppOptions.d.ts +19 -2
- package/dist/boot/CreateAppOptions.d.ts.map +1 -1
- package/dist/boot/Emissions.d.ts +9 -0
- package/dist/boot/Emissions.d.ts.map +1 -1
- package/dist/boot/Emissions.js +11 -5
- package/dist/boot/Emissions.js.map +1 -1
- package/dist/boot/bootstrap.d.ts.map +1 -1
- package/dist/boot/bootstrap.js +342 -307
- package/dist/boot/bootstrap.js.map +1 -1
- package/dist/boot/declared.d.ts.map +1 -1
- package/dist/boot/declared.js +3 -0
- package/dist/boot/declared.js.map +1 -1
- package/dist/boot/frame.d.ts +0 -4
- package/dist/boot/frame.d.ts.map +1 -1
- package/dist/boot/frame.js +31 -14
- package/dist/boot/frame.js.map +1 -1
- package/dist/boot/hosted.d.ts.map +1 -1
- package/dist/boot/hosted.js +4 -3
- package/dist/boot/hosted.js.map +1 -1
- package/dist/boot/install.d.ts +9 -0
- package/dist/boot/install.d.ts.map +1 -1
- package/dist/boot/install.js +271 -185
- package/dist/boot/install.js.map +1 -1
- package/dist/boot/nesting.d.ts +33 -0
- package/dist/boot/nesting.d.ts.map +1 -0
- package/dist/boot/nesting.js +164 -0
- package/dist/boot/nesting.js.map +1 -0
- package/dist/boot/ownership.d.ts +0 -1
- package/dist/boot/ownership.d.ts.map +1 -1
- package/dist/boot/ownership.js +66 -51
- package/dist/boot/ownership.js.map +1 -1
- package/dist/boot/ports.js +30 -25
- package/dist/boot/ports.js.map +1 -1
- package/dist/boot/remote.d.ts.map +1 -1
- package/dist/boot/remote.js +31 -28
- package/dist/boot/remote.js.map +1 -1
- package/dist/boot/seed.d.ts.map +1 -1
- package/dist/boot/seed.js +27 -20
- package/dist/boot/seed.js.map +1 -1
- package/dist/boot/together.d.ts.map +1 -1
- package/dist/boot/together.js +71 -56
- package/dist/boot/together.js.map +1 -1
- package/dist/descriptor/FrondDescriptor.d.ts +7 -0
- package/dist/descriptor/FrondDescriptor.d.ts.map +1 -1
- package/dist/descriptor/MiddlewareEntry.d.ts +6 -5
- package/dist/descriptor/MiddlewareEntry.d.ts.map +1 -1
- package/dist/descriptor/ProviderEntry.d.ts +7 -0
- package/dist/descriptor/ProviderEntry.d.ts.map +1 -1
- package/dist/dispatch/HandlerFacade.d.ts +6 -1
- package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
- package/dist/dispatch/HandlerFacade.js +23 -20
- package/dist/dispatch/HandlerFacade.js.map +1 -1
- package/dist/dispatch/PresenterExecutor.d.ts +6 -0
- package/dist/dispatch/PresenterExecutor.d.ts.map +1 -1
- package/dist/dispatch/PresenterExecutor.js +30 -22
- package/dist/dispatch/PresenterExecutor.js.map +1 -1
- package/dist/dispatch/Release.js +53 -47
- package/dist/dispatch/Release.js.map +1 -1
- package/dist/dispatch/StorageGuard.d.ts +21 -0
- package/dist/dispatch/StorageGuard.d.ts.map +1 -1
- package/dist/dispatch/StorageGuard.js +84 -66
- package/dist/dispatch/StorageGuard.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +2 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +2 -1
- package/dist/node.js.map +1 -1
- package/dist/storage/Storage.js +1 -1
- package/dist/storage/Storage.js.map +1 -1
- package/dist/storage/Store.d.ts.map +1 -1
- package/dist/storage/Store.js +29 -21
- package/dist/storage/Store.js.map +1 -1
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +14 -0
- package/dist/verify.js.map +1 -1
- package/dist/wire/Invocation.d.ts +1 -1
- package/dist/wire/Invocation.d.ts.map +1 -1
- package/dist/wire/Invocation.js +1 -1
- package/dist/wire/Invocation.js.map +1 -1
- package/dist/wire/binding.d.ts.map +1 -1
- package/dist/wire/binding.js +29 -57
- package/dist/wire/binding.js.map +1 -1
- package/dist/wire/drift.d.ts.map +1 -1
- package/dist/wire/drift.js +22 -19
- package/dist/wire/drift.js.map +1 -1
- package/package.json +4 -4
- package/src/FougereConfig.ts +24 -10
- package/src/FrondConfig.ts +0 -6
- package/src/FrondDeclaration.ts +2 -7
- package/src/FrondsStated.ts +72 -0
- package/src/StatedFrond.ts +51 -0
- package/src/StatedModules.ts +58 -0
- package/src/boot/CreateAppOptions.ts +19 -2
- package/src/boot/Emissions.ts +16 -5
- package/src/boot/bootstrap.ts +478 -359
- package/src/boot/declared.ts +3 -0
- package/src/boot/frame.ts +42 -11
- package/src/boot/hosted.ts +4 -3
- package/src/boot/install.ts +388 -212
- package/src/boot/nesting.ts +196 -0
- package/src/boot/ownership.ts +79 -51
- package/src/boot/ports.ts +45 -31
- package/src/boot/remote.ts +47 -29
- package/src/boot/seed.ts +36 -27
- package/src/boot/together.ts +98 -63
- package/src/descriptor/FrondDescriptor.ts +7 -0
- package/src/descriptor/MiddlewareEntry.ts +6 -5
- package/src/descriptor/ProviderEntry.ts +7 -0
- package/src/dispatch/HandlerFacade.ts +27 -17
- package/src/dispatch/PresenterExecutor.ts +33 -22
- package/src/dispatch/Release.ts +68 -44
- package/src/dispatch/StorageGuard.ts +90 -69
- package/src/index.ts +3 -0
- package/src/node.ts +2 -1
- package/src/storage/Storage.ts +2 -2
- package/src/storage/Store.ts +39 -18
- package/src/verify.ts +15 -0
- package/src/wire/Invocation.ts +2 -1
- package/src/wire/binding.ts +26 -58
- package/src/wire/drift.ts +48 -21
package/src/boot/together.ts
CHANGED
|
@@ -48,42 +48,59 @@ function resolve(
|
|
|
48
48
|
refused: Diagnostic[],
|
|
49
49
|
): Members | undefined {
|
|
50
50
|
const before = refused.length;
|
|
51
|
+
const entities = entitiesNamed(names.entities, world, asked, refused);
|
|
52
|
+
const providers = providersNamed(names.providers, declared, asked, refused);
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
return refused.length === before ? { entities, providers } : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The first list names ENTITIES — a class of this frond goes in the second. */
|
|
58
|
+
function entitiesNamed(
|
|
59
|
+
named: readonly string[],
|
|
60
|
+
world: FrameWorld,
|
|
61
|
+
asked: Asked,
|
|
62
|
+
refused: Diagnostic[],
|
|
63
|
+
): { name: string; schema: SchemaView }[] {
|
|
64
|
+
return named.flatMap((member) => {
|
|
53
65
|
const name = lowerFirst(member);
|
|
54
66
|
const schema = world.entityByName.get(name);
|
|
55
|
-
if (
|
|
56
|
-
refused.push({
|
|
57
|
-
...asked,
|
|
58
|
-
code: 'together-entity-unknown',
|
|
59
|
-
message: `Together<[…${member}…]>: no entity named '${member}' is scanned in this app. `
|
|
60
|
-
+ 'The first list names entities; a class of this frond goes in the second.',
|
|
61
|
-
});
|
|
67
|
+
if (schema) return [{ name, schema }];
|
|
62
68
|
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
refused.push({
|
|
70
|
+
...asked,
|
|
71
|
+
code: 'together-entity-unknown',
|
|
72
|
+
message: `Together<[…${member}…]>: no entity named '${member}' is scanned in this app. `
|
|
73
|
+
+ 'The first list names entities; a class of this frond goes in the second.',
|
|
74
|
+
});
|
|
65
75
|
|
|
66
|
-
return [
|
|
76
|
+
return [];
|
|
67
77
|
});
|
|
78
|
+
}
|
|
68
79
|
|
|
69
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The second list names the classes to REBUILD inside the frame — a service, a mirror, a
|
|
82
|
+
* repository — so that what they write is covered by the unwind.
|
|
83
|
+
*/
|
|
84
|
+
function providersNamed(
|
|
85
|
+
named: readonly string[],
|
|
86
|
+
declared: readonly ProviderEntry[],
|
|
87
|
+
asked: Asked,
|
|
88
|
+
refused: Diagnostic[],
|
|
89
|
+
): ProviderEntry[] {
|
|
90
|
+
return named.flatMap((member) => {
|
|
70
91
|
const entry = declared.find((provider) => provider.ctor.name === member);
|
|
71
|
-
if (
|
|
72
|
-
refused.push({
|
|
73
|
-
...asked,
|
|
74
|
-
code: 'together-provider-unknown',
|
|
75
|
-
message: `Together<[…], [… ${member} …]>: this frond declares no class named '${member}'. `
|
|
76
|
-
+ 'The second list names providers to rebuild inside the frame — a service, a mirror, '
|
|
77
|
-
+ 'a repository — so that what they write is covered by the unwind.',
|
|
78
|
-
});
|
|
92
|
+
if (entry) return [entry];
|
|
79
93
|
|
|
80
|
-
|
|
81
|
-
|
|
94
|
+
refused.push({
|
|
95
|
+
...asked,
|
|
96
|
+
code: 'together-provider-unknown',
|
|
97
|
+
message: `Together<[…], [… ${member} …]>: this frond declares no class named '${member}'. `
|
|
98
|
+
+ 'The second list names providers to rebuild inside the frame — a service, a mirror, '
|
|
99
|
+
+ 'a repository — so that what they write is covered by the unwind.',
|
|
100
|
+
});
|
|
82
101
|
|
|
83
|
-
return [
|
|
102
|
+
return [];
|
|
84
103
|
});
|
|
85
|
-
|
|
86
|
-
return refused.length === before ? { entities, providers } : undefined;
|
|
87
104
|
}
|
|
88
105
|
|
|
89
106
|
/** Who asked for the frame, and where they wrote it — the same three fields every refusal here carries. */
|
|
@@ -160,11 +177,14 @@ export function registerFrames(
|
|
|
160
177
|
): void {
|
|
161
178
|
let registered = 0;
|
|
162
179
|
const seen = new Set<string>();
|
|
180
|
+
|
|
163
181
|
for (const { key, filePath } of wanted) {
|
|
164
182
|
if (seen.has(key)) continue;
|
|
165
183
|
seen.add(key);
|
|
184
|
+
|
|
166
185
|
const names = membersOfTogetherKey(key);
|
|
167
186
|
if (!names) continue;
|
|
187
|
+
|
|
168
188
|
registered += 1;
|
|
169
189
|
const asked = { severity: 'blocking', filePath, subject: key } as const;
|
|
170
190
|
if (!world.storageFactory) {
|
|
@@ -179,47 +199,12 @@ export function registerFrames(
|
|
|
179
199
|
const members = resolve(names, providers, world, asked, refused);
|
|
180
200
|
// Nothing left to build a frame out of, and the refusals above already say what is missing.
|
|
181
201
|
if (!members) continue;
|
|
202
|
+
|
|
182
203
|
remoteMembers(members, world, asked, refused);
|
|
183
204
|
uncoveredWrites(members, world, asked, refused);
|
|
184
|
-
|
|
185
|
-
const sources = new Set(members.entities.map((member) => world.sourceOf?.(member.name) ?? 'db'));
|
|
186
|
-
const validator = (storage: Storage, name: string, schema: SchemaView) =>
|
|
187
|
-
new StorageGuard(schema.getFields(), name, {}, heldBy(schema, name, world.hosting), releasing(world.hosting)).guard(storage);
|
|
188
|
-
|
|
189
|
-
// One engine and a way into it: the engine gives the unwind AND the isolation. The
|
|
190
|
-
// question goes to the source these members live in — a composition answering for the
|
|
191
|
-
// default one would compensate a frame whose own engine holds transactions.
|
|
192
|
-
const source = sources.size === 1 ? [...sources][0]! : undefined;
|
|
193
|
-
if (world.transacted && source !== undefined && (world.transacts?.(source) ?? true)) {
|
|
194
|
-
world.log.info(`${key} — transaction, source '${source}'`);
|
|
195
|
-
scope.registerValue(key, {
|
|
196
|
-
run: <R>(fn: (entities: never, providers: never) => Promise<R>) =>
|
|
197
|
-
ambient.enterFrame(key, () =>
|
|
198
|
-
world.transacted!(source, (factory) => inScope(scope, members, factory, validator, fn as never))),
|
|
199
|
-
});
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Split, or an engine that hands out no transaction: the frame keeps the before-image
|
|
204
|
-
// of every write and replays the inverses itself. `validator` stays OUTSIDE `recording`, so
|
|
205
|
-
// a write the entity refuses never enters the journal.
|
|
206
|
-
const why = sources.size > 1
|
|
207
|
-
? members.entities.map((m) => `${m.name} in '${world.sourceOf?.(m.name) ?? 'db'}'`).join(', ')
|
|
208
|
-
: 'this storage hands out no transaction';
|
|
209
|
-
world.log.info(`${key} — compensated: ${why} — no isolation`);
|
|
210
|
-
scope.registerValue(key, {
|
|
211
|
-
run: <R>(fn: (entities: never, providers: never) => Promise<R>): Promise<R> => ambient.enterFrame(key, async () => {
|
|
212
|
-
const journal: Undo[] = [];
|
|
213
|
-
const record = (storage: Storage, name: string, schema: SchemaView) =>
|
|
214
|
-
validator(recording(storage, name, schema, journal), name, schema);
|
|
215
|
-
try {
|
|
216
|
-
return await inScope(scope, members, world.storageFactory!, record, fn as never);
|
|
217
|
-
} catch (cause) {
|
|
218
|
-
return unwind(journal, cause, world.log);
|
|
219
|
-
}
|
|
220
|
-
}),
|
|
221
|
-
});
|
|
205
|
+
scope.registerValue(key, frameOver(key, members, scope, world));
|
|
222
206
|
}
|
|
207
|
+
|
|
223
208
|
if (registered > 0 && ambient.degraded) {
|
|
224
209
|
world.log.warn(
|
|
225
210
|
'no async context on this runtime — frames run one at a time, and a frame opened '
|
|
@@ -227,3 +212,53 @@ export function registerFrames(
|
|
|
227
212
|
);
|
|
228
213
|
}
|
|
229
214
|
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A transaction when one engine holds every member, a compensation otherwise.
|
|
218
|
+
*
|
|
219
|
+
* One engine and a way into it: the engine gives the unwind AND the isolation. The question goes
|
|
220
|
+
* to the SOURCE these members live in — a composition answering for the default one would
|
|
221
|
+
* compensate a frame whose own engine holds transactions.
|
|
222
|
+
*/
|
|
223
|
+
function frameOver(key: string, members: Members, scope: Container, world: FrameWorld): Frame {
|
|
224
|
+
const sources = new Set(members.entities.map((member) => world.sourceOf?.(member.name) ?? 'db'));
|
|
225
|
+
const validator = (storage: Storage, name: string, schema: SchemaView) =>
|
|
226
|
+
new StorageGuard(schema.getFields(), name, {}, heldBy(schema, name, world.hosting), releasing(world.hosting)).guard(storage);
|
|
227
|
+
|
|
228
|
+
const source = sources.size === 1 ? [...sources][0]! : undefined;
|
|
229
|
+
if (world.transacted && source !== undefined && (world.transacts?.(source) ?? true)) {
|
|
230
|
+
world.log.info(`${key} — transaction, source '${source}'`);
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
run: <R>(fn: (entities: never, providers: never) => Promise<R>) =>
|
|
234
|
+
ambient.enterFrame(key, () =>
|
|
235
|
+
world.transacted!(source, (factory) => inScope(scope, members, factory, validator, fn as never))),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Split, or an engine that hands out no transaction: the frame keeps the before-image of every
|
|
240
|
+
// write and replays the inverses itself. `validator` stays OUTSIDE `recording`, so a write the
|
|
241
|
+
// entity refuses never enters the journal.
|
|
242
|
+
const why = sources.size > 1
|
|
243
|
+
? members.entities.map((one) => `${one.name} in '${world.sourceOf?.(one.name) ?? 'db'}'`).join(', ')
|
|
244
|
+
: 'this storage hands out no transaction';
|
|
245
|
+
world.log.info(`${key} — compensated: ${why} — no isolation`);
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
run: <R>(fn: (entities: never, providers: never) => Promise<R>): Promise<R> => ambient.enterFrame(key, async () => {
|
|
249
|
+
const journal: Undo[] = [];
|
|
250
|
+
const record = (storage: Storage, name: string, schema: SchemaView) =>
|
|
251
|
+
validator(recording(storage, name, schema, journal), name, schema);
|
|
252
|
+
try {
|
|
253
|
+
return await inScope(scope, members, world.storageFactory!, record, fn as never);
|
|
254
|
+
} catch (cause) {
|
|
255
|
+
return unwind(journal, cause, world.log);
|
|
256
|
+
}
|
|
257
|
+
}),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** What a `Together<[…]>` key resolves to — the block, and nothing else. */
|
|
262
|
+
interface Frame {
|
|
263
|
+
run<R>(fn: (entities: never, providers: never) => Promise<R>): Promise<R>;
|
|
264
|
+
}
|
|
@@ -34,6 +34,13 @@ export interface FrondDescriptor {
|
|
|
34
34
|
* domain. Set by the boot, never by a declaration.
|
|
35
35
|
*/
|
|
36
36
|
brought?: true;
|
|
37
|
+
/**
|
|
38
|
+
* The frond this one inherits code from — its scope hangs off that one's, so a service, a
|
|
39
|
+
* repository, a port or a middleware declared there answers here too. The same word the
|
|
40
|
+
* config uses, set by the boot from `FougereConfig.fronds` and never by a scan: the disk is
|
|
41
|
+
* flat, and a directory says nothing about who shares its code.
|
|
42
|
+
*/
|
|
43
|
+
extends?: string;
|
|
37
44
|
/** The ops that finish a fact, in order — see `FrondConfig.pipes`. */
|
|
38
45
|
pipes?: Record<string, string[]>;
|
|
39
46
|
/**
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A discovered middleware — a class declaring `around(ctx, next)`, which runs before and
|
|
3
|
-
*
|
|
2
|
+
* A discovered middleware — a class declaring `around(ctx, next)`, which runs before and after
|
|
3
|
+
* every operation of its frond, and of the fronds that inherit from it.
|
|
4
|
+
*
|
|
5
|
+
* How far it reaches is not written here: it is where the frond sits in `FougereConfig.fronds`,
|
|
6
|
+
* and stating it twice would put one decision in two places.
|
|
4
7
|
*/
|
|
5
8
|
export interface MiddlewareEntry {
|
|
6
|
-
/** Class name —
|
|
9
|
+
/** Class name — the key it registers under in its frond's scope. */
|
|
7
10
|
name: string;
|
|
8
11
|
/** The middleware class. */
|
|
9
12
|
ctor: new (...args: never[]) => unknown;
|
|
10
|
-
/** How far it reaches: its own frond's entities, or every operation in the process. */
|
|
11
|
-
scope: 'frond' | 'app';
|
|
12
13
|
/** Constructor dependency type names (from AST scan). */
|
|
13
14
|
deps: string[];
|
|
14
15
|
/** Absolute file path (for debugging). */
|
|
@@ -15,4 +15,11 @@ export interface ProviderEntry {
|
|
|
15
15
|
deps: string[];
|
|
16
16
|
/** Absolute file path (for debugging). */
|
|
17
17
|
filePath: string;
|
|
18
|
+
/**
|
|
19
|
+
* It stated `implements AsyncDisposable` — so there is ONE of it per frond, and that frond's
|
|
20
|
+
* scope closes it. The language's own marker, which `App` already answers; a provider that
|
|
21
|
+
* says nothing is built per consumer and closed by nobody, which is right for one that holds
|
|
22
|
+
* nothing.
|
|
23
|
+
*/
|
|
24
|
+
kept?: true;
|
|
18
25
|
}
|
|
@@ -40,6 +40,11 @@ export class HandlerFacade {
|
|
|
40
40
|
/** A computed field's parameters and the collectors in scope are both boot-time facts. */
|
|
41
41
|
private readonly presenterPlans: Map<string, BindingPlan>;
|
|
42
42
|
|
|
43
|
+
private collectorResolver = (typeName: string): CollectorResolver | undefined => {
|
|
44
|
+
try { return this.scope.resolve(collectorKeyOf(typeName)) as CollectorResolver; }
|
|
45
|
+
catch { return undefined; }
|
|
46
|
+
};
|
|
47
|
+
|
|
43
48
|
constructor(
|
|
44
49
|
private readonly handler: HandlerEntry,
|
|
45
50
|
private readonly scope: Container,
|
|
@@ -93,20 +98,30 @@ export class HandlerFacade {
|
|
|
93
98
|
invocation,
|
|
94
99
|
};
|
|
95
100
|
|
|
96
|
-
return runMiddlewares(this.facade.middlewares(), context,
|
|
97
|
-
|
|
98
|
-
context.invocation = validated;
|
|
101
|
+
return runMiddlewares(this.facade.middlewares(), context, () => this.answer(op, contract, context, invocation));
|
|
102
|
+
}
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
/**
|
|
105
|
+
* What the handler answers, once the middlewares let the call through: the input judged, the
|
|
106
|
+
* arguments bound, the row projected onto the view this audience sees.
|
|
107
|
+
*/
|
|
108
|
+
private async answer(
|
|
109
|
+
op: string,
|
|
110
|
+
contract: OperationContract,
|
|
111
|
+
context: OperationContext,
|
|
112
|
+
invocation: Invocation,
|
|
113
|
+
): Promise<unknown> {
|
|
114
|
+
const validated = validateInput(contract.input, invocation, this.handler.address, op);
|
|
115
|
+
context.invocation = validated;
|
|
106
116
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
117
|
+
const args = contract.binding ? await this.arguments.resolve(contract.binding, validated) : [];
|
|
118
|
+
const { instance, method } = this.resolveImplementation(op);
|
|
119
|
+
const view = this.viewOf(op);
|
|
120
|
+
const output = view.project(await instance[method](...args));
|
|
121
|
+
|
|
122
|
+
const { presenter } = this.facade;
|
|
123
|
+
|
|
124
|
+
return view.closed || !presenter ? output : this.present(op, presenter, output, validated);
|
|
110
125
|
}
|
|
111
126
|
|
|
112
127
|
/** The computed fields a presenter adds, over the page the façade just projected. */
|
|
@@ -168,11 +183,6 @@ export class HandlerFacade {
|
|
|
168
183
|
return resolved;
|
|
169
184
|
}
|
|
170
185
|
|
|
171
|
-
private collectorResolver = (typeName: string): CollectorResolver | undefined => {
|
|
172
|
-
try { return this.scope.resolve(collectorKeyOf(typeName)) as CollectorResolver; }
|
|
173
|
-
catch { return undefined; }
|
|
174
|
-
};
|
|
175
|
-
|
|
176
186
|
/** The handler itself, resolved on first call — never at boot. */
|
|
177
187
|
private resolveHandler(): any {
|
|
178
188
|
if (!this.instance) this.instance = this.scope.resolve(this.handlerKey);
|
|
@@ -19,37 +19,48 @@ export class PresenterExecutor {
|
|
|
19
19
|
|
|
20
20
|
const rows = Array.isArray(result) ? result : [result];
|
|
21
21
|
const values = new Map<string, unknown[]>();
|
|
22
|
-
|
|
23
22
|
for (const name of this.fieldNames) {
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
26
|
-
try {
|
|
27
|
-
const answered = await field.call(this.presenter, rows, ...(args[name] ?? []));
|
|
28
|
-
if (!Array.isArray(answered) || answered.length !== rows.length) {
|
|
29
|
-
const actual = Array.isArray(answered) ? answered.length : typeof answered;
|
|
30
|
-
throw new Error(`expected ${rows.length} value(s) for ${rows.length} row(s), got ${actual}`);
|
|
31
|
-
}
|
|
32
|
-
values.set(name, answered);
|
|
33
|
-
} catch (cause) {
|
|
34
|
-
throw new FougereError({
|
|
35
|
-
code: ErrorCode.INTERNAL_ERROR,
|
|
36
|
-
message: `Computed field '${name}' failed: ${(cause as Error)?.message ?? cause}`,
|
|
37
|
-
entity: this.entity,
|
|
38
|
-
operation: this.operation,
|
|
39
|
-
cause,
|
|
40
|
-
});
|
|
41
|
-
}
|
|
23
|
+
const answered = await this.computed(name, rows, args[name] ?? []);
|
|
24
|
+
if (answered) values.set(name, answered);
|
|
42
25
|
}
|
|
43
26
|
|
|
44
27
|
const enriched = rows.map((item, index) => {
|
|
45
28
|
if (item === null || typeof item !== 'object') return item;
|
|
29
|
+
|
|
46
30
|
const output = { ...(item as Record<string, unknown>) };
|
|
47
31
|
for (const [name, answered] of values) output[name] = answered[index];
|
|
32
|
+
|
|
48
33
|
return output;
|
|
49
34
|
});
|
|
50
35
|
|
|
51
|
-
return Array.isArray(result)
|
|
52
|
-
|
|
53
|
-
|
|
36
|
+
return Array.isArray(result) ? preserveArrayProperties(result, enriched) : enriched[0];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One computed field, handed the whole PAGE — so one query per page is possible where one per
|
|
41
|
+
* row was not. It answers for every row or for none: a shorter list would silently shift the
|
|
42
|
+
* values onto the wrong rows.
|
|
43
|
+
*/
|
|
44
|
+
private async computed(name: string, rows: unknown[], args: unknown[]): Promise<unknown[] | undefined> {
|
|
45
|
+
const field = this.presenter?.[name];
|
|
46
|
+
if (typeof field !== 'function') return undefined;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const answered = await field.call(this.presenter, rows, ...args);
|
|
50
|
+
if (!Array.isArray(answered) || answered.length !== rows.length) {
|
|
51
|
+
const actual = Array.isArray(answered) ? answered.length : typeof answered;
|
|
52
|
+
throw new Error(`expected ${rows.length} value(s) for ${rows.length} row(s), got ${actual}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return answered;
|
|
56
|
+
} catch (cause) {
|
|
57
|
+
throw new FougereError({
|
|
58
|
+
code: ErrorCode.INTERNAL_ERROR,
|
|
59
|
+
message: `Computed field '${name}' failed: ${(cause as Error)?.message ?? cause}`,
|
|
60
|
+
entity: this.entity,
|
|
61
|
+
operation: this.operation,
|
|
62
|
+
cause,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
54
65
|
}
|
|
55
66
|
}
|
package/src/dispatch/Release.ts
CHANGED
|
@@ -86,57 +86,16 @@ async function walk(
|
|
|
86
86
|
trail: readonly string[],
|
|
87
87
|
): Promise<void> {
|
|
88
88
|
const dependents = world.dependentsOf(entity);
|
|
89
|
+
|
|
89
90
|
// Every refusal of this level BEFORE any of its rows move: two fields of one entity are two
|
|
90
91
|
// dependents, and declaration order would otherwise let a cascade beside a `restrict` win.
|
|
91
92
|
// Depth needs no such pass — a level acts only once the level below it came back.
|
|
92
93
|
for (const dependent of dependents.filter((one) => one.onDelete === 'restrict')) {
|
|
93
|
-
|
|
94
|
-
if (!rows) continue;
|
|
95
|
-
const found = [...(await rows.findAllByKeys(dependent.field, keys.map(String))).values()].flat();
|
|
96
|
-
if (found.length === 0) continue;
|
|
97
|
-
|
|
98
|
-
throw new FougereError({
|
|
99
|
-
code: ErrorCode.CONFLICT,
|
|
100
|
-
message: `${dependent.entity}.${dependent.field} holds ${found.length} row(s) naming this one, `
|
|
101
|
-
+ `and states onDelete 'restrict' — take them out first, or declare what should happen.`,
|
|
102
|
-
entity,
|
|
103
|
-
operation: 'delete',
|
|
104
|
-
});
|
|
94
|
+
await refuseHeld(entity, dependent, keys, world);
|
|
105
95
|
}
|
|
106
96
|
|
|
107
97
|
for (const dependent of dependents.filter((one) => one.onDelete !== 'restrict')) {
|
|
108
|
-
|
|
109
|
-
// Nothing here holds those rows. The process that does answers the same two readings core
|
|
110
|
-
// serves everywhere, so the ask crosses and the work happens on ITS side of the wire —
|
|
111
|
-
// which is what keeps the declaration identical whatever the topology.
|
|
112
|
-
// Nothing here holds those rows: every declared remote is asked about THIS level, and the
|
|
113
|
-
// one that holds them walks its own tree from there. The trail grows by one mark per level,
|
|
114
|
-
// so a deeper ask is never mistaken for the bounce of a shallower one.
|
|
115
|
-
if (!rows) {
|
|
116
|
-
for (const key of keys) {
|
|
117
|
-
// A level already on the trail is one somebody is asking every process about, so
|
|
118
|
-
// asking again is the bounce — the same reason `release` reads it before crossing.
|
|
119
|
-
if (trail.includes(`${entity}#${String(key)}`)) continue;
|
|
120
|
-
for (const peer of world.peers()) await peer.release(entity, key, trail);
|
|
121
|
-
}
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const naming = await rows.findAllByKeys(dependent.field, keys.map(String));
|
|
126
|
-
const found = [...naming.values()].flat();
|
|
127
|
-
if (found.length === 0) continue;
|
|
128
|
-
|
|
129
|
-
if (dependent.onDelete === 'set null') {
|
|
130
|
-
const primary = keyOf(world, dependent.entity);
|
|
131
|
-
for (const row of found) await rows.update(String(row[primary]), { [dependent.field]: null });
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const primary = keyOf(world, dependent.entity);
|
|
136
|
-
const below = found.map((row) => row[primary]).filter((id) => !taken(seen, dependent.entity, id));
|
|
137
|
-
// Deepest first: what names THESE rows goes before they do, all the way down.
|
|
138
|
-
await walk(dependent.entity, below, world, seen, trail);
|
|
139
|
-
for (const id of below) await rows.delete(String(id));
|
|
98
|
+
await carryOut(entity, dependent, keys, world, seen, trail);
|
|
140
99
|
}
|
|
141
100
|
}
|
|
142
101
|
|
|
@@ -154,3 +113,68 @@ function keyOf(world: Releasing, entity: string): string {
|
|
|
154
113
|
|
|
155
114
|
return (schema && FieldSet.of(schema.getFields()).primary) ?? 'id';
|
|
156
115
|
}
|
|
116
|
+
|
|
117
|
+
/** What names these rows and states `restrict` stops the whole release, before anything moves. */
|
|
118
|
+
async function refuseHeld(
|
|
119
|
+
entity: string,
|
|
120
|
+
dependent: Dependent,
|
|
121
|
+
keys: readonly unknown[],
|
|
122
|
+
world: Releasing,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
const rows = world.rowsOf(dependent.entity);
|
|
125
|
+
if (!rows) return;
|
|
126
|
+
|
|
127
|
+
const found = [...(await rows.findAllByKeys(dependent.field, keys.map(String))).values()].flat();
|
|
128
|
+
if (found.length === 0) return;
|
|
129
|
+
|
|
130
|
+
throw new FougereError({
|
|
131
|
+
code: ErrorCode.CONFLICT,
|
|
132
|
+
message: `${dependent.entity}.${dependent.field} holds ${found.length} row(s) naming this one, `
|
|
133
|
+
+ `and states onDelete 'restrict' — take them out first, or declare what should happen.`,
|
|
134
|
+
entity,
|
|
135
|
+
operation: 'delete',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* One dependent level: cleared here, or asked of whoever holds it.
|
|
141
|
+
*
|
|
142
|
+
* Nothing here holds those rows — every declared remote is asked about THIS level, and the one
|
|
143
|
+
* that holds them walks its own tree from there. The trail grows by one mark per level, so a
|
|
144
|
+
* deeper ask is never mistaken for the bounce of a shallower one.
|
|
145
|
+
*/
|
|
146
|
+
async function carryOut(
|
|
147
|
+
entity: string,
|
|
148
|
+
dependent: Dependent,
|
|
149
|
+
keys: readonly unknown[],
|
|
150
|
+
world: Releasing,
|
|
151
|
+
seen: Set<string>,
|
|
152
|
+
trail: readonly string[],
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const rows = world.rowsOf(dependent.entity);
|
|
155
|
+
if (!rows) {
|
|
156
|
+
for (const key of keys) {
|
|
157
|
+
// A level already on the trail is one somebody is asking every process about, so asking
|
|
158
|
+
// again is the bounce — the same reason `release` reads it before crossing.
|
|
159
|
+
if (trail.includes(`${entity}#${String(key)}`)) continue;
|
|
160
|
+
for (const peer of world.peers()) await peer.release(entity, key, trail);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const found = [...(await rows.findAllByKeys(dependent.field, keys.map(String))).values()].flat();
|
|
167
|
+
if (found.length === 0) return;
|
|
168
|
+
|
|
169
|
+
const primary = keyOf(world, dependent.entity);
|
|
170
|
+
if (dependent.onDelete === 'set null') {
|
|
171
|
+
for (const row of found) await rows.update(String(row[primary]), { [dependent.field]: null });
|
|
172
|
+
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const below = found.map((row) => row[primary]).filter((id) => !taken(seen, dependent.entity, id));
|
|
177
|
+
// Deepest first: what names THESE rows goes before they do, all the way down.
|
|
178
|
+
await walk(dependent.entity, below, world, seen, trail);
|
|
179
|
+
for (const id of below) await rows.delete(String(id));
|
|
180
|
+
}
|