@fougere/core 0.10.0-alpha.0 → 0.11.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/Source.d.ts.map +1 -1
- package/dist/Source.js +4 -3
- package/dist/Source.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 +122 -20
- package/dist/boot/bootstrap.js.map +1 -1
- package/dist/boot/declared.d.ts.map +1 -1
- package/dist/boot/declared.js +35 -3
- package/dist/boot/declared.js.map +1 -1
- package/dist/boot/install.d.ts +10 -0
- package/dist/boot/install.d.ts.map +1 -1
- package/dist/boot/install.js +11 -2
- package/dist/boot/install.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/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/together.d.ts +3 -0
- package/dist/boot/together.d.ts.map +1 -1
- package/dist/boot/together.js +2 -1
- 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/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/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/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 +124 -0
- package/dist/dispatch/Release.js.map +1 -0
- package/dist/dispatch/StorageGuard.d.ts +18 -1
- package/dist/dispatch/StorageGuard.d.ts.map +1 -1
- package/dist/dispatch/StorageGuard.js +57 -1
- package/dist/dispatch/StorageGuard.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.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 +2 -1
- package/dist/wire/Invocation.d.ts.map +1 -1
- package/dist/wire/Invocation.js +4 -1
- package/dist/wire/Invocation.js.map +1 -1
- package/dist/wire/InvocationContext.d.ts +2 -0
- package/dist/wire/InvocationContext.d.ts.map +1 -1
- package/dist/wire/SignedCall.d.ts +2 -0
- package/dist/wire/SignedCall.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/CallIdentity.ts +2 -0
- package/src/Constraint.ts +1 -1
- package/src/Source.ts +3 -2
- package/src/boot/Hosting.ts +38 -0
- package/src/boot/Peer.ts +15 -0
- package/src/boot/bootstrap.ts +142 -20
- package/src/boot/declared.ts +35 -3
- package/src/boot/install.ts +23 -2
- package/src/boot/peerOver.ts +17 -0
- package/src/boot/relations.ts +172 -0
- package/src/boot/together.ts +5 -1
- package/src/contract.ts +1 -0
- package/src/dispatch/Dependent.ts +19 -0
- package/src/dispatch/Dispatcher.ts +43 -7
- package/src/dispatch/Journal.ts +26 -0
- package/src/dispatch/RelationCheck.ts +21 -0
- package/src/dispatch/Release.ts +156 -0
- package/src/dispatch/StorageGuard.ts +62 -0
- package/src/index.ts +5 -0
- package/src/wire/Emit.ts +23 -0
- package/src/wire/Invocation.ts +3 -1
- package/src/wire/InvocationContext.ts +2 -0
- package/src/wire/SignedCall.ts +2 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/** Who holds a `ref()`: the source it lives in, this process, or nobody. */
|
|
2
|
+
import { lowerFirst, Role, Shapes, type SchemaView } from '@fougere/schema';
|
|
3
|
+
import type { Dependent } from '../dispatch/Dependent.js';
|
|
4
|
+
import type { RelationCheck } from '../dispatch/RelationCheck.js';
|
|
5
|
+
import type { Hosting } from './Hosting.js';
|
|
6
|
+
import type { Diagnostic } from '../diagnostic.js';
|
|
7
|
+
import type { Releasing, Rows } from '../dispatch/Release.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Does a foreign key hold this reference? Both sides in ONE source that keeps relations — two
|
|
11
|
+
* databases share no constraint, and a frond behind `remotes:` registers no storage here at
|
|
12
|
+
* all. So the PAIR decides, never the engine.
|
|
13
|
+
*
|
|
14
|
+
* And the pair is not enough: an engine's cascade does not pass through the guard, so a hop it
|
|
15
|
+
* owns above a hop it does not would take rows out with nothing left to release THEIR rows. One
|
|
16
|
+
* unkeyed hop anywhere below and the guard takes the whole tree — the engine's own cascade then
|
|
17
|
+
* finds nothing left to do, since the guard walks deepest first and the row goes last.
|
|
18
|
+
*/
|
|
19
|
+
function keyed(namer: string, target: string, hosting: Hosting, seen = new Set<string>()): boolean {
|
|
20
|
+
const pair = hosting.hostedHere(namer)
|
|
21
|
+
&& hosting.hostedHere(target)
|
|
22
|
+
&& hosting.sourceOf(namer) === hosting.sourceOf(target)
|
|
23
|
+
&& hosting.enforces(hosting.sourceOf(namer), 'relation');
|
|
24
|
+
if (!pair || seen.has(namer)) return pair;
|
|
25
|
+
seen.add(namer);
|
|
26
|
+
|
|
27
|
+
return referencesTo(namer, hosting).every(({ entity }) => keyed(entity, namer, hosting, seen));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every field of every entity that names `target` — read over the whole app, both ways need it. */
|
|
31
|
+
function referencesTo(target: string, hosting: Hosting): { entity: string; field: string; role: Role }[] {
|
|
32
|
+
const found: { entity: string; field: string; role: Role }[] = [];
|
|
33
|
+
|
|
34
|
+
for (const [entity, schema] of hosting.entities()) {
|
|
35
|
+
for (const [field, declared] of Object.entries(schema.getFields())) {
|
|
36
|
+
const role = Role.of(declared);
|
|
37
|
+
if (role.isReference && role.target?.name && lowerFirst(role.target.name) === target) {
|
|
38
|
+
found.push({ entity, field, role });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return found;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What a target's storage has to answer for a key set to be judged in one read. */
|
|
47
|
+
interface ByKeys {
|
|
48
|
+
findByKeys(keys: readonly string[]): Promise<Map<string, unknown>>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The references a foreign key cannot hold, each as a read the guard makes before the write.
|
|
53
|
+
*
|
|
54
|
+
* Documented: [entities](https://fougere.dev/docs/schema/entities).
|
|
55
|
+
*/
|
|
56
|
+
export function heldBy(entity: SchemaView, name: string, hosting: Hosting): RelationCheck[] {
|
|
57
|
+
const checks: RelationCheck[] = [];
|
|
58
|
+
|
|
59
|
+
for (const [field, declared] of Object.entries(entity.getFields())) {
|
|
60
|
+
const role = Role.of(declared);
|
|
61
|
+
if (!role.isReference || !role.target?.name) continue;
|
|
62
|
+
|
|
63
|
+
const target = lowerFirst(role.target.name);
|
|
64
|
+
if (keyed(name, target, hosting)) continue;
|
|
65
|
+
|
|
66
|
+
checks.push({
|
|
67
|
+
field,
|
|
68
|
+
target,
|
|
69
|
+
missing: async (keys) => {
|
|
70
|
+
const storage = hosting.storageOf(target) as ByKeys | undefined;
|
|
71
|
+
// Nothing here holds those rows — the process that does answers the same reading,
|
|
72
|
+
// so the question crosses rather than being given up on.
|
|
73
|
+
if (!storage) return hosting.peerOf(target)?.missing(target, keys) ?? [];
|
|
74
|
+
const found = await storage.findByKeys(keys.map(String));
|
|
75
|
+
|
|
76
|
+
return keys.filter((key) => !found.has(String(key)));
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return checks;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The rows that name this entity's, and what becomes of them — the dual of `heldBy`.
|
|
86
|
+
*
|
|
87
|
+
* Read over EVERY entity of the app, because a namer is declared wherever its own frond is,
|
|
88
|
+
* and the target learns of it from nowhere else. What a key already holds is left out: the
|
|
89
|
+
* engine answers it at the rows, inside the delete's own transaction.
|
|
90
|
+
*
|
|
91
|
+
* Documented: [entities](https://fougere.dev/docs/schema/entities).
|
|
92
|
+
*/
|
|
93
|
+
export function dependentsOf(target: string, hosting: Hosting): Dependent[] {
|
|
94
|
+
return referencesTo(target, hosting)
|
|
95
|
+
.filter(({ entity }) => !keyed(entity, target, hosting))
|
|
96
|
+
.map(({ entity, field, role }) => ({ entity, field, onDelete: role.onDelete ?? 'restrict' }));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `set null` on a field that may not hold one — refused where the FIELD is, not in `ref()`.
|
|
101
|
+
*
|
|
102
|
+
* `optional()` wraps a `ref()` and is applied after it, so only the field the entity ended up
|
|
103
|
+
* declaring knows whether null is admitted. Asked here rather than at the target, because the
|
|
104
|
+
* declaration is the namer's and so is the fix.
|
|
105
|
+
*/
|
|
106
|
+
export function refuseUnwritableNull(entity: SchemaView, name: string, filePath: string): Diagnostic[] {
|
|
107
|
+
const refused: Diagnostic[] = [];
|
|
108
|
+
|
|
109
|
+
for (const [field, declared] of Object.entries(entity.getFields())) {
|
|
110
|
+
if (Role.of(declared).onDelete !== 'set null' || Shapes.isNullable(declared.shape)) continue;
|
|
111
|
+
refused.push({
|
|
112
|
+
severity: 'blocking',
|
|
113
|
+
code: 'on-delete-null-refused',
|
|
114
|
+
filePath,
|
|
115
|
+
subject: `${name}.${field}`,
|
|
116
|
+
message: `${name}.${field} states onDelete 'set null', and the field admits no null — so the `
|
|
117
|
+
+ 'row could neither be emptied nor kept. Wrap it in `optional()`, or state `cascade` '
|
|
118
|
+
+ 'to take the row out with its target.',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return refused;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The checks nothing in this process can answer — asked once every frond has registered. */
|
|
126
|
+
export function unheldAmong(checks: readonly RelationCheck[], hosting: Hosting): string[] {
|
|
127
|
+
return checks
|
|
128
|
+
.filter((check) => hosting.storageOf(check.target) === undefined)
|
|
129
|
+
.map((check) => `${check.field} → ${check.target}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What a release asks the boot, built once per app — the same `Hosting` the guard reads.
|
|
134
|
+
*
|
|
135
|
+
* `dependentsOf` is answered per call rather than cached: a frond installed after this one
|
|
136
|
+
* brings rows that name an entity already registered, and a table taken at boot would miss
|
|
137
|
+
* exactly the crossings this exists for.
|
|
138
|
+
*/
|
|
139
|
+
export function releasing(hosting: Hosting): Releasing {
|
|
140
|
+
return {
|
|
141
|
+
dependentsOf: (entity) => dependentsOf(entity, hosting),
|
|
142
|
+
rowsOf: (entity) => hosting.storageOf(entity) as Rows | undefined,
|
|
143
|
+
schemaOf: (entity) => hosting.entities().get(entity),
|
|
144
|
+
peers: () => hosting.peers(),
|
|
145
|
+
journal: (entity) => hosting.journal(entity),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A release this process can START and cannot FINISH.
|
|
151
|
+
*
|
|
152
|
+
* Read from what is HERE and never asked of anyone: an entity states `cascade` or `set null`,
|
|
153
|
+
* and nothing registered a journal — both facts are local, so the line is too. The same shape
|
|
154
|
+
* as `unique declared, and the source does not enforce it`, one rung up: what is declared, and
|
|
155
|
+
* what this process cannot keep.
|
|
156
|
+
*
|
|
157
|
+
* Silent when a key holds every hop: the engine carries those out in one statement, and there
|
|
158
|
+
* is no half-done state for anybody to finish.
|
|
159
|
+
*/
|
|
160
|
+
export function unfinishable(hosting: Hosting): string[] {
|
|
161
|
+
if (hosting.journal('') !== undefined) return [];
|
|
162
|
+
|
|
163
|
+
const found: string[] = [];
|
|
164
|
+
for (const [entity] of hosting.entities()) {
|
|
165
|
+
for (const dependent of dependentsOf(entity, hosting)) {
|
|
166
|
+
if (dependent.onDelete === 'restrict') continue;
|
|
167
|
+
found.push(`${dependent.entity}.${dependent.field} → ${entity} (${dependent.onDelete})`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return found;
|
|
172
|
+
}
|
package/src/boot/together.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { type StorageFactory } from '../storage/StorageFactory.js';
|
|
|
11
11
|
import type { Logger } from '../builtin/Logger.js';
|
|
12
12
|
import type { ProviderEntry } from '../descriptor/ProviderEntry.js';
|
|
13
13
|
import { StorageGuard } from '../dispatch/StorageGuard.js';
|
|
14
|
+
import { heldBy, releasing } from './relations.js';
|
|
15
|
+
import type { Hosting } from './Hosting.js';
|
|
14
16
|
import { recording, unwind, type Undo } from './frame.js';
|
|
15
17
|
import type { Diagnostic } from '../diagnostic.js';
|
|
16
18
|
|
|
@@ -26,6 +28,8 @@ export interface FrameWorld {
|
|
|
26
28
|
/** Whether that source hands one out — asked before the frame is built, not at the call. */
|
|
27
29
|
transacts?: (source: string) => boolean;
|
|
28
30
|
transacted?: <R>(source: string, fn: (storageFactory: StorageFactory) => Promise<R>) => Promise<R>;
|
|
31
|
+
/** What a member's references ask before a write — the answer its frond's own storage gets. */
|
|
32
|
+
hosting: Hosting;
|
|
29
33
|
log: Logger;
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -180,7 +184,7 @@ export function registerFrames(
|
|
|
180
184
|
|
|
181
185
|
const sources = new Set(members.entities.map((member) => world.sourceOf?.(member.name) ?? 'db'));
|
|
182
186
|
const validator = (storage: Storage, name: string, schema: SchemaView) =>
|
|
183
|
-
new StorageGuard(schema.getFields(), name).guard(storage);
|
|
187
|
+
new StorageGuard(schema.getFields(), name, {}, heldBy(schema, name, world.hosting), releasing(world.hosting)).guard(storage);
|
|
184
188
|
|
|
185
189
|
// One engine and a way into it: the engine gives the unwind AND the isolation. The
|
|
186
190
|
// question goes to the source these members live in — a composition answering for the
|
package/src/contract.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type { Comparison } from './storage/Comparison.js';
|
|
|
18
18
|
export { toPublicError } from './wire/http-error.js';
|
|
19
19
|
export { Invocation } from './wire/Invocation.js';
|
|
20
20
|
export type { InvocationContext } from './wire/InvocationContext.js';
|
|
21
|
+
export type { PartialInvocation } from './wire/PartialInvocation.js';
|
|
21
22
|
export { Call } from './wire/Call.js';
|
|
22
23
|
export { RouteAddress } from './wire/RouteAddress.js';
|
|
23
24
|
export type { FrondCall } from './wire/FrondCall.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { OnDelete } from '@fougere/schema';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A `ref()` read from the TARGET's side: rows that name it, and what becomes of them.
|
|
5
|
+
*
|
|
6
|
+
* The dual of `RelationCheck`, which reads the same declaration from the child's side to judge
|
|
7
|
+
* a write. Only what no foreign key holds reaches here — a key answers at the rows, inside the
|
|
8
|
+
* delete's own transaction, and nothing in this process would see it happen.
|
|
9
|
+
*
|
|
10
|
+
* Documented: [entities](https://fougere.dev/docs/schema/entities).
|
|
11
|
+
*/
|
|
12
|
+
export interface Dependent {
|
|
13
|
+
/** The entity whose rows name the target — `post`. */
|
|
14
|
+
entity: string;
|
|
15
|
+
/** The field carrying the key — `authorId`. */
|
|
16
|
+
field: string;
|
|
17
|
+
/** Unstated is `restrict`: what a foreign key does when nothing is said. */
|
|
18
|
+
onDelete: OnDelete;
|
|
19
|
+
}
|
|
@@ -7,6 +7,7 @@ import type { RoutePolicy } from './RoutePolicy.js';
|
|
|
7
7
|
import { RouteRegistry } from './RouteRegistry.js';
|
|
8
8
|
import { routeNotFound, servedOperations } from './routeNotFound.js';
|
|
9
9
|
import type { InFlight } from './InFlight.js';
|
|
10
|
+
import type { Journal } from './Journal.js';
|
|
10
11
|
|
|
11
12
|
/** Resolves and executes every call through the same transverse lifecycle. */
|
|
12
13
|
export class Dispatcher implements DispatchPort {
|
|
@@ -15,22 +16,57 @@ export class Dispatcher implements DispatchPort {
|
|
|
15
16
|
private readonly inFlight: InFlight,
|
|
16
17
|
private readonly lifecycle = new DispatchLifecycle(),
|
|
17
18
|
private readonly policy?: RoutePolicy,
|
|
19
|
+
private readonly journalOf?: () => Journal | undefined,
|
|
18
20
|
) {}
|
|
19
21
|
|
|
22
|
+
/** The route that answers this address, or the refusal naming what is served instead. */
|
|
23
|
+
private async routeFor(call: Call): Promise<Route> {
|
|
24
|
+
const known = this.routes.find(call.address);
|
|
25
|
+
const resolved = known ?? await this.routes.resolve(call.address);
|
|
26
|
+
const route = resolved && (!this.policy || this.policy.accepts(resolved)) ? resolved : undefined;
|
|
27
|
+
if (!route) {
|
|
28
|
+
throw this.policy?.notFound?.(call, this.routes.routes())
|
|
29
|
+
?? routeNotFound(call, servedOperations(call, this.routes.routes(), this.policy));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return route;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A kept call publishes no dispatch event and enters no flight: it has not been answered,
|
|
37
|
+
* and counting it here would report one call spanning the days until its hour.
|
|
38
|
+
*
|
|
39
|
+
* Its address is resolved all the same. What is kept is made hours later, by then against
|
|
40
|
+
* whatever the app still serves — so an address nothing answers is refused to the caller who
|
|
41
|
+
* can still fix it, rather than to a beat nobody is watching.
|
|
42
|
+
*/
|
|
43
|
+
private async keep(call: Call, runAt: number): Promise<undefined> {
|
|
44
|
+
await this.routeFor(call);
|
|
45
|
+
|
|
46
|
+
const journal = this.journalOf?.();
|
|
47
|
+
if (!journal) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`${call.address.toString()} asks to run at ${new Date(runAt).toISOString()}, and nothing keeps it.`
|
|
50
|
+
+ ' Install a package answering Journal — @fougere/workflow.',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await journal.keep(call, runAt);
|
|
55
|
+
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
20
59
|
async dispatch(call: Call): Promise<unknown> {
|
|
60
|
+
const { runAt } = call.invocation;
|
|
61
|
+
if (runAt !== undefined) return this.keep(call, runAt);
|
|
62
|
+
|
|
21
63
|
let route: Route | undefined;
|
|
22
64
|
let release: (() => void) | undefined;
|
|
23
65
|
this.lifecycle.publish(DispatchEvent.received(call));
|
|
24
66
|
|
|
25
67
|
try {
|
|
26
68
|
release = this.inFlight.enter(call.address.entity, call.address.operation);
|
|
27
|
-
|
|
28
|
-
const resolved = known ?? await this.routes.resolve(call.address);
|
|
29
|
-
route = resolved && (!this.policy || this.policy.accepts(resolved)) ? resolved : undefined;
|
|
30
|
-
if (!route) {
|
|
31
|
-
throw this.policy?.notFound?.(call, this.routes.routes())
|
|
32
|
-
?? routeNotFound(call, servedOperations(call, this.routes.routes(), this.policy));
|
|
33
|
-
}
|
|
69
|
+
route = await this.routeFor(call);
|
|
34
70
|
|
|
35
71
|
this.lifecycle.publish(DispatchEvent.resolved(call, route.kind));
|
|
36
72
|
const result = await route.execute(call);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Call } from '../wire/Call.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What writes a release down while it happens, when a package provides one.
|
|
5
|
+
*
|
|
6
|
+
* Core does the same hops in the same order either way — children before their parent, so an
|
|
7
|
+
* interruption leaves fewer children and never an orphan. What a journal adds is that somebody
|
|
8
|
+
* can finish what a stopped process started: the row says a release began, and a sweep redoes
|
|
9
|
+
* it. Redoing is safe because every hop already is — a row taken out twice is taken out once.
|
|
10
|
+
*
|
|
11
|
+
* What writes a release down writes none of its own: the boot leaves out the entities of the
|
|
12
|
+
* fronds an extension BROUGHT, so keeping the row that says a release began does not begin one.
|
|
13
|
+
* Read from what was brought rather than written down, the way `CARRIES_LINE` is.
|
|
14
|
+
*
|
|
15
|
+
* Documented: [entities](https://fougere.dev/docs/schema/entities).
|
|
16
|
+
*/
|
|
17
|
+
export interface Journal {
|
|
18
|
+
/** Hold this call until `runAt`, so a sweep dispatches it then and nobody waits for it now. */
|
|
19
|
+
keep(call: Call, runAt: number): Promise<void>;
|
|
20
|
+
/** Take this release on, or say another process is already driving it. */
|
|
21
|
+
open(entity: string, key: string): Promise<'taken' | 'busy'>;
|
|
22
|
+
close(entity: string, key: string): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The container key a package registers its journal under. */
|
|
26
|
+
export const JOURNAL = 'Journal';
|
|
@@ -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,156 @@
|
|
|
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
|
+
// Every refusal of this level BEFORE any of its rows move: two fields of one entity are two
|
|
90
|
+
// dependents, and declaration order would otherwise let a cascade beside a `restrict` win.
|
|
91
|
+
// Depth needs no such pass — a level acts only once the level below it came back.
|
|
92
|
+
for (const dependent of dependents.filter((one) => one.onDelete === 'restrict')) {
|
|
93
|
+
const rows = world.rowsOf(dependent.entity);
|
|
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
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const dependent of dependents.filter((one) => one.onDelete !== 'restrict')) {
|
|
108
|
+
const rows = world.rowsOf(dependent.entity);
|
|
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));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** True once, and remembers — a key already on the path is what ends a cycle. */
|
|
144
|
+
function taken(seen: Set<string>, entity: string, key: unknown): boolean {
|
|
145
|
+
const mark = `${entity}#${String(key)}`;
|
|
146
|
+
if (seen.has(mark)) return true;
|
|
147
|
+
seen.add(mark);
|
|
148
|
+
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function keyOf(world: Releasing, entity: string): string {
|
|
153
|
+
const schema = world.schemaOf(entity);
|
|
154
|
+
|
|
155
|
+
return (schema && FieldSet.of(schema.getFields()).primary) ?? 'id';
|
|
156
|
+
}
|
|
@@ -4,6 +4,8 @@ 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,6 +26,8 @@ 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 {
|
|
@@ -32,13 +37,29 @@ export class StorageGuard {
|
|
|
32
37
|
const validation = this;
|
|
33
38
|
const guarded = Object.create(storage) as T & Writer;
|
|
34
39
|
|
|
40
|
+
const remove = writer.delete;
|
|
41
|
+
if (typeof remove === 'function' && validation.releasing) {
|
|
42
|
+
// What names this row goes first, and this row last: an interruption then leaves fewer
|
|
43
|
+
// children rather than an orphan. A key holds the rest, at the rows, in one statement.
|
|
44
|
+
guarded.delete = async function (id) {
|
|
45
|
+
let gone = false;
|
|
46
|
+
await release(validation.entity, id, validation.releasing!, [], async () => {
|
|
47
|
+
gone = await remove.call(this, id);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return gone;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
35
54
|
guarded.create = async function (...args) {
|
|
36
55
|
args[0] = validation.validated(args[0], 'create');
|
|
56
|
+
await validation.targetsOf([args[0]], 'create');
|
|
37
57
|
return writer.create.apply(this, args);
|
|
38
58
|
};
|
|
39
59
|
|
|
40
60
|
guarded.update = async function (...args) {
|
|
41
61
|
args[1] = validation.validated(args[1], 'update');
|
|
62
|
+
await validation.targetsOf([args[1]], 'update');
|
|
42
63
|
return writer.update.apply(this, args);
|
|
43
64
|
};
|
|
44
65
|
|
|
@@ -46,6 +67,7 @@ export class StorageGuard {
|
|
|
46
67
|
if (typeof upsert === 'function') {
|
|
47
68
|
guarded.upsert = async function (...args) {
|
|
48
69
|
args[0] = validation.validated(args[0], 'upsert');
|
|
70
|
+
await validation.targetsOf([args[0]], 'upsert');
|
|
49
71
|
return upsert.apply(this, args);
|
|
50
72
|
};
|
|
51
73
|
}
|
|
@@ -56,6 +78,9 @@ export class StorageGuard {
|
|
|
56
78
|
// the caller asked for as one, and the refusal is readable from the input alone.
|
|
57
79
|
guarded.upsertAll = async function (...args) {
|
|
58
80
|
args[0] = args[0].map((row, index) => validation.validated(row, 'upsertAll', index));
|
|
81
|
+
// The keys of the whole page in one read per relation, for the reason above: a page
|
|
82
|
+
// refused on its fourth row has already written three.
|
|
83
|
+
await validation.targetsOf(args[0], 'upsertAll');
|
|
59
84
|
return upsertAll.apply(this, args);
|
|
60
85
|
};
|
|
61
86
|
}
|
|
@@ -202,6 +227,43 @@ export class StorageGuard {
|
|
|
202
227
|
|
|
203
228
|
return parsed as T;
|
|
204
229
|
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The rows a `ref()` points at, read before the write lands.
|
|
233
|
+
*
|
|
234
|
+
* Only the references the boot could not leave to a foreign key reach here — a target in
|
|
235
|
+
* the same source is already refused by the key, at the rows, which this cannot be: the
|
|
236
|
+
* target may be deleted between the read and the write. It catches what a key catches in
|
|
237
|
+
* practice, which is a key that was never right: a row copied from another environment,
|
|
238
|
+
* an author deleted months ago, an id from a test.
|
|
239
|
+
*
|
|
240
|
+
* A patch that does not carry the field says nothing about it, so it is skipped rather
|
|
241
|
+
* than read as `null`.
|
|
242
|
+
*/
|
|
243
|
+
private async targetsOf(rows: readonly Record<string, unknown>[], operation: string): Promise<void> {
|
|
244
|
+
if (this.relations.length === 0) return;
|
|
245
|
+
|
|
246
|
+
const refused = await Promise.all(this.relations.map(async (relation) => {
|
|
247
|
+
const keys = [...new Set(
|
|
248
|
+
rows.map((row) => row[relation.field]).filter((key) => key !== undefined && key !== null),
|
|
249
|
+
)];
|
|
250
|
+
if (keys.length === 0) return [];
|
|
251
|
+
const missing = await relation.missing(keys);
|
|
252
|
+
|
|
253
|
+
return missing.map((key) => `${relation.field} ${JSON.stringify(key)} — no ${relation.target} holds it`);
|
|
254
|
+
}));
|
|
255
|
+
|
|
256
|
+
const errors = refused.flat();
|
|
257
|
+
if (errors.length === 0) return;
|
|
258
|
+
|
|
259
|
+
throw new FougereError({
|
|
260
|
+
code: ErrorCode.VALIDATION_FAILED,
|
|
261
|
+
message: `Refused on the way out — ${errors.join(', ')}`,
|
|
262
|
+
entity: this.entity,
|
|
263
|
+
operation,
|
|
264
|
+
details: errors,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
205
267
|
}
|
|
206
268
|
|
|
207
269
|
const unwrap = (one: { value: unknown } | { error: string }): unknown =>
|
package/src/index.ts
CHANGED
|
@@ -33,10 +33,15 @@ export { Repository, type RepositoryConstructor } from './prefab/RepositoryConst
|
|
|
33
33
|
export { type RepositoryOf } from './prefab/RepositoryOf.js';
|
|
34
34
|
export { Invocation } from './contract.js';
|
|
35
35
|
export type { InvocationContext } from './contract.js';
|
|
36
|
+
export type { PartialInvocation } from './contract.js';
|
|
36
37
|
export { Call } from './contract.js';
|
|
37
38
|
export { RouteAddress } from './contract.js';
|
|
38
39
|
export { DispatchLifecycle } from './dispatch/DispatchLifecycle.js';
|
|
39
40
|
export { Dispatcher } from './dispatch/Dispatcher.js';
|
|
41
|
+
export { release, type Releasing } from './dispatch/Release.js';
|
|
42
|
+
export { releasing } from './boot/relations.js';
|
|
43
|
+
export { type Dependent } from './dispatch/Dependent.js';
|
|
44
|
+
export { JOURNAL, type Journal } from './dispatch/Journal.js';
|
|
40
45
|
// The class and not only its type: an observer RECEIVES these, so a package that folds
|
|
41
46
|
// them — or a test of one — has to be able to make one through the facade.
|
|
42
47
|
export { DispatchEvent } from './dispatch/DispatchEvent.js';
|
package/src/wire/Emit.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { lowerFirst } from '@fougere/schema';
|
|
2
|
+
import { computeBindingPlan } from './binding.js';
|
|
3
|
+
import type { OperationContract } from './OperationContract.js';
|
|
4
|
+
import type { OperationsMap } from './OperationsMap.js';
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* What an emitter injects. PARTIAL, because announcing is what REALIZES the fact's
|
|
@@ -52,6 +55,26 @@ export function factsAwaitedBy(handlers: readonly { deps: readonly string[] }[])
|
|
|
52
55
|
return factsIn(handlers, factOfAwaitKey);
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
/**
|
|
59
|
+
* What a set of handlers LISTENS to — the dual of what it announces, and the half no
|
|
60
|
+
* dependency spells: accepting a `Fact<T>` IS the subscription, so it is read from the
|
|
61
|
+
* binding plan, where `Emissions` reads it too.
|
|
62
|
+
*/
|
|
63
|
+
export function factsListenedTo(handlers: readonly { operations: OperationsMap }[]): string[] {
|
|
64
|
+
const taken = (contract: OperationContract): string[] => {
|
|
65
|
+
const plan = contract.binding
|
|
66
|
+
?? (contract.signature ? computeBindingPlan(contract.signature.params, NO_COLLECTOR) : []);
|
|
67
|
+
|
|
68
|
+
return plan.flatMap((bound) =>
|
|
69
|
+
bound.source.kind === 'fact' || bound.source.kind === 'pipe' ? [bound.source.factName] : []);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return [...new Set(handlers.flatMap((handler) => [...handler.operations.values()].flatMap(taken)))];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `Fact` and `Pipe` name themselves, and the plan reads them before it reads a collector. */
|
|
76
|
+
const NO_COLLECTOR = new Set<string>();
|
|
77
|
+
|
|
55
78
|
function factsIn(
|
|
56
79
|
handlers: readonly { deps: readonly string[] }[],
|
|
57
80
|
behindKey: (key: string) => string | undefined,
|