@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
@@ -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
 
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
  }
@@ -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