@fougere/core 0.11.0-alpha.0 → 0.12.0-alpha.0

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