@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
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Who inherits code from whom, read off `FougereConfig.fronds` and judged before anything is
3
+ * built. It reads no disk and holds no container: the scopes it decides are built later, in
4
+ * `install.ts`, and everything here is a fact about names.
5
+ *
6
+ * Documented: [fronds](https://fougere.dev/docs/infra/fronds).
7
+ */
8
+ import type { Diagnostic } from '../diagnostic.js';
9
+ import type { FrondDescriptor } from '../descriptor/FrondDescriptor.js';
10
+ import { Fronds } from '../descriptor/Fronds.js';
11
+ import { statesModule, type FrondsStated } from '../FrondsStated.js';
12
+ import { statedFronds } from '../StatedFrond.js';
13
+
14
+ /** A frond that holds what a family shares serves nothing — which is what keeps it here. */
15
+ function refuseServingParent(
16
+ parent: FrondDescriptor,
17
+ children: string[],
18
+ refused: Diagnostic[],
19
+ ): boolean {
20
+ if (parent.handlers.length === 0) return false;
21
+
22
+ const served = [...new Set(parent.handlers.map((handler) => handler.address))];
23
+ refused.push({
24
+ severity: 'blocking',
25
+ code: 'frond-parent-serves',
26
+ filePath: parent.handlers[0]!.filePath,
27
+ frond: parent.name,
28
+ subject: parent.name,
29
+ message: `'${parent.name}' has children (${children.join(', ')}) and answers at `
30
+ + `${served.join(', ')}. A frond its family inherits from holds the code they share and `
31
+ + 'nothing else — with no façade, nothing can call it and nothing can place it elsewhere. '
32
+ + 'Move its handlers into a child, or take the children out.',
33
+ });
34
+
35
+ return true;
36
+ }
37
+
38
+ /** Rows belong to a frond something can call — the family case is not traced, so it is refused. */
39
+ function refuseParentEntities(
40
+ parent: FrondDescriptor,
41
+ children: string[],
42
+ refused: Diagnostic[],
43
+ ): void {
44
+ if (parent.entities.length === 0) return;
45
+
46
+ refused.push({
47
+ severity: 'blocking',
48
+ code: 'frond-parent-entities',
49
+ filePath: parent.entities[0]!.filePath,
50
+ frond: parent.name,
51
+ subject: parent.name,
52
+ message: `'${parent.name}' has children (${children.join(', ')}) and declares `
53
+ + `${parent.entities.map((entity) => entity.name).join(', ')}. A frond they inherit from `
54
+ + 'holds code, not rows. Declare the entity in a child and import it from there — '
55
+ + `\`import X from '@fronds/<child>/entities/X.js'\` reaches it from anywhere.`,
56
+ });
57
+ }
58
+
59
+ /** What the tree says, against what this process actually holds. */
60
+ export function nestingOf(
61
+ stated: FrondsStated | undefined,
62
+ fronds: Fronds,
63
+ remotes: Record<string, string> | undefined,
64
+ /** This process carries a subset of the project — `only:`, so an absent name is expected. */
65
+ narrowed = false,
66
+ ): { under: Map<string, string>; refused: Diagnostic[] } {
67
+ const refused: Diagnostic[] = [];
68
+ const under = new Map<string, string>();
69
+ if (stated === undefined) return { under, refused };
70
+
71
+ const statements = statedFronds(stated);
72
+ const here = new Map(fronds.map((frond) => [frond.name, frond]));
73
+ const childrenOf = new Map<string, string[]>();
74
+ const inherits = new Set<string>();
75
+ for (const statement of statements) {
76
+ if (statement.extends === undefined) continue;
77
+
78
+ inherits.add(statement.key);
79
+ childrenOf.set(statement.extends, [...(childrenOf.get(statement.extends) ?? []), statement.key]);
80
+ }
81
+
82
+ for (const statement of statements) {
83
+ if (statement.extends === undefined || !inherits.has(statement.extends)) continue;
84
+
85
+ refused.push({
86
+ severity: 'blocking',
87
+ code: 'frond-extends-chain',
88
+ filePath: 'fougere.config.ts',
89
+ subject: statement.path,
90
+ message: `'${statement.key}' inherits from '${statement.extends}', which inherits from `
91
+ + `'${statedFronds(stated).find((one) => one.key === statement.extends)?.extends}'. `
92
+ + 'Inheriting goes one level: a scope hangs off the one above it, and a chain would make '
93
+ + 'a frond depend on code its own family never named. Move what they share into one '
94
+ + 'frond they both inherit from.',
95
+ });
96
+ }
97
+
98
+ // A frond a family inherits from needs no entry of its own — `extends` is said by the
99
+ // inheritor — so what it may not be is asked of the NAMES, not of the entries.
100
+ for (const [parent, children] of childrenOf) {
101
+ const frond = here.get(parent);
102
+ if (frond === undefined) continue;
103
+
104
+ const placed = stated[parent];
105
+ const address = typeof placed === 'string' ? placed : placed?.remote;
106
+ if (address !== undefined || (remotes && parent in remotes)) {
107
+ refused.push({
108
+ severity: 'blocking',
109
+ code: 'frond-parent-remote',
110
+ filePath: frond.source.path,
111
+ frond: frond.name,
112
+ subject: parent,
113
+ message: `'${parent}' is inherited from by ${children.join(', ')} and is placed at `
114
+ + `${address ?? remotes?.[parent]}. A frond they inherit from stands in every process `
115
+ + 'that holds one of them, so it has no address of its own. Give the address to one of '
116
+ + 'them, or have them inherit from somewhere else.',
117
+ });
118
+ }
119
+
120
+ if (!refuseServingParent(frond, children, refused)) refuseParentEntities(frond, children, refused);
121
+ }
122
+
123
+ for (const statement of statements) {
124
+ const frond = here.get(statement.key);
125
+
126
+ if (frond === undefined) {
127
+ // Three ways a stated name is absent from this process and says nothing wrong: a module
128
+ // key, which the host imports; an address, whose frond answers elsewhere by definition;
129
+ // and a frond `only:` left out, since the config describes the PROJECT and the flag one
130
+ // process. What is left is a name nothing carries — a typo, and the config is where it
131
+ // was made, so that is where the message points.
132
+ const elsewhere = statement.value !== undefined || (remotes && statement.key in remotes);
133
+ if (!statesModule(statement.key) && !elsewhere && here.size > 0 && !narrowed) {
134
+ refused.push({
135
+ severity: 'blocking',
136
+ code: 'frond-unknown',
137
+ filePath: 'fougere.config.ts',
138
+ subject: statement.path,
139
+ message: `'${statement.path}' names no frond this project holds. It has `
140
+ + `${[...here.keys()].join(', ') || 'none'}. Check the spelling, or drop the entry.`,
141
+ });
142
+ }
143
+ continue;
144
+ }
145
+
146
+ if (statement.extends !== undefined && !here.has(statement.extends)) {
147
+ refused.push({
148
+ severity: 'blocking',
149
+ code: 'frond-family-split',
150
+ filePath: frond.source.path,
151
+ frond: frond.name,
152
+ subject: statement.path,
153
+ message: `'${statement.key}' inherits from '${statement.extends}', which this process does `
154
+ + 'not carry. A frond cannot leave the code it resolves behind — carry the two '
155
+ + `together, or take '${statement.key}' out from under '${statement.extends}'.`,
156
+ });
157
+ continue;
158
+ }
159
+
160
+ if (statement.extends !== undefined) under.set(statement.key, statement.extends);
161
+ }
162
+
163
+ return { under, refused };
164
+ }
165
+
166
+ /**
167
+ * The same fronds, each parent before the ones inheriting from it — the order the boot installs
168
+ * them in, since a scope hangs off the one above it.
169
+ *
170
+ * A partition and not a sort: inheriting goes one level, so a frond that inherits has nothing
171
+ * under it and two passes are enough. Stable, and the same array when nothing inherits — which
172
+ * is what `orderSeeds` reads.
173
+ */
174
+ export function parentsFirst(fronds: Fronds, under: Map<string, string>): Fronds {
175
+ if (under.size === 0) return fronds;
176
+
177
+ return Fronds.hosting([
178
+ ...fronds.filter((frond) => !under.has(frond.name)),
179
+ ...fronds.filter((frond) => under.has(frond.name)),
180
+ ]);
181
+ }
182
+
183
+ /**
184
+ * The same `under` a boot stamps, for a reader that does not boot — `fougere check` and
185
+ * `fougere explain` resolve the model off a scan, and a scan knows nothing of the tree.
186
+ * Without it they report every inherited dependency as a crossing.
187
+ */
188
+ export function nested(fronds: Fronds, stated: FrondsStated | undefined): Fronds {
189
+ const { under } = nestingOf(stated, fronds, undefined, true);
190
+ for (const frond of fronds) {
191
+ const parent = under.get(frond.name);
192
+ if (parent !== undefined) frond.extends = parent;
193
+ }
194
+
195
+ return parentsFirst(fronds, under);
196
+ }
@@ -84,6 +84,83 @@ function builtOn(ctor: unknown): string[] {
84
84
  }
85
85
 
86
86
  /** `Storage<E>` is not a word of the user's vocabulary — `<E>Repository` is the one way in. */
87
+ /**
88
+ * A provider named `<Entity>Storage` takes the key of that entity's own storage — and the storage
89
+ * is registered second, so the provider is never resolved. The name is already wrong by two other
90
+ * rules, which is why nothing hits it; it is said rather than left silent.
91
+ */
92
+ function refuseStorageKeyAsProvider(
93
+ provider: ProviderEntry,
94
+ frond: string,
95
+ known: (entity: string) => boolean,
96
+ refused: Diagnostic[],
97
+ ): void {
98
+ // The key the boot registers it under, which a bundler may have renamed — the same reading
99
+ // `ProviderEntry.name` exists for.
100
+ const registered = nameOf(provider);
101
+ const held = entityOfStorageKey(registered, known);
102
+ if (!held) return;
103
+
104
+ refused.push({
105
+ severity: 'blocking',
106
+ code: 'storage-key-is-provider',
107
+ filePath: provider.filePath,
108
+ frond,
109
+ subject: registered,
110
+ message: `${registered} is a provider, and it is the container key of ${held}'s own storage `
111
+ + '— the storage is registered second, so the provider is never resolved. Name it for '
112
+ + 'what it holds; `<Entity>Storage` belongs to the entity.',
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Storage is reached through a repository, and an OWNED entity's not even that way: an aggregate's
118
+ * members are reached through it and nowhere else. The ownership question is asked first, so a
119
+ * member says who owns it rather than being told to write a repository it may not have.
120
+ */
121
+ function refuseStorageReached(
122
+ decl: { ctor: { name: string }; deps: string[]; filePath: string; kind: string },
123
+ frond: string,
124
+ owners: Map<string, string>,
125
+ known: (entity: string) => boolean,
126
+ refused: Diagnostic[],
127
+ ): void {
128
+ const allowed = decl.kind === 'provider' ? builtOn(decl.ctor) : [];
129
+
130
+ for (const dep of decl.deps) {
131
+ const entity = entityOfStorageKey(dep, known);
132
+ if (!entity) continue;
133
+
134
+ const owner = owners.get(entity);
135
+ if (owner && owner !== decl.ctor.name) {
136
+ refused.push({
137
+ severity: 'blocking',
138
+ code: 'aggregate-storage-reached',
139
+ filePath: decl.filePath,
140
+ frond,
141
+ subject: `${decl.ctor.name}(${dep})`,
142
+ message: `${decl.ctor.name} asks for ${dep}, and ${owner} owns ${entity}. Nothing else `
143
+ + `reaches an owned entity's storage — name the operation on ${owner} and ask for it: `
144
+ + `constructor(private ${entity}: ${owner}) {}`,
145
+ });
146
+ continue;
147
+ }
148
+ if (allowed.includes(entity)) continue;
149
+
150
+ refused.push({
151
+ severity: 'blocking',
152
+ code: 'storage-outside-repository',
153
+ filePath: decl.filePath,
154
+ frond,
155
+ subject: `${decl.ctor.name}(${dep})`,
156
+ message: `${decl.ctor.name} asks for ${dep}. Storage is reached through a repository, never `
157
+ + `through the port: constructor(private ${entity}: ${repositoryKeyOf(entity)}) {}. It `
158
+ + `answers every gesture ${dep} does, whether or not anyone wrote the file — and the day `
159
+ + `${entity} belongs to an aggregate, this line does not move.`,
160
+ });
161
+ }
162
+ }
163
+
87
164
  export function storageInUserCode(
88
165
  frond: FrondDescriptor,
89
166
  owners: Map<string, string>,
@@ -97,59 +174,10 @@ export function storageInUserCode(
97
174
  ];
98
175
  const holders = frond.providers.map((p) => ({ ...p, kind: 'provider' as const }));
99
176
 
100
- for (const provider of holders) {
101
- // The key the boot registers it under, which a bundler may have renamed — the same
102
- // reading `ProviderEntry.name` exists for.
103
- const registered = nameOf(provider);
104
- const held = entityOfStorageKey(registered, known);
105
- if (!held) continue;
106
-
107
- refused.push({
108
- severity: 'blocking',
109
- code: 'storage-key-is-provider',
110
- filePath: provider.filePath,
111
- frond: frond.name,
112
- subject: registered,
113
- message: `${registered} is a provider, and it is the container key of ${held}'s own storage `
114
- + '— the storage is registered second, so the provider is never resolved. Name it for '
115
- + 'what it holds; `<Entity>Storage` belongs to the entity.',
116
- });
117
- }
177
+ for (const provider of holders) refuseStorageKeyAsProvider(provider, frond.name, known, refused);
118
178
 
119
179
  for (const decl of [...facades, ...holders]) {
120
- const allowed = decl.kind === 'provider' ? builtOn(decl.ctor) : [];
121
- for (const dep of decl.deps) {
122
- const entity = entityOfStorageKey(dep, known);
123
- if (!entity) continue;
124
-
125
- const owner = owners.get(entity);
126
- if (owner && owner !== decl.ctor.name) {
127
- refused.push({
128
- severity: 'blocking',
129
- code: 'aggregate-storage-reached',
130
- filePath: decl.filePath,
131
- frond: frond.name,
132
- subject: `${decl.ctor.name}(${dep})`,
133
- message: `${decl.ctor.name} asks for ${dep}, and ${owner} owns ${entity}. Nothing else `
134
- + `reaches an owned entity's storage — name the operation on ${owner} and ask for it: `
135
- + `constructor(private ${entity}: ${owner}) {}`,
136
- });
137
- continue;
138
- }
139
- if (allowed.includes(entity)) continue;
140
-
141
- refused.push({
142
- severity: 'blocking',
143
- code: 'storage-outside-repository',
144
- filePath: decl.filePath,
145
- frond: frond.name,
146
- subject: `${decl.ctor.name}(${dep})`,
147
- message: `${decl.ctor.name} asks for ${dep}. Storage is reached through a repository, never `
148
- + `through the port: constructor(private ${entity}: ${repositoryKeyOf(entity)}) {}. It `
149
- + `answers every gesture ${dep} does, whether or not anyone wrote the file — and the day `
150
- + `${entity} belongs to an aggregate, this line does not move.`,
151
- });
152
- }
180
+ refuseStorageReached(decl, frond.name, owners, known, refused);
153
181
  }
154
182
  }
155
183
 
@@ -0,0 +1,17 @@
1
+ /** The two readings core serves everywhere, asked of one process over one transport. */
2
+ import { Invocation } from '../wire/Invocation.js';
3
+ import { RPC_ENTITY } from '../wire/RpcAnswer.js';
4
+ import type { Transport } from '../wire/Transport.js';
5
+ import type { Peer } from './Peer.js';
6
+
7
+ export function peerOver(transport: Transport): Peer {
8
+ const ask = (op: string, params: Record<string, unknown>) =>
9
+ transport({ entity: RPC_ENTITY, op }, { ...Invocation.empty, params: params as never });
10
+
11
+ return {
12
+ missing: async (entity, keys) =>
13
+ ((await ask('holds', { entity, keys })) as { missing: readonly unknown[] }).missing,
14
+ dependents: (entity) => ask('dependents', { entity }),
15
+ release: async (entity, key, visited) => { await ask('release', { entity, key, visited }); },
16
+ };
17
+ }
package/src/boot/ports.ts CHANGED
@@ -111,35 +111,54 @@ function chain(
111
111
  stated: string | readonly string[] | undefined,
112
112
  refused: Diagnostic[],
113
113
  ): ProviderEntry[] {
114
- // The whole chain, outside in — a string is a chain of one, and the last name is what
115
- // actually answers. What a deployment wraps its realization with is the same kind of
116
- // decision as which realization it uses, so it is the same key.
117
- if (stated !== undefined) {
118
- const order = typeof stated === 'string' ? [stated] : stated;
119
- const all = [...wrappers, ...impls];
120
-
121
- return order.flatMap((name) => {
122
- const pick = all.find((one) => nameOf(one) === name);
123
- if (!pick) {
124
- refused.push({
125
- severity: 'blocking',
126
- code: 'port-not-extended',
127
- filePath: all[0]?.filePath ?? 'fougere.config.ts',
128
- subject: `ports: { ${port}: '${name}' }`,
129
- message: `${port}: '${name}' does not extend it. What does: ${all.map(nameOf).join(', ')}.`,
130
- });
131
-
132
- return [];
133
- }
134
-
135
- return [pick];
114
+ if (stated !== undefined) return statedChain(port, [...wrappers, ...impls], stated, refused);
115
+
116
+ refuseUndecided(port, impls, wrappers, refused);
117
+
118
+ return [...wrappers, ...impls];
119
+ }
120
+
121
+ /**
122
+ * The whole chain, outside in — a string is a chain of one, and the last name is what actually
123
+ * answers. What a deployment wraps its realization with is the same kind of decision as which
124
+ * realization it uses, so it is the same key.
125
+ */
126
+ function statedChain(
127
+ port: string,
128
+ all: ProviderEntry[],
129
+ stated: string | readonly string[],
130
+ refused: Diagnostic[],
131
+ ): ProviderEntry[] {
132
+ const order = typeof stated === 'string' ? [stated] : stated;
133
+
134
+ return order.flatMap((name) => {
135
+ const pick = all.find((one) => nameOf(one) === name);
136
+ if (pick) return [pick];
137
+
138
+ refused.push({
139
+ severity: 'blocking',
140
+ code: 'port-not-extended',
141
+ filePath: all[0]?.filePath ?? 'fougere.config.ts',
142
+ subject: `ports: { ${port}: '${name}' }`,
143
+ message: `${port}: '${name}' does not extend it. What does: ${all.map(nameOf).join(', ')}.`,
136
144
  });
137
- }
138
145
 
146
+ return [];
147
+ });
148
+ }
149
+
150
+ /**
151
+ * Two realizations, or two wrappers, and nothing saying which — refused rather than settled by
152
+ * scan order, the same reason `remotes` refuses two owners of one entity: whichever won would
153
+ * depend on the order files were read, and the handler would charge the wrong provider silently.
154
+ */
155
+ function refuseUndecided(
156
+ port: string,
157
+ impls: ProviderEntry[],
158
+ wrappers: ProviderEntry[],
159
+ refused: Diagnostic[],
160
+ ): void {
139
161
  if (impls.length > 1) {
140
- // Refusing rather than keeping one, for the reason `remotes` refuses two owners
141
- // of an entity: whichever won would depend on scan order, and the handler would
142
- // charge the wrong provider without a word.
143
162
  refused.push({
144
163
  severity: 'blocking',
145
164
  code: 'port-implemented-twice',
@@ -149,12 +168,9 @@ function chain(
149
168
  + 'answers it. Which realization a deployment uses is not a fact about the code — state it: '
150
169
  + `ports: { ${port}: '${nameOf(impls[0]!)}' } in fougere.config.ts.`,
151
170
  });
152
-
153
- return [...wrappers, ...impls];
154
171
  }
155
172
 
156
173
  if (wrappers.length > 1) {
157
- // Same refusal one layer out: two wrappers are an ORDER, and scan order is not one.
158
174
  refused.push({
159
175
  severity: 'blocking',
160
176
  code: 'port-wrapped-twice',
@@ -166,6 +182,4 @@ function chain(
166
182
  + 'in fougere.config.ts.',
167
183
  });
168
184
  }
169
-
170
- return [...wrappers, ...impls];
171
185
  }
@@ -0,0 +1,172 @@
1
+ /** Who holds a `ref()`: the source it lives in, this process, or nobody. */
2
+ import { lowerFirst, Role, Shapes, type SchemaView } from '@fougere/schema';
3
+ import type { Dependent } from '../dispatch/Dependent.js';
4
+ import type { RelationCheck } from '../dispatch/RelationCheck.js';
5
+ import type { Hosting } from './Hosting.js';
6
+ import type { Diagnostic } from '../diagnostic.js';
7
+ import type { Releasing, Rows } from '../dispatch/Release.js';
8
+
9
+ /**
10
+ * Does a foreign key hold this reference? Both sides in ONE source that keeps relations — two
11
+ * databases share no constraint, and a frond behind `remotes:` registers no storage here at
12
+ * all. So the PAIR decides, never the engine.
13
+ *
14
+ * And the pair is not enough: an engine's cascade does not pass through the guard, so a hop it
15
+ * owns above a hop it does not would take rows out with nothing left to release THEIR rows. One
16
+ * unkeyed hop anywhere below and the guard takes the whole tree — the engine's own cascade then
17
+ * finds nothing left to do, since the guard walks deepest first and the row goes last.
18
+ */
19
+ function keyed(namer: string, target: string, hosting: Hosting, seen = new Set<string>()): boolean {
20
+ const pair = hosting.hostedHere(namer)
21
+ && hosting.hostedHere(target)
22
+ && hosting.sourceOf(namer) === hosting.sourceOf(target)
23
+ && hosting.enforces(hosting.sourceOf(namer), 'relation');
24
+ if (!pair || seen.has(namer)) return pair;
25
+ seen.add(namer);
26
+
27
+ return referencesTo(namer, hosting).every(({ entity }) => keyed(entity, namer, hosting, seen));
28
+ }
29
+
30
+ /** Every field of every entity that names `target` — read over the whole app, both ways need it. */
31
+ function referencesTo(target: string, hosting: Hosting): { entity: string; field: string; role: Role }[] {
32
+ const found: { entity: string; field: string; role: Role }[] = [];
33
+
34
+ for (const [entity, schema] of hosting.entities()) {
35
+ for (const [field, declared] of Object.entries(schema.getFields())) {
36
+ const role = Role.of(declared);
37
+ if (role.isReference && role.target?.name && lowerFirst(role.target.name) === target) {
38
+ found.push({ entity, field, role });
39
+ }
40
+ }
41
+ }
42
+
43
+ return found;
44
+ }
45
+
46
+ /** What a target's storage has to answer for a key set to be judged in one read. */
47
+ interface ByKeys {
48
+ findByKeys(keys: readonly string[]): Promise<Map<string, unknown>>;
49
+ }
50
+
51
+ /**
52
+ * The references a foreign key cannot hold, each as a read the guard makes before the write.
53
+ *
54
+ * Documented: [entities](https://fougere.dev/docs/schema/entities).
55
+ */
56
+ export function heldBy(entity: SchemaView, name: string, hosting: Hosting): RelationCheck[] {
57
+ const checks: RelationCheck[] = [];
58
+
59
+ for (const [field, declared] of Object.entries(entity.getFields())) {
60
+ const role = Role.of(declared);
61
+ if (!role.isReference || !role.target?.name) continue;
62
+
63
+ const target = lowerFirst(role.target.name);
64
+ if (keyed(name, target, hosting)) continue;
65
+
66
+ checks.push({
67
+ field,
68
+ target,
69
+ missing: async (keys) => {
70
+ const storage = hosting.storageOf(target) as ByKeys | undefined;
71
+ // Nothing here holds those rows — the process that does answers the same reading,
72
+ // so the question crosses rather than being given up on.
73
+ if (!storage) return hosting.peerOf(target)?.missing(target, keys) ?? [];
74
+ const found = await storage.findByKeys(keys.map(String));
75
+
76
+ return keys.filter((key) => !found.has(String(key)));
77
+ },
78
+ });
79
+ }
80
+
81
+ return checks;
82
+ }
83
+
84
+ /**
85
+ * The rows that name this entity's, and what becomes of them — the dual of `heldBy`.
86
+ *
87
+ * Read over EVERY entity of the app, because a namer is declared wherever its own frond is,
88
+ * and the target learns of it from nowhere else. What a key already holds is left out: the
89
+ * engine answers it at the rows, inside the delete's own transaction.
90
+ *
91
+ * Documented: [entities](https://fougere.dev/docs/schema/entities).
92
+ */
93
+ export function dependentsOf(target: string, hosting: Hosting): Dependent[] {
94
+ return referencesTo(target, hosting)
95
+ .filter(({ entity }) => !keyed(entity, target, hosting))
96
+ .map(({ entity, field, role }) => ({ entity, field, onDelete: role.onDelete ?? 'restrict' }));
97
+ }
98
+
99
+ /**
100
+ * `set null` on a field that may not hold one — refused where the FIELD is, not in `ref()`.
101
+ *
102
+ * `optional()` wraps a `ref()` and is applied after it, so only the field the entity ended up
103
+ * declaring knows whether null is admitted. Asked here rather than at the target, because the
104
+ * declaration is the namer's and so is the fix.
105
+ */
106
+ export function refuseUnwritableNull(entity: SchemaView, name: string, filePath: string): Diagnostic[] {
107
+ const refused: Diagnostic[] = [];
108
+
109
+ for (const [field, declared] of Object.entries(entity.getFields())) {
110
+ if (Role.of(declared).onDelete !== 'set null' || Shapes.isNullable(declared.shape)) continue;
111
+ refused.push({
112
+ severity: 'blocking',
113
+ code: 'on-delete-null-refused',
114
+ filePath,
115
+ subject: `${name}.${field}`,
116
+ message: `${name}.${field} states onDelete 'set null', and the field admits no null — so the `
117
+ + 'row could neither be emptied nor kept. Wrap it in `optional()`, or state `cascade` '
118
+ + 'to take the row out with its target.',
119
+ });
120
+ }
121
+
122
+ return refused;
123
+ }
124
+
125
+ /** The checks nothing in this process can answer — asked once every frond has registered. */
126
+ export function unheldAmong(checks: readonly RelationCheck[], hosting: Hosting): string[] {
127
+ return checks
128
+ .filter((check) => hosting.storageOf(check.target) === undefined)
129
+ .map((check) => `${check.field} → ${check.target}`);
130
+ }
131
+
132
+ /**
133
+ * What a release asks the boot, built once per app — the same `Hosting` the guard reads.
134
+ *
135
+ * `dependentsOf` is answered per call rather than cached: a frond installed after this one
136
+ * brings rows that name an entity already registered, and a table taken at boot would miss
137
+ * exactly the crossings this exists for.
138
+ */
139
+ export function releasing(hosting: Hosting): Releasing {
140
+ return {
141
+ dependentsOf: (entity) => dependentsOf(entity, hosting),
142
+ rowsOf: (entity) => hosting.storageOf(entity) as Rows | undefined,
143
+ schemaOf: (entity) => hosting.entities().get(entity),
144
+ peers: () => hosting.peers(),
145
+ journal: (entity) => hosting.journal(entity),
146
+ };
147
+ }
148
+
149
+ /**
150
+ * A release this process can START and cannot FINISH.
151
+ *
152
+ * Read from what is HERE and never asked of anyone: an entity states `cascade` or `set null`,
153
+ * and nothing registered a journal — both facts are local, so the line is too. The same shape
154
+ * as `unique declared, and the source does not enforce it`, one rung up: what is declared, and
155
+ * what this process cannot keep.
156
+ *
157
+ * Silent when a key holds every hop: the engine carries those out in one statement, and there
158
+ * is no half-done state for anybody to finish.
159
+ */
160
+ export function unfinishable(hosting: Hosting): string[] {
161
+ if (hosting.journal('') !== undefined) return [];
162
+
163
+ const found: string[] = [];
164
+ for (const [entity] of hosting.entities()) {
165
+ for (const dependent of dependentsOf(entity, hosting)) {
166
+ if (dependent.onDelete === 'restrict') continue;
167
+ found.push(`${dependent.entity}.${dependent.field} → ${entity} (${dependent.onDelete})`);
168
+ }
169
+ }
170
+
171
+ return found;
172
+ }