@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.
Files changed (215) hide show
  1. package/dist/CallIdentity.d.ts.map +1 -1
  2. package/dist/CallIdentity.js +2 -0
  3. package/dist/CallIdentity.js.map +1 -1
  4. package/dist/Constraint.d.ts +1 -1
  5. package/dist/Constraint.d.ts.map +1 -1
  6. package/dist/FougereConfig.d.ts +10 -6
  7. package/dist/FougereConfig.d.ts.map +1 -1
  8. package/dist/FougereConfig.js +19 -4
  9. package/dist/FougereConfig.js.map +1 -1
  10. package/dist/FrondConfig.d.ts +0 -6
  11. package/dist/FrondConfig.d.ts.map +1 -1
  12. package/dist/FrondConfig.js.map +1 -1
  13. package/dist/FrondDeclaration.d.ts +2 -9
  14. package/dist/FrondDeclaration.d.ts.map +1 -1
  15. package/dist/FrondDeclaration.js +0 -1
  16. package/dist/FrondDeclaration.js.map +1 -1
  17. package/dist/FrondsStated.d.ts +54 -0
  18. package/dist/FrondsStated.d.ts.map +1 -0
  19. package/dist/FrondsStated.js +26 -0
  20. package/dist/FrondsStated.js.map +1 -0
  21. package/dist/Source.d.ts.map +1 -1
  22. package/dist/Source.js +4 -3
  23. package/dist/Source.js.map +1 -1
  24. package/dist/StatedFrond.d.ts +21 -0
  25. package/dist/StatedFrond.d.ts.map +1 -0
  26. package/dist/StatedFrond.js +34 -0
  27. package/dist/StatedFrond.js.map +1 -0
  28. package/dist/StatedModules.d.ts +19 -0
  29. package/dist/StatedModules.d.ts.map +1 -0
  30. package/dist/StatedModules.js +37 -0
  31. package/dist/StatedModules.js.map +1 -0
  32. package/dist/boot/CreateAppOptions.d.ts +19 -2
  33. package/dist/boot/CreateAppOptions.d.ts.map +1 -1
  34. package/dist/boot/Emissions.d.ts +9 -0
  35. package/dist/boot/Emissions.d.ts.map +1 -1
  36. package/dist/boot/Emissions.js +11 -5
  37. package/dist/boot/Emissions.js.map +1 -1
  38. package/dist/boot/Hosting.d.ts +38 -0
  39. package/dist/boot/Hosting.d.ts.map +1 -0
  40. package/dist/boot/Hosting.js +2 -0
  41. package/dist/boot/Hosting.js.map +1 -0
  42. package/dist/boot/Peer.d.ts +16 -0
  43. package/dist/boot/Peer.d.ts.map +1 -0
  44. package/dist/boot/Peer.js +2 -0
  45. package/dist/boot/Peer.js.map +1 -0
  46. package/dist/boot/bootstrap.d.ts.map +1 -1
  47. package/dist/boot/bootstrap.js +372 -235
  48. package/dist/boot/bootstrap.js.map +1 -1
  49. package/dist/boot/declared.d.ts.map +1 -1
  50. package/dist/boot/declared.js +38 -3
  51. package/dist/boot/declared.js.map +1 -1
  52. package/dist/boot/frame.d.ts +0 -4
  53. package/dist/boot/frame.d.ts.map +1 -1
  54. package/dist/boot/frame.js +31 -14
  55. package/dist/boot/frame.js.map +1 -1
  56. package/dist/boot/hosted.d.ts.map +1 -1
  57. package/dist/boot/hosted.js +4 -3
  58. package/dist/boot/hosted.js.map +1 -1
  59. package/dist/boot/install.d.ts +19 -0
  60. package/dist/boot/install.d.ts.map +1 -1
  61. package/dist/boot/install.js +273 -178
  62. package/dist/boot/install.js.map +1 -1
  63. package/dist/boot/nesting.d.ts +33 -0
  64. package/dist/boot/nesting.d.ts.map +1 -0
  65. package/dist/boot/nesting.js +164 -0
  66. package/dist/boot/nesting.js.map +1 -0
  67. package/dist/boot/ownership.d.ts +0 -1
  68. package/dist/boot/ownership.d.ts.map +1 -1
  69. package/dist/boot/ownership.js +66 -51
  70. package/dist/boot/ownership.js.map +1 -1
  71. package/dist/boot/peerOver.d.ts +4 -0
  72. package/dist/boot/peerOver.d.ts.map +1 -0
  73. package/dist/boot/peerOver.js +12 -0
  74. package/dist/boot/peerOver.js.map +1 -0
  75. package/dist/boot/ports.js +30 -25
  76. package/dist/boot/ports.js.map +1 -1
  77. package/dist/boot/relations.d.ts +54 -0
  78. package/dist/boot/relations.d.ts.map +1 -0
  79. package/dist/boot/relations.js +150 -0
  80. package/dist/boot/relations.js.map +1 -0
  81. package/dist/boot/remote.d.ts.map +1 -1
  82. package/dist/boot/remote.js +31 -28
  83. package/dist/boot/remote.js.map +1 -1
  84. package/dist/boot/seed.d.ts.map +1 -1
  85. package/dist/boot/seed.js +27 -20
  86. package/dist/boot/seed.js.map +1 -1
  87. package/dist/boot/together.d.ts +3 -0
  88. package/dist/boot/together.d.ts.map +1 -1
  89. package/dist/boot/together.js +72 -56
  90. package/dist/boot/together.js.map +1 -1
  91. package/dist/contract.d.ts +1 -0
  92. package/dist/contract.d.ts.map +1 -1
  93. package/dist/contract.js.map +1 -1
  94. package/dist/descriptor/FrondDescriptor.d.ts +7 -0
  95. package/dist/descriptor/FrondDescriptor.d.ts.map +1 -1
  96. package/dist/descriptor/MiddlewareEntry.d.ts +6 -5
  97. package/dist/descriptor/MiddlewareEntry.d.ts.map +1 -1
  98. package/dist/descriptor/ProviderEntry.d.ts +7 -0
  99. package/dist/descriptor/ProviderEntry.d.ts.map +1 -1
  100. package/dist/dispatch/Dependent.d.ts +19 -0
  101. package/dist/dispatch/Dependent.d.ts.map +1 -0
  102. package/dist/dispatch/Dependent.js +2 -0
  103. package/dist/dispatch/Dependent.js.map +1 -0
  104. package/dist/dispatch/Dispatcher.d.ts +14 -1
  105. package/dist/dispatch/Dispatcher.d.ts.map +1 -1
  106. package/dist/dispatch/Dispatcher.js +36 -8
  107. package/dist/dispatch/Dispatcher.js.map +1 -1
  108. package/dist/dispatch/HandlerFacade.d.ts +6 -1
  109. package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
  110. package/dist/dispatch/HandlerFacade.js +23 -20
  111. package/dist/dispatch/HandlerFacade.js.map +1 -1
  112. package/dist/dispatch/Journal.d.ts +25 -0
  113. package/dist/dispatch/Journal.d.ts.map +1 -0
  114. package/dist/dispatch/Journal.js +3 -0
  115. package/dist/dispatch/Journal.js.map +1 -0
  116. package/dist/dispatch/PresenterExecutor.d.ts +6 -0
  117. package/dist/dispatch/PresenterExecutor.d.ts.map +1 -1
  118. package/dist/dispatch/PresenterExecutor.js +30 -22
  119. package/dist/dispatch/PresenterExecutor.js.map +1 -1
  120. package/dist/dispatch/RelationCheck.d.ts +22 -0
  121. package/dist/dispatch/RelationCheck.d.ts.map +1 -0
  122. package/dist/dispatch/RelationCheck.js +2 -0
  123. package/dist/dispatch/RelationCheck.js.map +1 -0
  124. package/dist/dispatch/Release.d.ts +36 -0
  125. package/dist/dispatch/Release.d.ts.map +1 -0
  126. package/dist/dispatch/Release.js +130 -0
  127. package/dist/dispatch/Release.js.map +1 -0
  128. package/dist/dispatch/StorageGuard.d.ts +39 -1
  129. package/dist/dispatch/StorageGuard.d.ts.map +1 -1
  130. package/dist/dispatch/StorageGuard.js +128 -54
  131. package/dist/dispatch/StorageGuard.js.map +1 -1
  132. package/dist/index.d.ts +8 -0
  133. package/dist/index.d.ts.map +1 -1
  134. package/dist/index.js +6 -0
  135. package/dist/index.js.map +1 -1
  136. package/dist/node.d.ts +2 -1
  137. package/dist/node.d.ts.map +1 -1
  138. package/dist/node.js +2 -1
  139. package/dist/node.js.map +1 -1
  140. package/dist/storage/Storage.js +1 -1
  141. package/dist/storage/Storage.js.map +1 -1
  142. package/dist/storage/Store.d.ts.map +1 -1
  143. package/dist/storage/Store.js +29 -21
  144. package/dist/storage/Store.js.map +1 -1
  145. package/dist/verify.d.ts.map +1 -1
  146. package/dist/verify.js +14 -0
  147. package/dist/verify.js.map +1 -1
  148. package/dist/wire/Emit.d.ts +9 -0
  149. package/dist/wire/Emit.d.ts.map +1 -1
  150. package/dist/wire/Emit.js +16 -0
  151. package/dist/wire/Emit.js.map +1 -1
  152. package/dist/wire/Invocation.d.ts +3 -2
  153. package/dist/wire/Invocation.d.ts.map +1 -1
  154. package/dist/wire/Invocation.js +5 -2
  155. package/dist/wire/Invocation.js.map +1 -1
  156. package/dist/wire/InvocationContext.d.ts +2 -0
  157. package/dist/wire/InvocationContext.d.ts.map +1 -1
  158. package/dist/wire/SignedCall.d.ts +2 -0
  159. package/dist/wire/SignedCall.d.ts.map +1 -1
  160. package/dist/wire/binding.d.ts.map +1 -1
  161. package/dist/wire/binding.js +29 -57
  162. package/dist/wire/binding.js.map +1 -1
  163. package/dist/wire/drift.d.ts.map +1 -1
  164. package/dist/wire/drift.js +22 -19
  165. package/dist/wire/drift.js.map +1 -1
  166. package/package.json +4 -4
  167. package/src/CallIdentity.ts +2 -0
  168. package/src/Constraint.ts +1 -1
  169. package/src/FougereConfig.ts +24 -10
  170. package/src/FrondConfig.ts +0 -6
  171. package/src/FrondDeclaration.ts +2 -7
  172. package/src/FrondsStated.ts +72 -0
  173. package/src/Source.ts +3 -2
  174. package/src/StatedFrond.ts +51 -0
  175. package/src/StatedModules.ts +58 -0
  176. package/src/boot/CreateAppOptions.ts +19 -2
  177. package/src/boot/Emissions.ts +16 -5
  178. package/src/boot/Hosting.ts +38 -0
  179. package/src/boot/Peer.ts +15 -0
  180. package/src/boot/bootstrap.ts +512 -271
  181. package/src/boot/declared.ts +38 -3
  182. package/src/boot/frame.ts +42 -11
  183. package/src/boot/hosted.ts +4 -3
  184. package/src/boot/install.ts +400 -203
  185. package/src/boot/nesting.ts +196 -0
  186. package/src/boot/ownership.ts +79 -51
  187. package/src/boot/peerOver.ts +17 -0
  188. package/src/boot/ports.ts +45 -31
  189. package/src/boot/relations.ts +172 -0
  190. package/src/boot/remote.ts +47 -29
  191. package/src/boot/seed.ts +36 -27
  192. package/src/boot/together.ts +102 -63
  193. package/src/contract.ts +1 -0
  194. package/src/descriptor/FrondDescriptor.ts +7 -0
  195. package/src/descriptor/MiddlewareEntry.ts +6 -5
  196. package/src/descriptor/ProviderEntry.ts +7 -0
  197. package/src/dispatch/Dependent.ts +19 -0
  198. package/src/dispatch/Dispatcher.ts +43 -7
  199. package/src/dispatch/HandlerFacade.ts +27 -17
  200. package/src/dispatch/Journal.ts +26 -0
  201. package/src/dispatch/PresenterExecutor.ts +33 -22
  202. package/src/dispatch/RelationCheck.ts +21 -0
  203. package/src/dispatch/Release.ts +180 -0
  204. package/src/dispatch/StorageGuard.ts +138 -55
  205. package/src/index.ts +8 -0
  206. package/src/node.ts +2 -1
  207. package/src/storage/Storage.ts +2 -2
  208. package/src/storage/Store.ts +39 -18
  209. package/src/verify.ts +15 -0
  210. package/src/wire/Emit.ts +23 -0
  211. package/src/wire/Invocation.ts +5 -2
  212. package/src/wire/InvocationContext.ts +2 -0
  213. package/src/wire/SignedCall.ts +2 -0
  214. package/src/wire/binding.ts +26 -58
  215. 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 field = this.presenter[name];
25
- if (typeof field !== 'function') continue;
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
- ? preserveArrayProperties(result, enriched)
53
- : enriched[0];
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: a page refused halfway leaves rows behind that
56
- // the caller asked for as one, and the refusal is readable from the input alone.
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 === 'function') {
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
- return list.apply(this, args);
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
- return guarded;
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 field = this.fields[key];
102
- if (!field) {
103
- errors.push(`${key}: ${InputRefusal.unknownField}`);
104
- continue;
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
- private value(field: Fields[string], asked: unknown): { value: unknown } | { error: string } {
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 field = this.fields[key];
178
- // A key the entity does not declare has no column to land in and no judge to pass:
179
- // on the client facade it is a typo, and on this one it is a mapping that went stale.
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: { value: unknown } | { error: string }): unknown =>
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
@@ -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!), providers: split(providers) };
177
+ return { entities: split(entities), providers: split(providers) };
178
178
  }