@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
@@ -65,36 +65,9 @@ export function createRemoteRouter(
65
65
 
66
66
  for (const answered of cards) {
67
67
  if (!answered) continue;
68
- const { label, url, transport, answer } = answered;
69
- pending.delete(label);
70
- const card = assertIdentityCard(answer, `Remote '${label}' (${url})`);
71
68
 
72
- for (const frond of card.fronds) {
73
- // Facades only. A fact is not routable — nobody calls it, it arrives — so
74
- // adding one here would answer a call with a transport to a facade that
75
- // does not exist.
76
- for (const facade of frond.facades) {
77
- const first = claimedBy.get(facade.name);
78
- /** Two remotes claiming one name is refused, not silently arbitrated. */
79
- if (first !== undefined && first !== label) {
80
- throw new FougereError({
81
- code: ErrorCode.INTERNAL_ERROR,
82
- message:
83
- `[claim] Two remotes serve '${facade.name}': '${first}' and '${label}'.\n`
84
- + ` A call names an entity, not a frond, so nothing could choose between them.\n`
85
- + ` - Keep one of the two out of \`remotes:\`, or\n`
86
- + ` - expose one of them under a different entity name.`,
87
- entity: facade.name,
88
- });
89
- }
90
- claimedBy.set(facade.name, label);
91
- byEntity.set(facade.name, {
92
- frond: frond.name,
93
- transport,
94
- ...(facade.schema ? { schema: Card.fromDescriptor(facade.schema as SchemaDescriptor).toSchema() } : {}),
95
- });
96
- }
97
- }
69
+ pending.delete(answered.label);
70
+ claimFacades(answered, byEntity, claimedBy);
98
71
  }
99
72
  };
100
73
 
@@ -141,3 +114,48 @@ export function createRemoteFacade(
141
114
 
142
115
  return dynamicOperations(opFn) as Facade;
143
116
  }
117
+
118
+ /** What one remote answered `discover` with, once it has been reached. */
119
+ interface Answered {
120
+ label: string;
121
+ url: string;
122
+ transport: Transport;
123
+ answer: unknown;
124
+ }
125
+
126
+ /**
127
+ * Which remote serves which facade. Facades only: a fact is not routable — nobody calls it, it
128
+ * arrives — so adding one here would answer a call with a transport to a facade that does not
129
+ * exist. Two remotes claiming one name is refused, never silently arbitrated.
130
+ */
131
+ function claimFacades(
132
+ { label, url, transport, answer }: Answered,
133
+ byEntity: Map<string, Route>,
134
+ claimedBy: Map<string, string>,
135
+ ): void {
136
+ const card = assertIdentityCard(answer, `Remote '${label}' (${url})`);
137
+
138
+ for (const frond of card.fronds) {
139
+ for (const facade of frond.facades) {
140
+ const first = claimedBy.get(facade.name);
141
+ if (first !== undefined && first !== label) {
142
+ throw new FougereError({
143
+ code: ErrorCode.INTERNAL_ERROR,
144
+ message:
145
+ `[claim] Two remotes serve '${facade.name}': '${first}' and '${label}'.\n`
146
+ + ` A call names an entity, not a frond, so nothing could choose between them.\n`
147
+ + ` - Keep one of the two out of \`remotes:\`, or\n`
148
+ + ` - expose one of them under a different entity name.`,
149
+ entity: facade.name,
150
+ });
151
+ }
152
+
153
+ claimedBy.set(facade.name, label);
154
+ byEntity.set(facade.name, {
155
+ frond: frond.name,
156
+ transport,
157
+ ...(facade.schema ? { schema: Card.fromDescriptor(facade.schema as SchemaDescriptor).toSchema() } : {}),
158
+ });
159
+ }
160
+ }
161
+ }
package/src/boot/seed.ts CHANGED
@@ -15,6 +15,33 @@ export interface SeedOrder {
15
15
 
16
16
  /** Seeds in dependency order — a `ref()` target is planted before its referrer. */
17
17
  export function orderSeeds(fronds: FrondDescriptor[]): SeedOrder {
18
+ const seeds = fronds.flatMap((frond) => frond.seeds);
19
+ const waiting = whatEachWaitsFor(fronds, seeds);
20
+
21
+ const ordered: SeedEntry[] = [];
22
+ const planted = new Set<string>();
23
+
24
+ while (waiting.size > 0) {
25
+ const ready = [...waiting.keys()].find((seed) => [...waiting.get(seed)!].every((dep) => planted.has(dep)));
26
+ if (!ready) break;
27
+
28
+ ordered.push(ready);
29
+ planted.add(ready.entityName.toLowerCase());
30
+ waiting.delete(ready);
31
+ }
32
+
33
+ return { ordered, cycle: [...waiting.keys()] };
34
+ }
35
+
36
+ /**
37
+ * Only what is actually SEEDED can be waited for: a relation to an entity with no seed is
38
+ * already satisfied by whatever put its rows there, and an entity naming itself waits for
39
+ * nobody.
40
+ */
41
+ function whatEachWaitsFor(
42
+ fronds: FrondDescriptor[],
43
+ seeds: SeedEntry[],
44
+ ): Map<SeedEntry, Set<string>> {
18
45
  const refs = new Map<string, Set<string>>();
19
46
  for (const frond of fronds) {
20
47
  for (const entity of frond.entities) {
@@ -28,33 +55,13 @@ export function orderSeeds(fronds: FrondDescriptor[]): SeedOrder {
28
55
  }
29
56
  }
30
57
 
31
- const seeds = fronds.flatMap((frond) => frond.seeds);
32
58
  const seeded = new Set(seeds.map((seed) => seed.entityName.toLowerCase()));
33
59
 
34
- // Only what is actually seeded can be waited for: a relation to an entity with no seed
35
- // is already satisfied by whatever put its rows there.
36
- const waiting = new Map(
37
- seeds.map((seed) => {
38
- const own = seed.entityName.toLowerCase();
39
- const targets = [...(refs.get(own) ?? [])].filter((target) => seeded.has(target) && target !== own);
60
+ return new Map(seeds.map((seed) => {
61
+ const own = seed.entityName.toLowerCase();
40
62
 
41
- return [seed, new Set(targets)] as const;
42
- }),
43
- );
44
-
45
- const ordered: SeedEntry[] = [];
46
- const planted = new Set<string>();
47
-
48
- while (waiting.size > 0) {
49
- const ready = [...waiting.keys()].find((seed) => [...waiting.get(seed)!].every((dep) => planted.has(dep)));
50
- if (!ready) break;
51
-
52
- ordered.push(ready);
53
- planted.add(ready.entityName.toLowerCase());
54
- waiting.delete(ready);
55
- }
56
-
57
- return { ordered, cycle: [...waiting.keys()] };
63
+ return [seed, new Set([...(refs.get(own) ?? [])].filter((target) => seeded.has(target) && target !== own))] as const;
64
+ }));
58
65
  }
59
66
 
60
67
  /** Where a seed writes, and what it may skip — resolved per entity. */
@@ -110,10 +117,12 @@ function facadeFor(app: App, entityName: string): SeedFacade | undefined {
110
117
  let handler: Record<string, Function> | undefined;
111
118
  try { handler = app.resolve<Record<string, Function>>(facadeKeyOf(entityName)); } catch {}
112
119
 
113
- if (typeof handler?.list === 'function' && typeof handler.create === 'function') {
120
+ const list = handler?.list;
121
+ const create = handler?.create;
122
+ if (typeof list === 'function' && typeof create === 'function') {
114
123
  return {
115
- list: () => handler!.list() as Promise<unknown[]>,
116
- write: (item) => handler!.create({ params: {}, query: {}, input: item, state: {} }),
124
+ list: () => list.call(handler) as Promise<unknown[]>,
125
+ write: (item) => create.call(handler, { params: {}, query: {}, input: item, state: {} }),
117
126
  };
118
127
  }
119
128
 
@@ -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
 
@@ -44,42 +48,59 @@ function resolve(
44
48
  refused: Diagnostic[],
45
49
  ): Members | undefined {
46
50
  const before = refused.length;
51
+ const entities = entitiesNamed(names.entities, world, asked, refused);
52
+ const providers = providersNamed(names.providers, declared, asked, refused);
47
53
 
48
- 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) => {
49
65
  const name = lowerFirst(member);
50
66
  const schema = world.entityByName.get(name);
51
- if (!schema) {
52
- refused.push({
53
- ...asked,
54
- code: 'together-entity-unknown',
55
- message: `Together<[…${member}…]>: no entity named '${member}' is scanned in this app. `
56
- + 'The first list names entities; a class of this frond goes in the second.',
57
- });
67
+ if (schema) return [{ name, schema }];
58
68
 
59
- return [];
60
- }
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
+ });
61
75
 
62
- return [{ name, schema }];
76
+ return [];
63
77
  });
78
+ }
64
79
 
65
- 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) => {
66
91
  const entry = declared.find((provider) => provider.ctor.name === member);
67
- if (!entry) {
68
- refused.push({
69
- ...asked,
70
- code: 'together-provider-unknown',
71
- message: `Together<[…], [… ${member} …]>: this frond declares no class named '${member}'. `
72
- + 'The second list names providers to rebuild inside the frame — a service, a mirror, '
73
- + 'a repository — so that what they write is covered by the unwind.',
74
- });
92
+ if (entry) return [entry];
75
93
 
76
- return [];
77
- }
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
+ });
78
101
 
79
- return [entry];
102
+ return [];
80
103
  });
81
-
82
- return refused.length === before ? { entities, providers } : undefined;
83
104
  }
84
105
 
85
106
  /** Who asked for the frame, and where they wrote it — the same three fields every refusal here carries. */
@@ -156,11 +177,14 @@ export function registerFrames(
156
177
  ): void {
157
178
  let registered = 0;
158
179
  const seen = new Set<string>();
180
+
159
181
  for (const { key, filePath } of wanted) {
160
182
  if (seen.has(key)) continue;
161
183
  seen.add(key);
184
+
162
185
  const names = membersOfTogetherKey(key);
163
186
  if (!names) continue;
187
+
164
188
  registered += 1;
165
189
  const asked = { severity: 'blocking', filePath, subject: key } as const;
166
190
  if (!world.storageFactory) {
@@ -175,47 +199,12 @@ export function registerFrames(
175
199
  const members = resolve(names, providers, world, asked, refused);
176
200
  // Nothing left to build a frame out of, and the refusals above already say what is missing.
177
201
  if (!members) continue;
202
+
178
203
  remoteMembers(members, world, asked, refused);
179
204
  uncoveredWrites(members, world, asked, refused);
180
-
181
- const sources = new Set(members.entities.map((member) => world.sourceOf?.(member.name) ?? 'db'));
182
- const validator = (storage: Storage, name: string, schema: SchemaView) =>
183
- new StorageGuard(schema.getFields(), name).guard(storage);
184
-
185
- // One engine and a way into it: the engine gives the unwind AND the isolation. The
186
- // question goes to the source these members live in — a composition answering for the
187
- // default one would compensate a frame whose own engine holds transactions.
188
- const source = sources.size === 1 ? [...sources][0]! : undefined;
189
- if (world.transacted && source !== undefined && (world.transacts?.(source) ?? true)) {
190
- world.log.info(`${key} — transaction, source '${source}'`);
191
- scope.registerValue(key, {
192
- run: <R>(fn: (entities: never, providers: never) => Promise<R>) =>
193
- ambient.enterFrame(key, () =>
194
- world.transacted!(source, (factory) => inScope(scope, members, factory, validator, fn as never))),
195
- });
196
- continue;
197
- }
198
-
199
- // Split, or an engine that hands out no transaction: the frame keeps the before-image
200
- // of every write and replays the inverses itself. `validator` stays OUTSIDE `recording`, so
201
- // a write the entity refuses never enters the journal.
202
- const why = sources.size > 1
203
- ? members.entities.map((m) => `${m.name} in '${world.sourceOf?.(m.name) ?? 'db'}'`).join(', ')
204
- : 'this storage hands out no transaction';
205
- world.log.info(`${key} — compensated: ${why} — no isolation`);
206
- scope.registerValue(key, {
207
- run: <R>(fn: (entities: never, providers: never) => Promise<R>): Promise<R> => ambient.enterFrame(key, async () => {
208
- const journal: Undo[] = [];
209
- const record = (storage: Storage, name: string, schema: SchemaView) =>
210
- validator(recording(storage, name, schema, journal), name, schema);
211
- try {
212
- return await inScope(scope, members, world.storageFactory!, record, fn as never);
213
- } catch (cause) {
214
- return unwind(journal, cause, world.log);
215
- }
216
- }),
217
- });
205
+ scope.registerValue(key, frameOver(key, members, scope, world));
218
206
  }
207
+
219
208
  if (registered > 0 && ambient.degraded) {
220
209
  world.log.warn(
221
210
  'no async context on this runtime — frames run one at a time, and a frame opened '
@@ -223,3 +212,53 @@ export function registerFrames(
223
212
  );
224
213
  }
225
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
+ }
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';
@@ -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
  }
@@ -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
- const known = this.routes.find(call.address);
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);
@@ -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);
@@ -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';