@fougere/core 0.10.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/CallIdentity.d.ts.map +1 -1
- package/dist/CallIdentity.js +2 -0
- package/dist/CallIdentity.js.map +1 -1
- package/dist/Constraint.d.ts +1 -1
- package/dist/Constraint.d.ts.map +1 -1
- 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/Source.d.ts.map +1 -1
- package/dist/Source.js +4 -3
- package/dist/Source.js.map +1 -1
- 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/Hosting.d.ts +38 -0
- package/dist/boot/Hosting.d.ts.map +1 -0
- package/dist/boot/Hosting.js +2 -0
- package/dist/boot/Hosting.js.map +1 -0
- package/dist/boot/Peer.d.ts +16 -0
- package/dist/boot/Peer.d.ts.map +1 -0
- package/dist/boot/Peer.js +2 -0
- package/dist/boot/Peer.js.map +1 -0
- package/dist/boot/bootstrap.d.ts.map +1 -1
- package/dist/boot/bootstrap.js +372 -235
- package/dist/boot/bootstrap.js.map +1 -1
- package/dist/boot/declared.d.ts.map +1 -1
- package/dist/boot/declared.js +38 -3
- 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 +19 -0
- package/dist/boot/install.d.ts.map +1 -1
- package/dist/boot/install.js +273 -178
- 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/peerOver.d.ts +4 -0
- package/dist/boot/peerOver.d.ts.map +1 -0
- package/dist/boot/peerOver.js +12 -0
- package/dist/boot/peerOver.js.map +1 -0
- package/dist/boot/ports.js +30 -25
- package/dist/boot/ports.js.map +1 -1
- package/dist/boot/relations.d.ts +54 -0
- package/dist/boot/relations.d.ts.map +1 -0
- package/dist/boot/relations.js +150 -0
- package/dist/boot/relations.js.map +1 -0
- 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 +3 -0
- package/dist/boot/together.d.ts.map +1 -1
- package/dist/boot/together.js +72 -56
- package/dist/boot/together.js.map +1 -1
- package/dist/contract.d.ts +1 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.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/Dependent.d.ts +19 -0
- package/dist/dispatch/Dependent.d.ts.map +1 -0
- package/dist/dispatch/Dependent.js +2 -0
- package/dist/dispatch/Dependent.js.map +1 -0
- package/dist/dispatch/Dispatcher.d.ts +14 -1
- package/dist/dispatch/Dispatcher.d.ts.map +1 -1
- package/dist/dispatch/Dispatcher.js +36 -8
- package/dist/dispatch/Dispatcher.js.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/Journal.d.ts +25 -0
- package/dist/dispatch/Journal.d.ts.map +1 -0
- package/dist/dispatch/Journal.js +3 -0
- package/dist/dispatch/Journal.js.map +1 -0
- 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/RelationCheck.d.ts +22 -0
- package/dist/dispatch/RelationCheck.d.ts.map +1 -0
- package/dist/dispatch/RelationCheck.js +2 -0
- package/dist/dispatch/RelationCheck.js.map +1 -0
- package/dist/dispatch/Release.d.ts +36 -0
- package/dist/dispatch/Release.d.ts.map +1 -0
- package/dist/dispatch/Release.js +130 -0
- package/dist/dispatch/Release.js.map +1 -0
- package/dist/dispatch/StorageGuard.d.ts +39 -1
- package/dist/dispatch/StorageGuard.d.ts.map +1 -1
- package/dist/dispatch/StorageGuard.js +128 -54
- package/dist/dispatch/StorageGuard.js.map +1 -1
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -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/Emit.d.ts +9 -0
- package/dist/wire/Emit.d.ts.map +1 -1
- package/dist/wire/Emit.js +16 -0
- package/dist/wire/Emit.js.map +1 -1
- package/dist/wire/Invocation.d.ts +3 -2
- package/dist/wire/Invocation.d.ts.map +1 -1
- package/dist/wire/Invocation.js +5 -2
- 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/SignedCall.d.ts +2 -0
- package/dist/wire/SignedCall.d.ts.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/CallIdentity.ts +2 -0
- package/src/Constraint.ts +1 -1
- 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/Source.ts +3 -2
- 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/Hosting.ts +38 -0
- package/src/boot/Peer.ts +15 -0
- package/src/boot/bootstrap.ts +512 -271
- package/src/boot/declared.ts +38 -3
- package/src/boot/frame.ts +42 -11
- package/src/boot/hosted.ts +4 -3
- package/src/boot/install.ts +400 -203
- package/src/boot/nesting.ts +196 -0
- package/src/boot/ownership.ts +79 -51
- package/src/boot/peerOver.ts +17 -0
- package/src/boot/ports.ts +45 -31
- package/src/boot/relations.ts +172 -0
- package/src/boot/remote.ts +47 -29
- package/src/boot/seed.ts +36 -27
- package/src/boot/together.ts +102 -63
- package/src/contract.ts +1 -0
- package/src/descriptor/FrondDescriptor.ts +7 -0
- package/src/descriptor/MiddlewareEntry.ts +6 -5
- package/src/descriptor/ProviderEntry.ts +7 -0
- package/src/dispatch/Dependent.ts +19 -0
- package/src/dispatch/Dispatcher.ts +43 -7
- package/src/dispatch/HandlerFacade.ts +27 -17
- package/src/dispatch/Journal.ts +26 -0
- package/src/dispatch/PresenterExecutor.ts +33 -22
- package/src/dispatch/RelationCheck.ts +21 -0
- package/src/dispatch/Release.ts +180 -0
- package/src/dispatch/StorageGuard.ts +138 -55
- package/src/index.ts +8 -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/Emit.ts +23 -0
- package/src/wire/Invocation.ts +5 -2
- package/src/wire/InvocationContext.ts +2 -0
- package/src/wire/SignedCall.ts +2 -0
- package/src/wire/binding.ts +26 -58
- package/src/wire/drift.ts +48 -21
|
@@ -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
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `ref()` whose target the source does not hold, so the guard reads it before the write.
|
|
3
|
+
*
|
|
4
|
+
* The pair decides, never the engine: SQL keeps a foreign key over rows it can see, and a
|
|
5
|
+
* target in another source gets a column and nothing else. The boot builds one of these for
|
|
6
|
+
* each reference that falls outside its own source, and none for the rest — a co-located
|
|
7
|
+
* relation costs nothing here, because the key already refuses the same row.
|
|
8
|
+
*
|
|
9
|
+
* Documented: [entities](https://fougere.dev/docs/schema/entities).
|
|
10
|
+
*/
|
|
11
|
+
export interface RelationCheck {
|
|
12
|
+
/** The field carrying the key — `authorId`. */
|
|
13
|
+
field: string;
|
|
14
|
+
/** What it points at, as the boot files it — `user`. */
|
|
15
|
+
target: string;
|
|
16
|
+
/**
|
|
17
|
+
* Which of these keys no row answers. Takes a set so a page costs one read, and so the
|
|
18
|
+
* single-row case is the same call with one member.
|
|
19
|
+
*/
|
|
20
|
+
missing(keys: readonly unknown[]): Promise<readonly unknown[]>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/** What a delete does to the rows that name it, when no foreign key does it instead. */
|
|
2
|
+
import { FieldSet, type SchemaView } from '@fougere/schema';
|
|
3
|
+
import type { Dependent } from './Dependent.js';
|
|
4
|
+
import type { Journal } from './Journal.js';
|
|
5
|
+
import { ErrorCode } from '../wire/ErrorCode.js';
|
|
6
|
+
import { FougereError } from '../wire/FougereError.js';
|
|
7
|
+
|
|
8
|
+
/** The gestures a release reaches a dependent's rows through. */
|
|
9
|
+
export interface Rows {
|
|
10
|
+
findAllByKeys(field: string, keys: readonly string[]): Promise<Map<string, Record<string, unknown>[]>>;
|
|
11
|
+
update(id: string, patch: Record<string, unknown>): Promise<unknown>;
|
|
12
|
+
delete(id: string): Promise<boolean>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What the guard hands a release: who depends on an entity, and how to reach their rows. */
|
|
16
|
+
export interface Releasing {
|
|
17
|
+
dependentsOf(entity: string): readonly Dependent[];
|
|
18
|
+
rowsOf(entity: string): Rows | undefined;
|
|
19
|
+
schemaOf(entity: string): SchemaView | undefined;
|
|
20
|
+
/** Written down while it happens, when a package provides one — absent is the plain walk. */
|
|
21
|
+
journal(entity: string): Journal | undefined;
|
|
22
|
+
/** Every declared remote — each one walks its own tree, and is alone able to. */
|
|
23
|
+
peers(): readonly { release(entity: string, key: unknown, visited: readonly string[]): Promise<void> }[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The rows that name a key, taken out or emptied, deepest FIRST.
|
|
28
|
+
*
|
|
29
|
+
* Order is the whole guarantee: a child never outlives its parent, so an interruption leaves
|
|
30
|
+
* fewer children and never an orphan. That is what lets a release cross a process without a
|
|
31
|
+
* two-phase commit — and why nothing here needs to be undone, only finished.
|
|
32
|
+
*
|
|
33
|
+
* A row already visited is not visited again, which is what ends a cycle: `Category.parentId`
|
|
34
|
+
* pointing at its own entity walks down and stops when a key repeats.
|
|
35
|
+
*
|
|
36
|
+
* Documented: [entities](https://fougere.dev/docs/schema/entities).
|
|
37
|
+
*/
|
|
38
|
+
export async function release(
|
|
39
|
+
entity: string,
|
|
40
|
+
key: unknown,
|
|
41
|
+
world: Releasing,
|
|
42
|
+
visited: readonly string[] = [],
|
|
43
|
+
row?: () => Promise<unknown>,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const mark = `${entity}#${String(key)}`;
|
|
46
|
+
// Already on the trail means SOMEONE upstream is asking every process about this row, so
|
|
47
|
+
// asking again would be the bounce two processes declaring each other would never end. The
|
|
48
|
+
// local walk still runs: being asked is exactly the point, and only this process can do it.
|
|
49
|
+
const asked = visited.includes(mark);
|
|
50
|
+
const trail = asked ? visited : [...visited, mark];
|
|
51
|
+
|
|
52
|
+
// Written down before the first hop, so a process that stops mid-way leaves a row saying so.
|
|
53
|
+
// Absent, the hops are the same and nothing finishes them.
|
|
54
|
+
const journal = world.journal(entity);
|
|
55
|
+
if (journal && await journal.open(entity, String(key)) === 'busy') return;
|
|
56
|
+
|
|
57
|
+
// Every declared remote, and every one of them: a frond behind `remotes:` may have no sources
|
|
58
|
+
// here, so this process cannot see that its rows name ours — only that process can.
|
|
59
|
+
if (!asked) for (const peer of world.peers()) await peer.release(entity, key, trail);
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await walk(entity, [key], world, new Set([mark]), trail);
|
|
63
|
+
} catch (refusal) {
|
|
64
|
+
// A refusal is an outcome, not an interruption: the framework decided, and redoing it
|
|
65
|
+
// would only decide again — a run kept open for one would be swept forever. Anything
|
|
66
|
+
// else stopped us mid-tree, and THAT is what a run is for.
|
|
67
|
+
if (refusal instanceof FougereError) await journal?.close(entity, String(key));
|
|
68
|
+
throw refusal;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The row itself, LAST — handed in by whoever owns the gesture, so a run covers the whole
|
|
72
|
+
// thing rather than stopping one statement short of it. Whatever the row answers, the
|
|
73
|
+
// release did its part: what depends on this one is dealt with, and nothing is left to redo.
|
|
74
|
+
try {
|
|
75
|
+
await row?.();
|
|
76
|
+
} finally {
|
|
77
|
+
await journal?.close(entity, String(key));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function walk(
|
|
82
|
+
entity: string,
|
|
83
|
+
keys: readonly unknown[],
|
|
84
|
+
world: Releasing,
|
|
85
|
+
seen: Set<string>,
|
|
86
|
+
trail: readonly string[],
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const dependents = world.dependentsOf(entity);
|
|
89
|
+
|
|
90
|
+
// Every refusal of this level BEFORE any of its rows move: two fields of one entity are two
|
|
91
|
+
// dependents, and declaration order would otherwise let a cascade beside a `restrict` win.
|
|
92
|
+
// Depth needs no such pass — a level acts only once the level below it came back.
|
|
93
|
+
for (const dependent of dependents.filter((one) => one.onDelete === 'restrict')) {
|
|
94
|
+
await refuseHeld(entity, dependent, keys, world);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const dependent of dependents.filter((one) => one.onDelete !== 'restrict')) {
|
|
98
|
+
await carryOut(entity, dependent, keys, world, seen, trail);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True once, and remembers — a key already on the path is what ends a cycle. */
|
|
103
|
+
function taken(seen: Set<string>, entity: string, key: unknown): boolean {
|
|
104
|
+
const mark = `${entity}#${String(key)}`;
|
|
105
|
+
if (seen.has(mark)) return true;
|
|
106
|
+
seen.add(mark);
|
|
107
|
+
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function keyOf(world: Releasing, entity: string): string {
|
|
112
|
+
const schema = world.schemaOf(entity);
|
|
113
|
+
|
|
114
|
+
return (schema && FieldSet.of(schema.getFields()).primary) ?? 'id';
|
|
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
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { FieldSet, FieldValueValidator, InputRefusal, type Fields } from '@fougere/schema';
|
|
1
|
+
import { FieldSet, FieldValueValidator, InputRefusal, type Fields, type Verdict } from '@fougere/schema';
|
|
2
2
|
import { COMPARISONS, comparisonOf, unknownIn } from '../storage/Comparison.js';
|
|
3
3
|
import { assertListOptions } from '../storage/Storage.js';
|
|
4
4
|
import { ErrorCode } from '../wire/ErrorCode.js';
|
|
5
5
|
import { FougereError } from '../wire/FougereError.js';
|
|
6
6
|
import type { GuardReport } from './GuardReport.js';
|
|
7
|
+
import type { RelationCheck } from './RelationCheck.js';
|
|
8
|
+
import { release, type Releasing } from './Release.js';
|
|
7
9
|
|
|
8
10
|
/** The gestures this guard grafts onto. */
|
|
9
11
|
interface Writer {
|
|
@@ -11,6 +13,7 @@ interface Writer {
|
|
|
11
13
|
update(...args: [unknown, Record<string, unknown>, ...unknown[]]): Promise<unknown>;
|
|
12
14
|
upsert?(...args: [Record<string, unknown>, ...unknown[]]): Promise<unknown>;
|
|
13
15
|
upsertAll?(...args: [readonly Record<string, unknown>[], ...unknown[]]): Promise<unknown>;
|
|
16
|
+
delete?(id: string): Promise<boolean>;
|
|
14
17
|
list?(...args: unknown[]): unknown;
|
|
15
18
|
}
|
|
16
19
|
|
|
@@ -23,22 +26,55 @@ export class StorageGuard {
|
|
|
23
26
|
private readonly fields: Fields,
|
|
24
27
|
private readonly entity: string,
|
|
25
28
|
private readonly report: GuardReport = {},
|
|
29
|
+
private readonly relations: readonly RelationCheck[] = [],
|
|
30
|
+
private readonly releasing?: Releasing,
|
|
26
31
|
) {}
|
|
27
32
|
|
|
28
33
|
guard<T extends object>(storage: T): T {
|
|
29
34
|
const writer = storage as unknown as Writer;
|
|
30
35
|
if (typeof writer.create !== 'function' || typeof writer.update !== 'function') return storage;
|
|
31
36
|
|
|
32
|
-
const validation = this;
|
|
33
37
|
const guarded = Object.create(storage) as T & Writer;
|
|
34
38
|
|
|
39
|
+
this.guardDelete(guarded, writer);
|
|
40
|
+
this.guardWrites(guarded, writer);
|
|
41
|
+
this.guardList(guarded, writer);
|
|
42
|
+
|
|
43
|
+
return guarded;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* What names this row goes first, and this row last: an interruption then leaves fewer
|
|
48
|
+
* children rather than an orphan. A key holds the rest, at the rows, in one statement.
|
|
49
|
+
*/
|
|
50
|
+
private guardDelete(guarded: Writer, writer: Writer): void {
|
|
51
|
+
const remove = writer.delete;
|
|
52
|
+
if (typeof remove !== 'function' || !this.releasing) return;
|
|
53
|
+
|
|
54
|
+
const validation = this;
|
|
55
|
+
guarded.delete = async function (id) {
|
|
56
|
+
let gone = false;
|
|
57
|
+
await release(validation.entity, id, validation.releasing!, [], async () => {
|
|
58
|
+
gone = await remove.call(this, id);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return gone;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every gesture that puts a row down: judged first, and handed on the value it parsed. */
|
|
66
|
+
private guardWrites(guarded: Writer, writer: Writer): void {
|
|
67
|
+
const validation = this;
|
|
68
|
+
|
|
35
69
|
guarded.create = async function (...args) {
|
|
36
70
|
args[0] = validation.validated(args[0], 'create');
|
|
71
|
+
await validation.targetsOf([args[0]], 'create');
|
|
37
72
|
return writer.create.apply(this, args);
|
|
38
73
|
};
|
|
39
74
|
|
|
40
75
|
guarded.update = async function (...args) {
|
|
41
76
|
args[1] = validation.validated(args[1], 'update');
|
|
77
|
+
await validation.targetsOf([args[1]], 'update');
|
|
42
78
|
return writer.update.apply(this, args);
|
|
43
79
|
};
|
|
44
80
|
|
|
@@ -46,32 +82,36 @@ export class StorageGuard {
|
|
|
46
82
|
if (typeof upsert === 'function') {
|
|
47
83
|
guarded.upsert = async function (...args) {
|
|
48
84
|
args[0] = validation.validated(args[0], 'upsert');
|
|
85
|
+
await validation.targetsOf([args[0]], 'upsert');
|
|
49
86
|
return upsert.apply(this, args);
|
|
50
87
|
};
|
|
51
88
|
}
|
|
52
89
|
|
|
53
90
|
const upsertAll = writer.upsertAll;
|
|
54
91
|
if (typeof upsertAll === 'function') {
|
|
55
|
-
// Every row before the first write
|
|
56
|
-
//
|
|
92
|
+
// Every row before the first write, and the keys of the whole page in one read per
|
|
93
|
+
// relation: a page refused on its fourth row has already written three.
|
|
57
94
|
guarded.upsertAll = async function (...args) {
|
|
58
95
|
args[0] = args[0].map((row, index) => validation.validated(row, 'upsertAll', index));
|
|
96
|
+
await validation.targetsOf(args[0], 'upsertAll');
|
|
59
97
|
return upsertAll.apply(this, args);
|
|
60
98
|
};
|
|
61
99
|
}
|
|
100
|
+
}
|
|
62
101
|
|
|
102
|
+
/** A criterion is read where the entity declares it, so a page is asked for what it can answer. */
|
|
103
|
+
private guardList(guarded: Writer, writer: Writer): void {
|
|
63
104
|
const list = writer.list;
|
|
64
|
-
if (typeof list
|
|
65
|
-
guarded.list = async function (...args: unknown[]) {
|
|
66
|
-
const options = args[0] as { where?: Record<string, unknown> } | undefined;
|
|
67
|
-
assertListOptions(options, validation.entity, Object.keys(validation.fields));
|
|
68
|
-
if (options?.where) args[0] = { ...options, where: validation.criteria(options.where) };
|
|
105
|
+
if (typeof list !== 'function') return;
|
|
69
106
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
107
|
+
const validation = this;
|
|
108
|
+
guarded.list = async function (...args: unknown[]) {
|
|
109
|
+
const options = args[0] as { where?: Record<string, unknown> } | undefined;
|
|
110
|
+
assertListOptions(options, validation.entity, Object.keys(validation.fields));
|
|
111
|
+
if (options?.where) args[0] = { ...options, where: validation.criteria(options.where) };
|
|
73
112
|
|
|
74
|
-
|
|
113
|
+
return list.apply(this, args);
|
|
114
|
+
};
|
|
75
115
|
}
|
|
76
116
|
|
|
77
117
|
/**
|
|
@@ -98,34 +138,12 @@ export class StorageGuard {
|
|
|
98
138
|
const parsed: Record<string, unknown> = {};
|
|
99
139
|
|
|
100
140
|
for (const [key, asked] of Object.entries(where)) {
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
// A comparison names its own vocabulary, and a typo in it would otherwise be a
|
|
107
|
-
// criterion that filters nothing — the silent truncation this facade exists to stop.
|
|
108
|
-
const comparison = comparisonOf(field, asked);
|
|
109
|
-
if (comparison) {
|
|
110
|
-
const unknown = unknownIn(comparison);
|
|
111
|
-
if (unknown.length) {
|
|
112
|
-
errors.push(`${key}: unknown comparison ${unknown.join(', ')} — one of ${COMPARISONS.join(', ')}`);
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
parsed[key] = comparison;
|
|
141
|
+
const read = this.criterion(key, asked);
|
|
142
|
+
if ('message' in read) errors.push(`${key}: ${read.message}`);
|
|
143
|
+
else {
|
|
144
|
+
parsed[key] = read.value;
|
|
116
145
|
this.beyondTheView(key);
|
|
117
|
-
continue;
|
|
118
146
|
}
|
|
119
|
-
|
|
120
|
-
const values = Array.isArray(asked) ? asked : [asked];
|
|
121
|
-
const each = values.map((value) => this.value(field, value));
|
|
122
|
-
const refused = each.find((one) => typeof one === 'object' && one !== null && 'error' in one);
|
|
123
|
-
if (refused) {
|
|
124
|
-
errors.push(`${key}: ${(refused as { error: string }).error}`);
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
parsed[key] = Array.isArray(asked) ? each.map(unwrap) : unwrap(each[0]);
|
|
128
|
-
this.beyondTheView(key);
|
|
129
147
|
}
|
|
130
148
|
|
|
131
149
|
if (errors.length > 0) {
|
|
@@ -141,6 +159,33 @@ export class StorageGuard {
|
|
|
141
159
|
return parsed;
|
|
142
160
|
}
|
|
143
161
|
|
|
162
|
+
/**
|
|
163
|
+
* One criterion, told from a value by the FIELD and never by its own shape.
|
|
164
|
+
*
|
|
165
|
+
* A comparison names its own vocabulary, and a typo in it would otherwise be a criterion that
|
|
166
|
+
* filters nothing — the silent truncation this facade exists to stop.
|
|
167
|
+
*/
|
|
168
|
+
private criterion(key: string, asked: unknown): Verdict {
|
|
169
|
+
const field = this.fields[key];
|
|
170
|
+
if (!field) return { message: InputRefusal.unknownField };
|
|
171
|
+
|
|
172
|
+
const comparison = comparisonOf(field, asked);
|
|
173
|
+
if (comparison) {
|
|
174
|
+
const unknown = unknownIn(comparison);
|
|
175
|
+
|
|
176
|
+
return unknown.length
|
|
177
|
+
? { message: `unknown comparison ${unknown.join(', ')} — one of ${COMPARISONS.join(', ')}` }
|
|
178
|
+
: { value: comparison };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const values = Array.isArray(asked) ? asked : [asked];
|
|
182
|
+
const each = values.map((value) => this.value(field, value));
|
|
183
|
+
const refused = each.find((one) => 'message' in one);
|
|
184
|
+
if (refused) return refused;
|
|
185
|
+
|
|
186
|
+
return { value: Array.isArray(asked) ? each.map(unwrap) : unwrap(each[0]) };
|
|
187
|
+
}
|
|
188
|
+
|
|
144
189
|
/**
|
|
145
190
|
* A filter on a field this facade does not hand back.
|
|
146
191
|
*
|
|
@@ -161,7 +206,19 @@ export class StorageGuard {
|
|
|
161
206
|
}
|
|
162
207
|
|
|
163
208
|
/** One value against one field — validated, then decoded the way the wire hands it. */
|
|
164
|
-
|
|
209
|
+
/**
|
|
210
|
+
* One key a handler wrote. A key the entity does not declare has no column to land in and no
|
|
211
|
+
* judge to pass: on the client facade it is a typo, and on this one a mapping that went stale.
|
|
212
|
+
*/
|
|
213
|
+
private written(key: string, item: unknown): Verdict {
|
|
214
|
+
const field = this.fields[key];
|
|
215
|
+
if (!field) return { message: InputRefusal.unknownField };
|
|
216
|
+
if (item === undefined) return { value: item };
|
|
217
|
+
|
|
218
|
+
return FieldValueValidator.of(field).parse(item);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private value(field: Fields[string], asked: unknown): Verdict {
|
|
165
222
|
if (asked === null || asked === undefined) return { value: asked };
|
|
166
223
|
return FieldValueValidator.of(field).parse(asked);
|
|
167
224
|
}
|
|
@@ -174,20 +231,9 @@ export class StorageGuard {
|
|
|
174
231
|
const parsed: Record<string, unknown> = {};
|
|
175
232
|
|
|
176
233
|
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (!field) {
|
|
181
|
-
errors.push(`${where}${key}: ${InputRefusal.unknownField}`);
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
if (item === undefined) {
|
|
185
|
-
parsed[key] = item;
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
const value = FieldValueValidator.of(field).parse(item);
|
|
189
|
-
if ('error' in value) errors.push(`${where}${key}: ${value.error}`);
|
|
190
|
-
else parsed[key] = value.value;
|
|
234
|
+
const read = this.written(key, item);
|
|
235
|
+
if ('message' in read) errors.push(`${where}${key}: ${read.message}`);
|
|
236
|
+
else parsed[key] = read.value;
|
|
191
237
|
}
|
|
192
238
|
|
|
193
239
|
if (errors.length > 0) {
|
|
@@ -202,7 +248,44 @@ export class StorageGuard {
|
|
|
202
248
|
|
|
203
249
|
return parsed as T;
|
|
204
250
|
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The rows a `ref()` points at, read before the write lands.
|
|
254
|
+
*
|
|
255
|
+
* Only the references the boot could not leave to a foreign key reach here — a target in
|
|
256
|
+
* the same source is already refused by the key, at the rows, which this cannot be: the
|
|
257
|
+
* target may be deleted between the read and the write. It catches what a key catches in
|
|
258
|
+
* practice, which is a key that was never right: a row copied from another environment,
|
|
259
|
+
* an author deleted months ago, an id from a test.
|
|
260
|
+
*
|
|
261
|
+
* A patch that does not carry the field says nothing about it, so it is skipped rather
|
|
262
|
+
* than read as `null`.
|
|
263
|
+
*/
|
|
264
|
+
private async targetsOf(rows: readonly Record<string, unknown>[], operation: string): Promise<void> {
|
|
265
|
+
if (this.relations.length === 0) return;
|
|
266
|
+
|
|
267
|
+
const refused = await Promise.all(this.relations.map(async (relation) => {
|
|
268
|
+
const keys = [...new Set(
|
|
269
|
+
rows.map((row) => row[relation.field]).filter((key) => key !== undefined && key !== null),
|
|
270
|
+
)];
|
|
271
|
+
if (keys.length === 0) return [];
|
|
272
|
+
const missing = await relation.missing(keys);
|
|
273
|
+
|
|
274
|
+
return missing.map((key) => `${relation.field} ${JSON.stringify(key)} — no ${relation.target} holds it`);
|
|
275
|
+
}));
|
|
276
|
+
|
|
277
|
+
const errors = refused.flat();
|
|
278
|
+
if (errors.length === 0) return;
|
|
279
|
+
|
|
280
|
+
throw new FougereError({
|
|
281
|
+
code: ErrorCode.VALIDATION_FAILED,
|
|
282
|
+
message: `Refused on the way out — ${errors.join(', ')}`,
|
|
283
|
+
entity: this.entity,
|
|
284
|
+
operation,
|
|
285
|
+
details: errors,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
205
288
|
}
|
|
206
289
|
|
|
207
|
-
const unwrap = (one:
|
|
290
|
+
const unwrap = (one: Verdict): unknown =>
|
|
208
291
|
'value' in one ? one.value : undefined;
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,9 @@ export type { Extension } from './boot/Extension.js';
|
|
|
6
6
|
export { defineFougere } from './define.js';
|
|
7
7
|
export type { AdapterConfig } from './AdapterConfig.js';
|
|
8
8
|
export type { FougereConfig } from './FougereConfig.js';
|
|
9
|
+
export { statesModule, type FrondsStated, type FrondStated } from './FrondsStated.js';
|
|
10
|
+
export { statedFronds, type StatedFrond } from './StatedFrond.js';
|
|
11
|
+
export { nested } from './boot/nesting.js';
|
|
9
12
|
export type { AnswerFor } from './AnswerFor.js';
|
|
10
13
|
export type { FougereNames } from './FougereNames.js';
|
|
11
14
|
export type { FougerePorts } from './FougerePorts.js';
|
|
@@ -33,10 +36,15 @@ export { Repository, type RepositoryConstructor } from './prefab/RepositoryConst
|
|
|
33
36
|
export { type RepositoryOf } from './prefab/RepositoryOf.js';
|
|
34
37
|
export { Invocation } from './contract.js';
|
|
35
38
|
export type { InvocationContext } from './contract.js';
|
|
39
|
+
export type { PartialInvocation } from './contract.js';
|
|
36
40
|
export { Call } from './contract.js';
|
|
37
41
|
export { RouteAddress } from './contract.js';
|
|
38
42
|
export { DispatchLifecycle } from './dispatch/DispatchLifecycle.js';
|
|
39
43
|
export { Dispatcher } from './dispatch/Dispatcher.js';
|
|
44
|
+
export { release, type Releasing } from './dispatch/Release.js';
|
|
45
|
+
export { releasing } from './boot/relations.js';
|
|
46
|
+
export { type Dependent } from './dispatch/Dependent.js';
|
|
47
|
+
export { JOURNAL, type Journal } from './dispatch/Journal.js';
|
|
40
48
|
// The class and not only its type: an observer RECEIVES these, so a package that folds
|
|
41
49
|
// them — or a test of one — has to be able to make one through the facade.
|
|
42
50
|
export { DispatchEvent } from './dispatch/DispatchEvent.js';
|
package/src/node.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** What Fougere does with a filesystem, minus the scan — that is `@fougere/compiler`. */
|
|
2
2
|
export { setModuleLoader, getModuleLoader } from './loader.js';
|
|
3
|
-
export { loadConfig, loadCascadedConfig } from './FougereConfig.js';
|
|
3
|
+
export { loadConfig, loadCascadedConfig, remotesOf } from './FougereConfig.js';
|
|
4
|
+
export { statedModules } from './StatedModules.js';
|
|
4
5
|
export { defineFrond, loadFrondConfig } from './FrondConfig.js';
|
|
5
6
|
|
|
6
7
|
// Making a key and binding a name to it happen once, at a deployment, on a machine with
|
package/src/storage/Storage.ts
CHANGED
|
@@ -172,7 +172,7 @@ const KINDS = '|';
|
|
|
172
172
|
/** The members behind a frame key, or `undefined` when the key is not one. */
|
|
173
173
|
export function membersOfTogetherKey(key: string): { entities: string[]; providers: string[] } | undefined {
|
|
174
174
|
if (key.length <= FRAME.length || !key.endsWith(FRAME)) return undefined;
|
|
175
|
-
const [entities, providers = ''] = key.slice(0, -FRAME.length).split(KINDS);
|
|
175
|
+
const [entities = '', providers = ''] = key.slice(0, -FRAME.length).split(KINDS);
|
|
176
176
|
const split = (list: string) => list.split(SEPARATOR).filter(Boolean);
|
|
177
|
-
return { entities: split(entities
|
|
177
|
+
return { entities: split(entities), providers: split(providers) };
|
|
178
178
|
}
|