@fougere/core 0.11.0-alpha.0 → 0.12.1-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.
- package/dist/EffectiveOperationModel.d.ts.map +1 -1
- package/dist/EffectiveOperationModel.js +18 -0
- package/dist/EffectiveOperationModel.js.map +1 -1
- package/dist/FougereConfig.d.ts +10 -6
- package/dist/FougereConfig.d.ts.map +1 -1
- package/dist/FougereConfig.js +19 -4
- package/dist/FougereConfig.js.map +1 -1
- package/dist/FrondConfig.d.ts +0 -6
- package/dist/FrondConfig.d.ts.map +1 -1
- package/dist/FrondConfig.js.map +1 -1
- package/dist/FrondDeclaration.d.ts +2 -9
- package/dist/FrondDeclaration.d.ts.map +1 -1
- package/dist/FrondDeclaration.js +0 -1
- package/dist/FrondDeclaration.js.map +1 -1
- package/dist/FrondsStated.d.ts +54 -0
- package/dist/FrondsStated.d.ts.map +1 -0
- package/dist/FrondsStated.js +26 -0
- package/dist/FrondsStated.js.map +1 -0
- package/dist/StatedFrond.d.ts +21 -0
- package/dist/StatedFrond.d.ts.map +1 -0
- package/dist/StatedFrond.js +34 -0
- package/dist/StatedFrond.js.map +1 -0
- package/dist/StatedModules.d.ts +19 -0
- package/dist/StatedModules.d.ts.map +1 -0
- package/dist/StatedModules.js +37 -0
- package/dist/StatedModules.js.map +1 -0
- package/dist/boot/CreateAppOptions.d.ts +19 -2
- package/dist/boot/CreateAppOptions.d.ts.map +1 -1
- package/dist/boot/Emissions.d.ts +9 -0
- package/dist/boot/Emissions.d.ts.map +1 -1
- package/dist/boot/Emissions.js +11 -5
- package/dist/boot/Emissions.js.map +1 -1
- package/dist/boot/bootstrap.d.ts.map +1 -1
- package/dist/boot/bootstrap.js +342 -307
- package/dist/boot/bootstrap.js.map +1 -1
- package/dist/boot/declared.d.ts.map +1 -1
- package/dist/boot/declared.js +3 -0
- package/dist/boot/declared.js.map +1 -1
- package/dist/boot/frame.d.ts +0 -4
- package/dist/boot/frame.d.ts.map +1 -1
- package/dist/boot/frame.js +31 -14
- package/dist/boot/frame.js.map +1 -1
- package/dist/boot/hosted.d.ts.map +1 -1
- package/dist/boot/hosted.js +4 -3
- package/dist/boot/hosted.js.map +1 -1
- package/dist/boot/install.d.ts +9 -0
- package/dist/boot/install.d.ts.map +1 -1
- package/dist/boot/install.js +275 -185
- package/dist/boot/install.js.map +1 -1
- package/dist/boot/nesting.d.ts +33 -0
- package/dist/boot/nesting.d.ts.map +1 -0
- package/dist/boot/nesting.js +164 -0
- package/dist/boot/nesting.js.map +1 -0
- package/dist/boot/ownership.d.ts +0 -1
- package/dist/boot/ownership.d.ts.map +1 -1
- package/dist/boot/ownership.js +66 -51
- package/dist/boot/ownership.js.map +1 -1
- package/dist/boot/ports.js +30 -25
- package/dist/boot/ports.js.map +1 -1
- package/dist/boot/remote.d.ts.map +1 -1
- package/dist/boot/remote.js +39 -30
- package/dist/boot/remote.js.map +1 -1
- package/dist/boot/seed.d.ts.map +1 -1
- package/dist/boot/seed.js +27 -20
- package/dist/boot/seed.js.map +1 -1
- package/dist/boot/together.d.ts.map +1 -1
- package/dist/boot/together.js +71 -56
- package/dist/boot/together.js.map +1 -1
- package/dist/contract.d.ts +2 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +2 -0
- package/dist/contract.js.map +1 -1
- package/dist/descriptor/FrondDescriptor.d.ts +7 -0
- package/dist/descriptor/FrondDescriptor.d.ts.map +1 -1
- package/dist/descriptor/MiddlewareEntry.d.ts +6 -5
- package/dist/descriptor/MiddlewareEntry.d.ts.map +1 -1
- package/dist/descriptor/ProviderEntry.d.ts +7 -0
- package/dist/descriptor/ProviderEntry.d.ts.map +1 -1
- package/dist/dispatch/HandlerFacade.d.ts +6 -1
- package/dist/dispatch/HandlerFacade.d.ts.map +1 -1
- package/dist/dispatch/HandlerFacade.js +23 -20
- package/dist/dispatch/HandlerFacade.js.map +1 -1
- package/dist/dispatch/OutputView.d.ts.map +1 -1
- package/dist/dispatch/OutputView.js +7 -0
- package/dist/dispatch/OutputView.js.map +1 -1
- package/dist/dispatch/PresenterExecutor.d.ts +6 -0
- package/dist/dispatch/PresenterExecutor.d.ts.map +1 -1
- package/dist/dispatch/PresenterExecutor.js +37 -22
- package/dist/dispatch/PresenterExecutor.js.map +1 -1
- package/dist/dispatch/Received.d.ts +3 -0
- package/dist/dispatch/Received.d.ts.map +1 -0
- package/dist/dispatch/Received.js +2 -0
- package/dist/dispatch/Received.js.map +1 -0
- package/dist/dispatch/Release.js +53 -47
- package/dist/dispatch/Release.js.map +1 -1
- package/dist/dispatch/StorageGuard.d.ts +21 -0
- package/dist/dispatch/StorageGuard.d.ts.map +1 -1
- package/dist/dispatch/StorageGuard.js +84 -66
- package/dist/dispatch/StorageGuard.js.map +1 -1
- package/dist/dispatch/decoded.d.ts +19 -0
- package/dist/dispatch/decoded.d.ts.map +1 -0
- package/dist/dispatch/decoded.js +36 -0
- package/dist/dispatch/decoded.js.map +1 -0
- package/dist/entry/facade.d.ts +9 -2
- package/dist/entry/facade.d.ts.map +1 -1
- package/dist/entry/facade.js +16 -7
- package/dist/entry/facade.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +2 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +2 -1
- package/dist/node.js.map +1 -1
- package/dist/prefab/CrudConstructor.d.ts.map +1 -1
- package/dist/prefab/CrudConstructor.js +3 -2
- package/dist/prefab/CrudConstructor.js.map +1 -1
- package/dist/prefab/CrudOps.d.ts +2 -2
- package/dist/prefab/CrudOps.d.ts.map +1 -1
- package/dist/prefab/MirrorConstructor.js +1 -1
- package/dist/prefab/MirrorConstructor.js.map +1 -1
- package/dist/prefab/Refreshed.d.ts +7 -2
- package/dist/prefab/Refreshed.d.ts.map +1 -1
- package/dist/storage/Storage.js +1 -1
- package/dist/storage/Storage.js.map +1 -1
- package/dist/storage/Store.d.ts.map +1 -1
- package/dist/storage/Store.js +35 -31
- package/dist/storage/Store.js.map +1 -1
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +14 -0
- package/dist/verify.js.map +1 -1
- package/dist/wire/AppMiddleware.d.ts +10 -1
- package/dist/wire/AppMiddleware.d.ts.map +1 -1
- package/dist/wire/AppMiddleware.js.map +1 -1
- package/dist/wire/AppNext.d.ts +2 -1
- package/dist/wire/AppNext.d.ts.map +1 -1
- package/dist/wire/Facade.d.ts +14 -2
- package/dist/wire/Facade.d.ts.map +1 -1
- package/dist/wire/Facade.js.map +1 -1
- package/dist/wire/FougereError.js +1 -1
- package/dist/wire/FougereError.js.map +1 -1
- package/dist/wire/Invocation.d.ts +1 -1
- package/dist/wire/Invocation.d.ts.map +1 -1
- package/dist/wire/Invocation.js +1 -1
- package/dist/wire/Invocation.js.map +1 -1
- package/dist/wire/OperationContract.js +1 -1
- package/dist/wire/OperationContract.js.map +1 -1
- package/dist/wire/Page.d.ts +32 -0
- package/dist/wire/Page.d.ts.map +1 -0
- package/dist/wire/Page.js +32 -0
- package/dist/wire/Page.js.map +1 -0
- package/dist/wire/Signature.d.ts +7 -0
- package/dist/wire/Signature.d.ts.map +1 -1
- package/dist/wire/binding.d.ts.map +1 -1
- package/dist/wire/binding.js +29 -57
- package/dist/wire/binding.js.map +1 -1
- package/dist/wire/drift.d.ts.map +1 -1
- package/dist/wire/drift.js +22 -19
- package/dist/wire/drift.js.map +1 -1
- package/package.json +4 -4
- package/src/EffectiveOperationModel.ts +19 -0
- package/src/FougereConfig.ts +24 -10
- package/src/FrondConfig.ts +0 -6
- package/src/FrondDeclaration.ts +2 -7
- package/src/FrondsStated.ts +72 -0
- package/src/StatedFrond.ts +51 -0
- package/src/StatedModules.ts +58 -0
- package/src/boot/CreateAppOptions.ts +19 -2
- package/src/boot/Emissions.ts +16 -5
- package/src/boot/bootstrap.ts +478 -359
- package/src/boot/declared.ts +3 -0
- package/src/boot/frame.ts +42 -11
- package/src/boot/hosted.ts +4 -3
- package/src/boot/install.ts +392 -212
- package/src/boot/nesting.ts +196 -0
- package/src/boot/ownership.ts +79 -51
- package/src/boot/ports.ts +45 -31
- package/src/boot/remote.ts +57 -32
- package/src/boot/seed.ts +36 -27
- package/src/boot/together.ts +98 -63
- package/src/contract.ts +2 -0
- package/src/descriptor/FrondDescriptor.ts +7 -0
- package/src/descriptor/MiddlewareEntry.ts +6 -5
- package/src/descriptor/ProviderEntry.ts +7 -0
- package/src/dispatch/HandlerFacade.ts +27 -17
- package/src/dispatch/OutputView.ts +7 -0
- package/src/dispatch/PresenterExecutor.ts +40 -22
- package/src/dispatch/Received.ts +2 -0
- package/src/dispatch/Release.ts +68 -44
- package/src/dispatch/StorageGuard.ts +90 -69
- package/src/dispatch/decoded.ts +37 -0
- package/src/entry/facade.ts +21 -9
- package/src/index.ts +4 -0
- package/src/node.ts +2 -1
- package/src/prefab/CrudConstructor.ts +3 -2
- package/src/prefab/CrudOps.ts +2 -2
- package/src/prefab/MirrorConstructor.ts +1 -1
- package/src/prefab/Refreshed.ts +7 -2
- package/src/storage/Storage.ts +2 -2
- package/src/storage/Store.ts +45 -27
- package/src/verify.ts +15 -0
- package/src/wire/AppMiddleware.ts +10 -1
- package/src/wire/AppNext.ts +2 -1
- package/src/wire/Facade.ts +14 -2
- package/src/wire/FougereError.ts +1 -1
- package/src/wire/Invocation.ts +2 -1
- package/src/wire/OperationContract.ts +1 -1
- package/src/wire/Page.ts +50 -0
- package/src/wire/Signature.ts +7 -0
- package/src/wire/binding.ts +26 -58
- package/src/wire/drift.ts +48 -21
package/src/boot/bootstrap.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { createContainer, type Container } from '@fougere/container';
|
|
2
2
|
import { lowerFirst, type SchemaView } from '@fougere/schema';
|
|
3
3
|
|
|
4
4
|
import type { HandlerEntry } from '../descriptor/HandlerEntry.js';
|
|
5
5
|
import type { PresenterEntry } from '../descriptor/PresenterEntry.js';
|
|
6
6
|
import { hostedBy } from './hosted.js';
|
|
7
|
+
import { nestingOf, parentsFirst } from './nesting.js';
|
|
8
|
+
import type { Fronds } from '../descriptor/Fronds.js';
|
|
7
9
|
import { installFrond, type Assembly } from './install.js';
|
|
8
10
|
import { dependentsOf, releasing, unfinishable, unheldAmong } from './relations.js';
|
|
9
11
|
import { release as releaseRow } from '../dispatch/Release.js';
|
|
@@ -25,13 +27,14 @@ import { emitKeyOf, type Emit } from '../wire/Emit.js';
|
|
|
25
27
|
/** The fact the boot announces, spelled once. */
|
|
26
28
|
const LOG_LINE = lowerFirst(LogLine.name);
|
|
27
29
|
import { Config } from '../builtin/config.js';
|
|
28
|
-
import { createRemoteRouter, createRemoteFacade } from './remote.js';
|
|
30
|
+
import { createRemoteRouter, createRemoteFacade, type RemoteRouter } from './remote.js';
|
|
31
|
+
import type { Peer } from './Peer.js';
|
|
29
32
|
|
|
30
33
|
import { Emissions } from './Emissions.js';
|
|
31
34
|
|
|
32
35
|
import type { OperationContract } from '../wire/OperationContract.js';
|
|
33
36
|
import type { OperationsMap } from '../wire/OperationsMap.js';
|
|
34
|
-
import { resolveEffectiveOperations } from '../EffectiveOperationModel.js';
|
|
37
|
+
import { resolveEffectiveOperations, type EffectiveOperationModel } from '../EffectiveOperationModel.js';
|
|
35
38
|
import { type EffectiveOperationsMap } from '../EffectiveOperationsMap.js';
|
|
36
39
|
|
|
37
40
|
import { InFlight } from '../dispatch/InFlight.js';
|
|
@@ -100,9 +103,463 @@ function keyClaims(
|
|
|
100
103
|
}
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
/**
|
|
107
|
+
* What this app hosts, and what its operations effectively are.
|
|
108
|
+
*
|
|
109
|
+
* Producing it may read a disk; consuming it never does, which is the whole reason this file
|
|
110
|
+
* names no builtin and a Worker can run what it builds.
|
|
111
|
+
*/
|
|
112
|
+
async function readFronds(
|
|
113
|
+
options: CreateAppOptions,
|
|
114
|
+
log: Logger,
|
|
115
|
+
): Promise<{ fronds: Fronds; operationModel: EffectiveOperationModel }> {
|
|
116
|
+
const scanStart = performance.now();
|
|
117
|
+
// An extension's fronds sit beside the app's own: it is installed like any other, and
|
|
118
|
+
// its handlers resolve at call time — by which point the extension's `up` has put what
|
|
119
|
+
// they ask for in the container.
|
|
120
|
+
const brought = (options.extensions ?? [])
|
|
121
|
+
.flatMap((extension) => extension?.fronds ?? [])
|
|
122
|
+
.map((frond) => ({ ...frond, brought: true as const }));
|
|
123
|
+
const { fronds, diagnostics } = await hostedBy(
|
|
124
|
+
brought.length > 0 ? { ...options, fronds: [...(options.fronds ?? []), ...brought] } : options,
|
|
125
|
+
);
|
|
126
|
+
// An app that states nothing AND scans nothing is a mistake — unless something else it
|
|
127
|
+
// declares brings its own entities, which an auth provider does. Refused here and not in
|
|
128
|
+
// `hostedBy`, which is handed the frond sources and cannot see the rest of the app. The
|
|
129
|
+
// condition is the KEYS, not the count: a scan that found nothing is an ordinary answer.
|
|
130
|
+
if (!options.fronds && !options.scan && !options.auth && brought.length === 0) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
'createApp needs `fronds:` (what this app states) or `scan:` (what a scanner found). '
|
|
133
|
+
+ 'Neither was given, and nothing else declares entities of its own.\n'
|
|
134
|
+
+ ' createApp({ fronds: [blog] })\n'
|
|
135
|
+
+ ' createApp({ scan: await scanProject(root) })',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const { under, refused } = nestingOf(options.under, fronds, options.remotes, options.narrowed);
|
|
140
|
+
const refusal = refusalOf(refused, 'thing(s) the frond tree does not allow');
|
|
141
|
+
if (refusal) throw refusal;
|
|
142
|
+
|
|
143
|
+
for (const frond of fronds) {
|
|
144
|
+
const parent = under.get(frond.name);
|
|
145
|
+
if (parent !== undefined) frond.extends = parent;
|
|
146
|
+
}
|
|
147
|
+
const ordered = parentsFirst(fronds, under);
|
|
148
|
+
|
|
149
|
+
const operationModel = resolveEffectiveOperations(ordered, {
|
|
150
|
+
diagnostics,
|
|
151
|
+
remotes: options.remotes,
|
|
152
|
+
adapters: options.adapters,
|
|
153
|
+
});
|
|
154
|
+
const scanMs = (performance.now() - scanStart).toFixed(0);
|
|
155
|
+
log.info(`read ${ordered.length} frond(s) in ${scanMs}ms`
|
|
156
|
+
+ (diagnostics.length ? ` — ${diagnostics.length} thing(s) the scan could not do` : ''));
|
|
157
|
+
|
|
158
|
+
/** Say what could not be read, at the one line everyone already watches. */
|
|
159
|
+
for (const d of diagnostics.filter((one) => one.severity === 'blocking')) log.error(`[${d.code}] ${d.message}`, d.cause);
|
|
160
|
+
for (const d of diagnostics) if (d.severity === 'warning') log.warn(`[${d.code}] ${d.message}`);
|
|
161
|
+
|
|
162
|
+
/** An ambiguous convention is not a partial scan. */
|
|
163
|
+
const unresolved = refusalOf(operationModel.resolutionDiagnostics, 'unresolved operation contract(s)');
|
|
164
|
+
if (unresolved) throw unresolved;
|
|
165
|
+
|
|
166
|
+
return { fronds: ordered, operationModel };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* What core answers under `rpc`, in every process, whatever fronds it carries.
|
|
171
|
+
*
|
|
172
|
+
* All four read the STORAGE and never a facade: a facade answers what its handler chose to show,
|
|
173
|
+
* so `PostHandler.list` hiding drafts would hide exactly the row the question exists to find.
|
|
174
|
+
*/
|
|
175
|
+
function serveCoreRpc(
|
|
176
|
+
app: App,
|
|
177
|
+
hosting: Hosting,
|
|
178
|
+
storageFor: (entity: string) => unknown | undefined,
|
|
179
|
+
): void {
|
|
180
|
+
app.serveRpc('discover', (_invocation, surface) => identityCardOf(app, surface));
|
|
181
|
+
|
|
182
|
+
app.serveRpc('holds', async (invocation) => {
|
|
183
|
+
const named = String(invocation.params.entity);
|
|
184
|
+
const keys = (invocation.params.keys ?? []) as readonly unknown[];
|
|
185
|
+
const rows = storageFor(named) as { findByKeys(k: readonly string[]): Promise<Map<string, unknown>> } | undefined;
|
|
186
|
+
if (!rows) return { missing: [] };
|
|
187
|
+
const found = await rows.findByKeys(keys.map(String));
|
|
188
|
+
|
|
189
|
+
return { missing: keys.filter((key) => !found.has(String(key))) };
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
app.serveRpc('dependents', (invocation) => dependentsOf(String(invocation.params.entity), hosting));
|
|
193
|
+
|
|
194
|
+
app.serveRpc('release', async (invocation) => {
|
|
195
|
+
await releaseRow(String(invocation.params.entity), invocation.params.key, releasing(hosting),
|
|
196
|
+
(invocation.params.visited ?? []) as readonly string[]);
|
|
197
|
+
|
|
198
|
+
return { released: true };
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* What no single frond can see about a `ref()`, so it is said once every frond is installed.
|
|
204
|
+
*
|
|
205
|
+
* A target belongs to another frond, and a journal arrives with an extension, which rises last.
|
|
206
|
+
*/
|
|
207
|
+
function warnAboutRelations(relations: RelationCheck[], hosting: Hosting, log: Logger): void {
|
|
208
|
+
const unfinished = unfinishable(hosting);
|
|
209
|
+
if (unfinished.length > 0) {
|
|
210
|
+
log.warn(
|
|
211
|
+
`[relations] ${unfinished.join(', ')} — declared, and this process keeps nothing on `
|
|
212
|
+
+ 'restart: a release interrupted here is not resumed. Install @fougere/workflow, or '
|
|
213
|
+
+ 'expect to finish one by hand.',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const unheld = unheldAmong(relations, hosting);
|
|
218
|
+
if (unheld.length > 0) {
|
|
219
|
+
log.warn(
|
|
220
|
+
`[relations] ${unheld.join(', ')} — declared, and nothing in this process holds them: `
|
|
221
|
+
+ 'no foreign key, and the target answers elsewhere. A row may name one that is gone.',
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The process that serves an entity, asked the three questions a release travels on. */
|
|
227
|
+
function peerBehind(entity: string, router: RemoteRouter): Peer {
|
|
228
|
+
const ask = async (op: string, params: Record<string, unknown>): Promise<unknown> =>
|
|
229
|
+
(await router.route(entity)).transport(
|
|
230
|
+
{ entity: RPC_ENTITY, op },
|
|
231
|
+
{ ...Invocation.empty, params: params as never },
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
dependents: async (named) => await ask('dependents', { entity: named }) as never,
|
|
236
|
+
missing: async (named, keys) =>
|
|
237
|
+
(await ask('holds', { entity: named, keys }) as { missing: readonly unknown[] }).missing,
|
|
238
|
+
release: async (named, key) => { await ask('release', { entity: named, key }); },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Close the door, and answer once the calls already running are done. */
|
|
243
|
+
async function drainCalls(inflight: InFlight, timeoutMs?: number): Promise<void> {
|
|
244
|
+
inflight.close();
|
|
245
|
+
if (timeoutMs === undefined) return inflight.whenIdle();
|
|
246
|
+
|
|
247
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
248
|
+
|
|
249
|
+
await Promise.race([
|
|
250
|
+
inflight.whenIdle().then(() => clearTimeout(timer)),
|
|
251
|
+
new Promise<never>((_, reject) => {
|
|
252
|
+
timer = setTimeout(
|
|
253
|
+
() => reject(new Error(`[drain] ${inflight.count} call(s) still running after ${timeoutMs}ms`)),
|
|
254
|
+
timeoutMs,
|
|
255
|
+
);
|
|
256
|
+
}),
|
|
257
|
+
]);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** What a call goes through in this process, before any frond is installed into it. */
|
|
261
|
+
interface Dispatching {
|
|
262
|
+
/** Counted at the one facade every caller goes through, so releasing can wait for the work. */
|
|
263
|
+
inflight: InFlight;
|
|
264
|
+
/** Where a late observer is added — `App.observe` is its only caller. */
|
|
265
|
+
dispatchLifecycle: DispatchLifecycle;
|
|
266
|
+
routeRegistry: RouteRegistry;
|
|
267
|
+
/** Follows the topology — a local façade and a remote doublure alike. */
|
|
268
|
+
dispatcher: Dispatcher;
|
|
269
|
+
/** Stays home: what this process serves, and nothing else. */
|
|
270
|
+
localDispatcher: Dispatcher;
|
|
271
|
+
getMiddlewares(entity: string): AppMiddleware[];
|
|
272
|
+
/** The one place a middleware is taken on — `App.use` is its late form, a frond's directory its early one. */
|
|
273
|
+
use(middleware: AppMiddleware, entity?: string): void;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function dispatching(
|
|
277
|
+
options: CreateAppOptions,
|
|
278
|
+
log: Logger,
|
|
279
|
+
fronds: Fronds,
|
|
280
|
+
journalOf: () => Journal | undefined,
|
|
281
|
+
): Dispatching {
|
|
282
|
+
const inflight = new InFlight();
|
|
283
|
+
const globalMiddlewares: AppMiddleware[] = [];
|
|
284
|
+
const scopedMiddlewares = new Map<string, AppMiddleware[]>();
|
|
285
|
+
const routeRegistry = new RouteRegistry();
|
|
286
|
+
const dispatchLifecycle = new DispatchLifecycle(
|
|
287
|
+
options.dispatchObservers,
|
|
288
|
+
(error, event) => log.error(
|
|
289
|
+
`[dispatch-observer] ${event.stage} ${event.call.address.toString()}`,
|
|
290
|
+
error,
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
inflight,
|
|
296
|
+
routeRegistry,
|
|
297
|
+
dispatchLifecycle,
|
|
298
|
+
// Resolved at the call and never here: a journal is a provider of a brought frond, and the
|
|
299
|
+
// extension that registers it rises long after this line.
|
|
300
|
+
dispatcher: new Dispatcher(routeRegistry, inflight, dispatchLifecycle, undefined, journalOf),
|
|
301
|
+
localDispatcher: new Dispatcher(
|
|
302
|
+
routeRegistry,
|
|
303
|
+
inflight,
|
|
304
|
+
dispatchLifecycle,
|
|
305
|
+
new LocalRoutePolicy((surface) => fronds.servedNames(surface)),
|
|
306
|
+
journalOf,
|
|
307
|
+
),
|
|
308
|
+
getMiddlewares: (entity) => [...globalMiddlewares, ...(scopedMiddlewares.get(entity) ?? [])],
|
|
309
|
+
use(middleware, entity) {
|
|
310
|
+
if (entity === undefined) {
|
|
311
|
+
globalMiddlewares.push(middleware);
|
|
312
|
+
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
scopedMiddlewares.set(entity, [...(scopedMiddlewares.get(entity) ?? []), middleware]);
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Every entity of every frond, who holds it, and the door a fact goes through. */
|
|
322
|
+
function whatTheAppKnows(
|
|
323
|
+
fronds: Fronds,
|
|
324
|
+
container: Container,
|
|
325
|
+
options: CreateAppOptions,
|
|
326
|
+
refused: Diagnostic[],
|
|
327
|
+
): { entityByName: Map<string, SchemaView>; frondOf: Map<string, string>; emissions: Emissions } {
|
|
328
|
+
// By name, so a fact can be validated where it LANDS and a `reads:` clause can name a
|
|
329
|
+
// neighbour's entity.
|
|
330
|
+
const entityByName = fronds.schemas();
|
|
331
|
+
// The line is core's, so its SHAPE is too: a destination that declares only a handler would
|
|
332
|
+
// otherwise be handed a line with no `at` — the announcement stamps `created()` off the shape,
|
|
333
|
+
// and the strict judge refuses what it did not stamp. Measured on `demos/observability`, where
|
|
334
|
+
// the ring held 11 calls and 0 lines.
|
|
335
|
+
if (!entityByName.has(LOG_LINE)) entityByName.set(LOG_LINE, LogLine);
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
entityByName,
|
|
339
|
+
// What turns "a member is remote" into a refusal that names the FROND rather than the
|
|
340
|
+
// entity, since `remotes:` is declared per frond.
|
|
341
|
+
frondOf: new Map(fronds.flatMap((one) => one.entities.map((entity) => [entity.name, one.name] as const))),
|
|
342
|
+
emissions: new Emissions(
|
|
343
|
+
fronds, entityByName, container,
|
|
344
|
+
// No carry: this is what CARRIES a fact, and a line about carrying one comes back here.
|
|
345
|
+
new Logger('boot:app'),
|
|
346
|
+
refused,
|
|
347
|
+
options.onEmit,
|
|
348
|
+
),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** What every check of the boot asks about where a row lives, and who else might hold one. */
|
|
353
|
+
interface Hosted {
|
|
354
|
+
fronds: Fronds;
|
|
355
|
+
container: Container;
|
|
356
|
+
options: CreateAppOptions;
|
|
357
|
+
declaredRemotes: [string, string][];
|
|
358
|
+
remoteRouter: RemoteRouter | undefined;
|
|
359
|
+
entityByName: Map<string, SchemaView>;
|
|
360
|
+
frondOf: Map<string, string>;
|
|
361
|
+
storageOf: (entity: string) => unknown | undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function hostingFor(
|
|
365
|
+
{ fronds, container, options, declaredRemotes, remoteRouter, entityByName, frondOf, storageOf }: Hosted,
|
|
366
|
+
): { hosting: Hosting; journalOf: () => Journal | undefined } {
|
|
367
|
+
// What carries a release writes none: an instrumentation frond's own rows are kept while a
|
|
368
|
+
// release happens, and journalling them would begin one inside the one being written down.
|
|
369
|
+
const carriesRelease = new Set(
|
|
370
|
+
fronds.filter((one) => one.brought).flatMap((one) => one.entities.map((entity) => entity.name)),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The journal a package registered, found where its own frond put it — a provider lands in its
|
|
375
|
+
* frond's scope, and the app container sees none of them. Resolved per call and never at boot:
|
|
376
|
+
* the package that registers it rises in the ascent, long after this answer is built.
|
|
377
|
+
*/
|
|
378
|
+
const journalOf = (): Journal | undefined => {
|
|
379
|
+
for (const frond of fronds) {
|
|
380
|
+
if (!frond.brought) continue;
|
|
381
|
+
const scope = container.resolve<Container>(`frond:${frond.name}`);
|
|
382
|
+
if (scope.has(JOURNAL)) return scope.resolve<Journal>(JOURNAL);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return undefined;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const hosting: Hosting = {
|
|
389
|
+
hostedHere: (entity) => !(options.remotes && (frondOf.get(entity) ?? '') in options.remotes),
|
|
390
|
+
sourceOf: (name) => options.sourceOf?.(name) ?? 'db',
|
|
391
|
+
enforces: (source, constraint) => options.enforces?.(source, constraint) ?? false,
|
|
392
|
+
storageOf,
|
|
393
|
+
entities: () => entityByName,
|
|
394
|
+
journal: (entity) => (carriesRelease.has(entity) ? undefined : journalOf()),
|
|
395
|
+
// Built from `remotes:` itself and not from the router: the router indexes by ENTITY, read
|
|
396
|
+
// off a card, while the question here is asked of a PROCESS about rows it may be alone in
|
|
397
|
+
// knowing about. Nothing is cached — a peer that was down at boot answers the next call.
|
|
398
|
+
peers: () => (options.remoteTransport
|
|
399
|
+
? declaredRemotes.map(([, url]) => peerOver(options.remoteTransport!(url)))
|
|
400
|
+
: []),
|
|
401
|
+
peerOf: (entity) => (remoteRouter ? peerBehind(entity, remoteRouter) : undefined),
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
return { hosting, journalOf };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** What the readings below are built from — every one of them answers about a SERVED entity. */
|
|
408
|
+
interface Serving {
|
|
409
|
+
container: Container;
|
|
410
|
+
fronds: Fronds;
|
|
411
|
+
remoteRouter: RemoteRouter | undefined;
|
|
412
|
+
localDispatcher: Dispatcher;
|
|
413
|
+
routeRegistry: RouteRegistry;
|
|
414
|
+
effectiveByKey: Map<string, EffectiveOperationsMap>;
|
|
415
|
+
log: Logger;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* What an app answers about what it serves — the membership rule and its neighbours.
|
|
420
|
+
*
|
|
421
|
+
* `facadeFor` is THE rule, stated once: every projection reads it and nothing else.
|
|
422
|
+
*/
|
|
423
|
+
function readings(
|
|
424
|
+
{ container, fronds, remoteRouter, localDispatcher, routeRegistry, effectiveByKey, log }: Serving,
|
|
425
|
+
): Pick<App, 'resolve' | 'schemaFor' | 'facadeFor' | 'operationsFor' | 'presenterFor'> {
|
|
426
|
+
/** Stop taking calls, and resolve once the ones already running are done. */
|
|
427
|
+
const resolve = <T>(name: string): T => {
|
|
428
|
+
try {
|
|
429
|
+
return container.resolve<T>(name);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
if (name.endsWith('Handler') && !name.includes(':') && !remoteRouter) {
|
|
432
|
+
throw new Error(notLoaded(name.replace(/Handler$/, '')));
|
|
433
|
+
}
|
|
434
|
+
throw err;
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const schemaFor = async (entity: string): Promise<SchemaView> => {
|
|
439
|
+
const found = fronds.entity(entity);
|
|
440
|
+
if (found) return found.entityClass;
|
|
441
|
+
if (remoteRouter) {
|
|
442
|
+
const route = await remoteRouter.route(entity);
|
|
443
|
+
// A remote facade that stores nothing publishes ops and no shape. Saying so beats
|
|
444
|
+
// handing back an empty schema, which would validate every input it was given.
|
|
445
|
+
if (!route.schema) {
|
|
446
|
+
throw new Error(
|
|
447
|
+
`'${entity}' is served by frond '${route.frond}' but stores no rows, so it has no schema. `
|
|
448
|
+
+ `Call its operations through the façade instead.`,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
return route.schema;
|
|
452
|
+
}
|
|
453
|
+
throw new Error(notLoaded(entity));
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const facadeAt = (key: string, topology: boolean): Record<string, Function> | undefined => {
|
|
457
|
+
try {
|
|
458
|
+
return topology
|
|
459
|
+
? resolve<Record<string, Function>>(key)
|
|
460
|
+
: container.resolve<Record<string, Function>>(key);
|
|
461
|
+
} catch {
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
/** Said once per pair, so a facade that registers in a loop says it once. */
|
|
467
|
+
const saidAbsent = new Set<string>();
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* A surface is declared in the frond that serves it. When that frond runs in another
|
|
471
|
+
* process, this one never asked for its facades, and answering 'no' is the only thing a
|
|
472
|
+
* synchronous rule can do — so it says so rather than registering nothing in silence.
|
|
473
|
+
*/
|
|
474
|
+
const sayNoSurfaceAcross = (entity: string, surface: string): void => {
|
|
475
|
+
if (!remoteRouter || saidAbsent.has(`${surface}:${entity}`)) return;
|
|
476
|
+
saidAbsent.add(`${surface}:${entity}`);
|
|
477
|
+
log.warn(
|
|
478
|
+
`surface '${surface}' serves nothing for '${entity}' — the frond that declares it runs `
|
|
479
|
+
+ 'elsewhere, and a remote is asked for its facades at the first call, not at boot. '
|
|
480
|
+
+ 'The default facade answers.',
|
|
481
|
+
);
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
/** THE membership rule, stated once — every projection reads this and nothing else. */
|
|
485
|
+
const facadeFor = (entity: string, surface?: string): Record<string, Function> | undefined => {
|
|
486
|
+
if (!surface) return facadeAt(facadeKeyOf(entity), true);
|
|
487
|
+
|
|
488
|
+
const own = facadeAt(facadeKeyOf(entity, surface), false);
|
|
489
|
+
const owner = fronds.owner(entity);
|
|
490
|
+
if (!owner) {
|
|
491
|
+
sayNoSurfaceAcross(entity, surface);
|
|
492
|
+
return own;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const declared = owner.surfaces?.[surface];
|
|
496
|
+
if (!declared) return own;
|
|
497
|
+
if (!declared.some((n) => n.toLowerCase() === entity.toLowerCase())) return undefined;
|
|
498
|
+
if (own) return own;
|
|
499
|
+
|
|
500
|
+
const fallback = facadeAt(facadeKeyOf(entity), false);
|
|
501
|
+
return fallback
|
|
502
|
+
? facadeOperations(
|
|
503
|
+
localDispatcher,
|
|
504
|
+
entity,
|
|
505
|
+
routeRegistry.operationNames(entity, surface),
|
|
506
|
+
surface,
|
|
507
|
+
)
|
|
508
|
+
: undefined;
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
/** The terms beside a facade, with the exact same named-surface fallback rule. */
|
|
512
|
+
const operationsFor = (entity: string, surface?: string): EffectiveOperationsMap | undefined => {
|
|
513
|
+
if (!surface) return effectiveByKey.get(facadeKeyOf(entity));
|
|
514
|
+
|
|
515
|
+
const own = effectiveByKey.get(facadeKeyOf(entity, surface));
|
|
516
|
+
const declared = fronds.owner(entity)?.surfaces?.[surface];
|
|
517
|
+
if (!declared) return own;
|
|
518
|
+
return declared.some((name) => name.toLowerCase() === entity.toLowerCase())
|
|
519
|
+
? (own ?? effectiveByKey.get(facadeKeyOf(entity)))
|
|
520
|
+
: undefined;
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
/** The presenter of an entity, resolved through its owning frond's scope. */
|
|
524
|
+
const presenterFor = (entity: string): unknown | undefined => {
|
|
525
|
+
const owner = fronds.owner(entity);
|
|
526
|
+
if (!owner) return undefined;
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
return container.resolve<Container>(`frond:${owner.name}`).resolve(presenterKeyOf(entity));
|
|
530
|
+
} catch {
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
return { resolve, schemaFor, facadeFor, operationsFor, presenterFor };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Built once from the lazy `AuthConfig` a provider factory produced (`betterAuth({…})` in
|
|
540
|
+
* fougere.config.ts). The provider receives our db and storage factory, so every auth write
|
|
541
|
+
* flows through `Storage` like any other.
|
|
542
|
+
*/
|
|
543
|
+
async function authFor(options: CreateAppOptions, log: Logger): Promise<AuthRuntime | undefined> {
|
|
544
|
+
if (!options.auth) return undefined;
|
|
545
|
+
|
|
546
|
+
if (!options.storageFactory) {
|
|
547
|
+
throw new Error('createApp: `auth` is set but `storageFactory` is missing — auth providers need it to back their adapter. Pass one through CreateAppOptions.storageFactory.');
|
|
548
|
+
}
|
|
549
|
+
if (options.db === undefined) {
|
|
550
|
+
throw new Error('createApp: `auth` is set but `db` is missing — pass the storage handle through CreateAppOptions.db.');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
log.info('initializing auth runtime');
|
|
554
|
+
const runtime = await options.auth.create({ db: options.db, storageFactory: options.storageFactory });
|
|
555
|
+
log.info(`auth ready — mounted at ${runtime.basePath}`);
|
|
556
|
+
|
|
557
|
+
return runtime;
|
|
558
|
+
}
|
|
559
|
+
|
|
103
560
|
/** Bootstrap a fougere application. */
|
|
104
561
|
export async function createApp(options: CreateAppOptions): Promise<App> {
|
|
105
|
-
const container = options.createContainer();
|
|
562
|
+
const container = (options.createContainer ?? createContainer)();
|
|
106
563
|
|
|
107
564
|
// Held out here, and not where the ascent reads it, because releasing needs it and
|
|
108
565
|
// releasing has to work from the first line the boot takes something.
|
|
@@ -167,67 +624,9 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
|
|
|
167
624
|
container.register('Config', Config, { lifetime: 'singleton' });
|
|
168
625
|
log.debug('builtins registered (Logger, Config)');
|
|
169
626
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const scanStart = performance.now();
|
|
174
|
-
// An extension's fronds sit beside the app's own: it is installed like any other, and
|
|
175
|
-
// its handlers resolve at call time — by which point the extension's `up` has put what
|
|
176
|
-
// they ask for in the container.
|
|
177
|
-
const brought = (options.extensions ?? [])
|
|
178
|
-
.flatMap((extension) => extension?.fronds ?? [])
|
|
179
|
-
.map((frond) => ({ ...frond, brought: true as const }));
|
|
180
|
-
const { fronds, diagnostics } = await hostedBy(
|
|
181
|
-
brought.length > 0 ? { ...options, fronds: [...(options.fronds ?? []), ...brought] } : options,
|
|
182
|
-
);
|
|
183
|
-
// An app that states nothing AND scans nothing is a mistake — unless something else it
|
|
184
|
-
// declares brings its own entities, which an auth provider does. Refused here and not in
|
|
185
|
-
// `hostedBy`, which is handed the frond sources and cannot see the rest of the app. The
|
|
186
|
-
// condition is the KEYS, not the count: a scan that found nothing is an ordinary answer.
|
|
187
|
-
if (!options.fronds && !options.scan && !options.auth && brought.length === 0) {
|
|
188
|
-
throw new Error(
|
|
189
|
-
'createApp needs `fronds:` (what this app states) or `scan:` (what a scanner found). '
|
|
190
|
-
+ 'Neither was given, and nothing else declares entities of its own.\n'
|
|
191
|
-
+ ' createApp({ fronds: [blog] })\n'
|
|
192
|
-
+ ' createApp({ scan: await scanProject(root) })',
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
const operationModel = resolveEffectiveOperations(fronds, {
|
|
196
|
-
diagnostics,
|
|
197
|
-
remotes: options.remotes,
|
|
198
|
-
adapters: options.adapters,
|
|
199
|
-
});
|
|
200
|
-
const scanMs = (performance.now() - scanStart).toFixed(0);
|
|
201
|
-
const blocking = diagnostics.filter((d) => d.severity === 'blocking');
|
|
202
|
-
log.info(`read ${fronds.length} frond(s) in ${scanMs}ms`
|
|
203
|
-
+ (diagnostics.length ? ` — ${diagnostics.length} thing(s) the scan could not do` : ''));
|
|
204
|
-
|
|
205
|
-
/** Say what could not be read, at the one line everyone already watches. */
|
|
206
|
-
for (const d of blocking) log.error(`[${d.code}] ${d.message}`, d.cause);
|
|
207
|
-
for (const d of diagnostics) if (d.severity === 'warning') log.warn(`[${d.code}] ${d.message}`);
|
|
208
|
-
|
|
209
|
-
/** An ambiguous convention is not a partial scan. */
|
|
210
|
-
const unresolved = refusalOf(operationModel.resolutionDiagnostics, 'unresolved operation contract(s)');
|
|
211
|
-
if (unresolved) throw unresolved;
|
|
212
|
-
|
|
213
|
-
// Auth runtime — built once from the lazy AuthConfig produced by a provider factory
|
|
214
|
-
// (e.g. betterAuth({...})) in fougere.config.ts. The provider receives our db +
|
|
215
|
-
// storageFactory so all auth writes flow through Storage.
|
|
216
|
-
let authRuntime: AuthRuntime | undefined;
|
|
217
|
-
if (options.auth) {
|
|
218
|
-
if (!options.storageFactory) {
|
|
219
|
-
throw new Error('createApp: `auth` is set but `storageFactory` is missing — auth providers need it to back their adapter. Pass one through CreateAppOptions.storageFactory.');
|
|
220
|
-
}
|
|
221
|
-
if (options.db === undefined) {
|
|
222
|
-
throw new Error('createApp: `auth` is set but `db` is missing — pass the storage handle through CreateAppOptions.db.');
|
|
223
|
-
}
|
|
224
|
-
log.info('initializing auth runtime');
|
|
225
|
-
authRuntime = await options.auth.create({
|
|
226
|
-
db: options.db,
|
|
227
|
-
storageFactory: options.storageFactory,
|
|
228
|
-
});
|
|
229
|
-
log.info(`auth ready — mounted at ${authRuntime.basePath}`);
|
|
230
|
-
}
|
|
627
|
+
const { fronds, operationModel } = await readFronds(options, log);
|
|
628
|
+
|
|
629
|
+
const authRuntime = await authFor(options, log);
|
|
231
630
|
|
|
232
631
|
// Remote routing — validated at boot: declaring remotes without a transport is a config error.
|
|
233
632
|
// A remote declaration wins over local presence: `remotes: { blog: url }` IS
|
|
@@ -242,52 +641,8 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
|
|
|
242
641
|
? createRemoteRouter(Object.fromEntries(declaredRemotes), options.remoteTransport)
|
|
243
642
|
: undefined;
|
|
244
643
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const inflight = new InFlight();
|
|
248
|
-
|
|
249
|
-
// Middleware storage — read at call time, not at boot time
|
|
250
|
-
const globalMiddlewares: AppMiddleware[] = [];
|
|
251
|
-
const scopedMiddlewares = new Map<string, AppMiddleware[]>();
|
|
252
|
-
/** What this app took on beyond its fronds. Its `up` is the last thing the boot does. */
|
|
253
|
-
const routeRegistry = new RouteRegistry();
|
|
254
|
-
const dispatchLifecycle = new DispatchLifecycle(
|
|
255
|
-
options.dispatchObservers,
|
|
256
|
-
(error, event) => log.error(
|
|
257
|
-
`[dispatch-observer] ${event.stage} ${event.call.address.toString()}`,
|
|
258
|
-
error,
|
|
259
|
-
),
|
|
260
|
-
);
|
|
261
|
-
// Resolved at the call and never here: a journal is a provider of a brought frond, and
|
|
262
|
-
// the extension that registers it rises long after this line.
|
|
263
|
-
const keeping = (): Journal | undefined => journalOf();
|
|
264
|
-
const dispatcher = new Dispatcher(routeRegistry, inflight, dispatchLifecycle, undefined, keeping);
|
|
265
|
-
const localDispatcher = new Dispatcher(
|
|
266
|
-
routeRegistry,
|
|
267
|
-
inflight,
|
|
268
|
-
dispatchLifecycle,
|
|
269
|
-
new LocalRoutePolicy((surface) => fronds.servedNames(surface)),
|
|
270
|
-
keeping,
|
|
271
|
-
);
|
|
272
|
-
|
|
273
|
-
function getMiddlewares(entity: string): AppMiddleware[] {
|
|
274
|
-
const scoped = scopedMiddlewares.get(entity) ?? [];
|
|
275
|
-
return [...globalMiddlewares, ...scoped];
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/**
|
|
279
|
-
* The one place a middleware is taken on. `App.use` is its late form, and a frond's
|
|
280
|
-
* `middlewares/` its early one — the app does not exist yet while fronds install.
|
|
281
|
-
*/
|
|
282
|
-
function use(middleware: AppMiddleware, entity?: string): void {
|
|
283
|
-
if (entity === undefined) {
|
|
284
|
-
globalMiddlewares.push(middleware);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
const scoped = scopedMiddlewares.get(entity) ?? [];
|
|
288
|
-
scoped.push(middleware);
|
|
289
|
-
scopedMiddlewares.set(entity, scoped);
|
|
290
|
-
}
|
|
644
|
+
const { inflight, routeRegistry, dispatchLifecycle, dispatcher, localDispatcher, getMiddlewares, use } =
|
|
645
|
+
dispatching(options, log, fronds, () => journalOf());
|
|
291
646
|
|
|
292
647
|
/** What every check of this boot writes into — refused together, once they have all run. */
|
|
293
648
|
const refused: Diagnostic[] = [];
|
|
@@ -298,26 +653,8 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
|
|
|
298
653
|
const claimed = refusalOf(refused, 'declaration(s) that do not hold');
|
|
299
654
|
if (claimed) throw claimed;
|
|
300
655
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const entityByName = fronds.schemas();
|
|
304
|
-
// The line is core's, so its SHAPE is too: a destination that declares only a handler
|
|
305
|
-
// would otherwise be handed a line with no `at` — the announcement stamps `created()`
|
|
306
|
-
// off the shape, and the strict judge refuses what it did not stamp. Measured on
|
|
307
|
-
// `demos/observability`, where the ring held 11 calls and 0 lines.
|
|
308
|
-
if (!entityByName.has(LOG_LINE)) entityByName.set(LOG_LINE, LogLine);
|
|
309
|
-
// Which frond holds an entity — what turns "a member is remote" into a refusal that
|
|
310
|
-
// names the frond rather than the entity, since `remotes:` is declared per frond.
|
|
311
|
-
const frondOf = new Map(fronds.flatMap((f) => f.entities.map((e) => [e.name, f.name] as const)));
|
|
312
|
-
// Its own writer, which does NOT announce: this is what carries a fact, and a line
|
|
313
|
-
// about carrying one would come back here. See `LoggerOptions.carries`.
|
|
314
|
-
const emissions = new Emissions(
|
|
315
|
-
fronds, entityByName, container,
|
|
316
|
-
// No carry: this is what CARRIES a fact, and a line about carrying one comes back.
|
|
317
|
-
new Logger('boot:app'),
|
|
318
|
-
refused,
|
|
319
|
-
options.onEmit,
|
|
320
|
-
);
|
|
656
|
+
const { entityByName, frondOf, emissions } = whatTheAppKnows(fronds, container, options, refused);
|
|
657
|
+
|
|
321
658
|
/** Canonical operation tables, indexed by the same audience key as their facades. */
|
|
322
659
|
const effectiveByKey = new Map<string, EffectiveOperationsMap>();
|
|
323
660
|
|
|
@@ -343,59 +680,9 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
|
|
|
343
680
|
|
|
344
681
|
// What carries a release writes none: an instrumentation frond's own rows are kept while a
|
|
345
682
|
// release happens, and journalling them would begin one inside the one being written down.
|
|
346
|
-
const
|
|
347
|
-
fronds
|
|
348
|
-
);
|
|
349
|
-
|
|
350
|
-
/**
|
|
351
|
-
* The journal a package registered, found where its own frond put it — a provider lands in
|
|
352
|
-
* its frond's scope, and the app container sees none of them.
|
|
353
|
-
*/
|
|
354
|
-
const journalOf = (): Journal | undefined => {
|
|
355
|
-
for (const frond of fronds) {
|
|
356
|
-
if (!frond.brought) continue;
|
|
357
|
-
const scope = container.resolve<Container>(`frond:${frond.name}`);
|
|
358
|
-
if (scope.has(JOURNAL)) return scope.resolve<Journal>(JOURNAL);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
return undefined;
|
|
362
|
-
};
|
|
363
|
-
|
|
364
|
-
const hosting: Hosting = {
|
|
365
|
-
hostedHere: (entity) => !(options.remotes && (frondOf.get(entity) ?? '') in options.remotes),
|
|
366
|
-
sourceOf: (name) => options.sourceOf?.(name) ?? 'db',
|
|
367
|
-
enforces: (source, constraint) => options.enforces?.(source, constraint) ?? false,
|
|
368
|
-
storageOf: storageFor,
|
|
369
|
-
entities: () => entityByName,
|
|
370
|
-
// The two readings core serves in EVERY process, asked of the one that holds the rows.
|
|
371
|
-
// Nothing is cached: a peer that was down at boot answers the next call.
|
|
372
|
-
// Built from `remotes:` itself, not from the router: the router indexes by ENTITY, read
|
|
373
|
-
// off a card, and the question here is asked of a PROCESS about rows it may be alone
|
|
374
|
-
// in knowing about.
|
|
375
|
-
// Resolved per call and never at boot: the package that registers it rises in the
|
|
376
|
-
// ascent, long after this answer is built.
|
|
377
|
-
journal: (entity) => (carriesRelease.has(entity) ? undefined : journalOf()),
|
|
378
|
-
peers: () => (options.remoteTransport
|
|
379
|
-
? declaredRemotes.map(([, url]) => peerOver(options.remoteTransport!(url)))
|
|
380
|
-
: []),
|
|
381
|
-
peerOf: (entity) => (remoteRouter ? {
|
|
382
|
-
dependents: async (named) => (await remoteRouter.route(entity))
|
|
383
|
-
.transport({ entity: RPC_ENTITY, op: 'dependents' },
|
|
384
|
-
{ ...Invocation.empty, params: { entity: named } as never }),
|
|
385
|
-
missing: async (named, keys) => {
|
|
386
|
-
const answer = await (await remoteRouter.route(entity))
|
|
387
|
-
.transport({ entity: RPC_ENTITY, op: 'holds' },
|
|
388
|
-
{ ...Invocation.empty, params: { entity: named, keys } as never });
|
|
389
|
-
|
|
390
|
-
return (answer as { missing: readonly unknown[] }).missing;
|
|
391
|
-
},
|
|
392
|
-
release: async (named, key) => {
|
|
393
|
-
const route = await remoteRouter.route(entity);
|
|
394
|
-
await route.transport({ entity: RPC_ENTITY, op: 'release' },
|
|
395
|
-
{ ...Invocation.empty, params: { entity: named, key } as never });
|
|
396
|
-
},
|
|
397
|
-
} : undefined),
|
|
398
|
-
};
|
|
683
|
+
const { hosting, journalOf } = hostingFor({
|
|
684
|
+
fronds, container, options, declaredRemotes, remoteRouter, entityByName, frondOf, storageOf: storageFor,
|
|
685
|
+
});
|
|
399
686
|
|
|
400
687
|
// Every port an implementation was bound to, so a `ports:` entry that named none
|
|
401
688
|
// can say so rather than look obeyed.
|
|
@@ -407,32 +694,11 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
|
|
|
407
694
|
const assembly: Assembly = {
|
|
408
695
|
container, routeRegistry, emissions, dispatcher, localDispatcher, effectiveByKey,
|
|
409
696
|
boundPorts, refused, relations, hosting, operationModel, entityByName, frondOf, contractsOf,
|
|
410
|
-
getMiddlewares, use, log, options,
|
|
697
|
+
getMiddlewares, use, middlewaresOf: new Map(), seamsOf: new Map(), log, options,
|
|
411
698
|
};
|
|
412
699
|
for (const frond of fronds) await installFrond(frond, assembly);
|
|
413
700
|
|
|
414
|
-
|
|
415
|
-
// `ports:` warning below is — a target is another frond's, so no single frond can tell
|
|
416
|
-
// an absence from a neighbour that had not been installed yet.
|
|
417
|
-
const unheld = unheldAmong(relations, hosting);
|
|
418
|
-
// Declared here, and this process cannot finish what it starts — both halves are local,
|
|
419
|
-
// so nothing is asked of anyone. Said once, after every frond installed, because a journal
|
|
420
|
-
// arrives with an extension and an extension rises last.
|
|
421
|
-
const unfinished = unfinishable(hosting);
|
|
422
|
-
if (unfinished.length > 0) {
|
|
423
|
-
log.warn(
|
|
424
|
-
`[relations] ${unfinished.join(', ')} — declared, and this process keeps nothing on `
|
|
425
|
-
+ 'restart: a release interrupted here is not resumed. Install @fougere/workflow, or '
|
|
426
|
-
+ 'expect to finish one by hand.',
|
|
427
|
-
);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
if (unheld.length > 0) {
|
|
431
|
-
log.warn(
|
|
432
|
-
`[relations] ${unheld.join(', ')} — declared, and nothing in this process holds them: `
|
|
433
|
-
+ 'no foreign key, and the target answers elsewhere. A row may name one that is gone.',
|
|
434
|
-
);
|
|
435
|
-
}
|
|
701
|
+
warnAboutRelations(relations, hosting, log);
|
|
436
702
|
|
|
437
703
|
// Every check of the install, said at once — a boot that stops at the first makes the
|
|
438
704
|
// next one visible only after a fix and a restart.
|
|
@@ -479,106 +745,6 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
|
|
|
479
745
|
);
|
|
480
746
|
});
|
|
481
747
|
|
|
482
|
-
/** Stop taking calls, and resolve once the ones already running are done. */
|
|
483
|
-
const drain = async (timeoutMs?: number): Promise<void> => {
|
|
484
|
-
inflight.close();
|
|
485
|
-
if (timeoutMs === undefined) return inflight.whenIdle();
|
|
486
|
-
let timer: ReturnType<typeof setTimeout>;
|
|
487
|
-
await Promise.race([
|
|
488
|
-
inflight.whenIdle().then(() => clearTimeout(timer)),
|
|
489
|
-
new Promise<never>((_, reject) => {
|
|
490
|
-
timer = setTimeout(
|
|
491
|
-
() => reject(new Error(`[drain] ${inflight.count} call(s) still running after ${timeoutMs}ms`)),
|
|
492
|
-
timeoutMs,
|
|
493
|
-
);
|
|
494
|
-
}),
|
|
495
|
-
]);
|
|
496
|
-
};
|
|
497
|
-
|
|
498
|
-
const resolve = <T>(name: string): T => {
|
|
499
|
-
try {
|
|
500
|
-
return container.resolve<T>(name);
|
|
501
|
-
} catch (err) {
|
|
502
|
-
if (name.endsWith('Handler') && !name.includes(':') && !remoteRouter) {
|
|
503
|
-
throw new Error(notLoaded(name.replace(/Handler$/, '')));
|
|
504
|
-
}
|
|
505
|
-
throw err;
|
|
506
|
-
}
|
|
507
|
-
};
|
|
508
|
-
|
|
509
|
-
const schemaFor = async (entity: string): Promise<SchemaView> => {
|
|
510
|
-
const found = fronds.entity(entity);
|
|
511
|
-
if (found) return found.entityClass;
|
|
512
|
-
if (remoteRouter) {
|
|
513
|
-
const route = await remoteRouter.route(entity);
|
|
514
|
-
// A remote facade that stores nothing publishes ops and no shape. Saying so beats
|
|
515
|
-
// handing back an empty schema, which would validate every input it was given.
|
|
516
|
-
if (!route.schema) {
|
|
517
|
-
throw new Error(
|
|
518
|
-
`'${entity}' is served by frond '${route.frond}' but stores no rows, so it has no schema. `
|
|
519
|
-
+ `Call its operations through the façade instead.`,
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
return route.schema;
|
|
523
|
-
}
|
|
524
|
-
throw new Error(notLoaded(entity));
|
|
525
|
-
};
|
|
526
|
-
|
|
527
|
-
const facadeAt = (key: string, topology: boolean): Record<string, Function> | undefined => {
|
|
528
|
-
try {
|
|
529
|
-
return topology
|
|
530
|
-
? resolve<Record<string, Function>>(key)
|
|
531
|
-
: container.resolve<Record<string, Function>>(key);
|
|
532
|
-
} catch {
|
|
533
|
-
return undefined;
|
|
534
|
-
}
|
|
535
|
-
};
|
|
536
|
-
|
|
537
|
-
/** Said once per pair, so a facade that registers in a loop says it once. */
|
|
538
|
-
const saidAbsent = new Set<string>();
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* A surface is declared in the frond that serves it. When that frond runs in another
|
|
542
|
-
* process, this one never asked for its facades, and answering 'no' is the only thing a
|
|
543
|
-
* synchronous rule can do — so it says so rather than registering nothing in silence.
|
|
544
|
-
*/
|
|
545
|
-
const sayNoSurfaceAcross = (entity: string, surface: string): void => {
|
|
546
|
-
if (!remoteRouter || saidAbsent.has(`${surface}:${entity}`)) return;
|
|
547
|
-
saidAbsent.add(`${surface}:${entity}`);
|
|
548
|
-
log.warn(
|
|
549
|
-
`surface '${surface}' serves nothing for '${entity}' — the frond that declares it runs `
|
|
550
|
-
+ 'elsewhere, and a remote is asked for its facades at the first call, not at boot. '
|
|
551
|
-
+ 'The default facade answers.',
|
|
552
|
-
);
|
|
553
|
-
};
|
|
554
|
-
|
|
555
|
-
/** THE membership rule, stated once — every projection reads this and nothing else. */
|
|
556
|
-
const facadeFor = (entity: string, surface?: string): Record<string, Function> | undefined => {
|
|
557
|
-
if (!surface) return facadeAt(facadeKeyOf(entity), true);
|
|
558
|
-
|
|
559
|
-
const own = facadeAt(facadeKeyOf(entity, surface), false);
|
|
560
|
-
const owner = fronds.owner(entity);
|
|
561
|
-
if (!owner) {
|
|
562
|
-
sayNoSurfaceAcross(entity, surface);
|
|
563
|
-
return own;
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
const declared = owner.surfaces?.[surface];
|
|
567
|
-
if (!declared) return own;
|
|
568
|
-
if (!declared.some((n) => n.toLowerCase() === entity.toLowerCase())) return undefined;
|
|
569
|
-
if (own) return own;
|
|
570
|
-
|
|
571
|
-
const fallback = facadeAt(facadeKeyOf(entity), false);
|
|
572
|
-
return fallback
|
|
573
|
-
? facadeOperations(
|
|
574
|
-
localDispatcher,
|
|
575
|
-
entity,
|
|
576
|
-
routeRegistry.operationNames(entity, surface),
|
|
577
|
-
surface,
|
|
578
|
-
)
|
|
579
|
-
: undefined;
|
|
580
|
-
};
|
|
581
|
-
|
|
582
748
|
if (remoteRouter) {
|
|
583
749
|
const remoteFacades = new Map<string, Record<string, Function>>();
|
|
584
750
|
routeRegistry.addResolver(remoteRoutes((entity) => {
|
|
@@ -590,29 +756,9 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
|
|
|
590
756
|
}));
|
|
591
757
|
}
|
|
592
758
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
const own = effectiveByKey.get(facadeKeyOf(entity, surface));
|
|
598
|
-
const declared = fronds.owner(entity)?.surfaces?.[surface];
|
|
599
|
-
if (!declared) return own;
|
|
600
|
-
return declared.some((name) => name.toLowerCase() === entity.toLowerCase())
|
|
601
|
-
? (own ?? effectiveByKey.get(facadeKeyOf(entity)))
|
|
602
|
-
: undefined;
|
|
603
|
-
};
|
|
604
|
-
|
|
605
|
-
/** The presenter of an entity, resolved through its owning frond's scope. */
|
|
606
|
-
const presenterFor = (entity: string): unknown | undefined => {
|
|
607
|
-
const owner = fronds.owner(entity);
|
|
608
|
-
if (!owner) return undefined;
|
|
609
|
-
|
|
610
|
-
try {
|
|
611
|
-
return container.resolve<Container>(`frond:${owner.name}`).resolve(presenterKeyOf(entity));
|
|
612
|
-
} catch {
|
|
613
|
-
return undefined;
|
|
614
|
-
}
|
|
615
|
-
};
|
|
759
|
+
const { resolve, schemaFor, facadeFor, operationsFor, presenterFor } = readings({
|
|
760
|
+
container, fronds, remoteRouter, localDispatcher, routeRegistry, effectiveByKey, log,
|
|
761
|
+
});
|
|
616
762
|
|
|
617
763
|
const app: App = {
|
|
618
764
|
container,
|
|
@@ -635,7 +781,7 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
|
|
|
635
781
|
storageFor,
|
|
636
782
|
presenterFor,
|
|
637
783
|
dispose: release,
|
|
638
|
-
drain,
|
|
784
|
+
drain: (timeoutMs?: number) => drainCalls(inflight, timeoutMs),
|
|
639
785
|
inFlight: () => inflight.count,
|
|
640
786
|
[Symbol.asyncDispose]: release,
|
|
641
787
|
serveRpc(op: string, answer: RpcAnswer): void {
|
|
@@ -672,34 +818,7 @@ export async function createApp(options: CreateAppOptions): Promise<App> {
|
|
|
672
818
|
// by type, the way every other dependency is asked for.
|
|
673
819
|
container.registerValue('Releasing', releasing(hosting));
|
|
674
820
|
|
|
675
|
-
app
|
|
676
|
-
|
|
677
|
-
// What a release asks another process, and what it tells it to do. Served by EVERY process
|
|
678
|
-
// the way `discover` is: a frond that hosts rows is the only one that can answer for them.
|
|
679
|
-
//
|
|
680
|
-
// Read off the STORAGE and never off a facade: a facade answers what its handler chose to
|
|
681
|
-
// show — `PostHandler.list` hands back published posts — so a draft naming a deleted user
|
|
682
|
-
// would be invisible to the very question that exists to find it.
|
|
683
|
-
// The dual of `release`, and the same reason it is served by every process: only the one
|
|
684
|
-
// that holds the rows can say a key is missing, and only from the STORAGE — a facade
|
|
685
|
-
// answers what its handler chose to show.
|
|
686
|
-
app.serveRpc('holds', async (invocation) => {
|
|
687
|
-
const named = String(invocation.params.entity);
|
|
688
|
-
const keys = (invocation.params.keys ?? []) as readonly unknown[];
|
|
689
|
-
const rows = storageFor(named) as { findByKeys(k: readonly string[]): Promise<Map<string, unknown>> } | undefined;
|
|
690
|
-
if (!rows) return { missing: [] };
|
|
691
|
-
const found = await rows.findByKeys(keys.map(String));
|
|
692
|
-
|
|
693
|
-
return { missing: keys.filter((key) => !found.has(String(key))) };
|
|
694
|
-
});
|
|
695
|
-
app.serveRpc('dependents', (invocation) =>
|
|
696
|
-
dependentsOf(String(invocation.params.entity), hosting));
|
|
697
|
-
app.serveRpc('release', async (invocation) => {
|
|
698
|
-
await releaseRow(String(invocation.params.entity), invocation.params.key, releasing(hosting),
|
|
699
|
-
(invocation.params.visited ?? []) as readonly string[]);
|
|
700
|
-
|
|
701
|
-
return { released: true };
|
|
702
|
-
});
|
|
821
|
+
serveCoreRpc(app, hosting, storageFor);
|
|
703
822
|
|
|
704
823
|
// The last thing the boot does, and the first thing a release undoes. An extension may
|
|
705
824
|
// await here — which is what a provider needing to OPEN something could never do.
|