@arki/dot 0.2.0 → 0.4.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/README.md +86 -86
- package/dist/cli/discover.js +2 -2
- package/dist/cli/discover.js.map +1 -1
- package/dist/cli/error-codes.d.ts +8 -0
- package/dist/cli/error-codes.d.ts.map +1 -1
- package/dist/cli/error-codes.js +12 -0
- package/dist/cli/error-codes.js.map +1 -1
- package/dist/cli/index.d.ts +11 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +66 -11
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/new.d.ts +1 -1
- package/dist/cli/new.js +1 -1
- package/dist/cli/render-doctor.d.ts +1 -1
- package/dist/cli/render-doctor.js +23 -23
- package/dist/cli/render-doctor.js.map +1 -1
- package/dist/cli/render-explain.d.ts.map +1 -1
- package/dist/cli/render-explain.js +33 -19
- package/dist/cli/render-explain.js.map +1 -1
- package/dist/cli/render-graph.d.ts +3 -3
- package/dist/cli/render-graph.js +12 -12
- package/dist/cli/render-graph.js.map +1 -1
- package/dist/cli/render-openapi.d.ts +38 -0
- package/dist/cli/render-openapi.d.ts.map +1 -0
- package/dist/cli/render-openapi.js +131 -0
- package/dist/cli/render-openapi.js.map +1 -0
- package/dist/cli/render-projection.d.ts +29 -0
- package/dist/cli/render-projection.d.ts.map +1 -0
- package/dist/cli/render-projection.js +202 -0
- package/dist/cli/render-projection.js.map +1 -0
- package/dist/define-app.d.ts +49 -17
- package/dist/define-app.d.ts.map +1 -1
- package/dist/define-app.js +16 -9
- package/dist/define-app.js.map +1 -1
- package/dist/diagnostics.d.ts +19 -15
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +2 -2
- package/dist/index.d.ts +15 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/init-pips.d.ts +45 -0
- package/dist/init-pips.d.ts.map +1 -0
- package/dist/init-pips.js +49 -0
- package/dist/init-pips.js.map +1 -0
- package/dist/init-plugins.d.ts +45 -0
- package/dist/init-plugins.d.ts.map +1 -0
- package/dist/init-plugins.js +49 -0
- package/dist/init-plugins.js.map +1 -0
- package/dist/kernel/app-instance.d.ts +5 -5
- package/dist/kernel/app-instance.d.ts.map +1 -1
- package/dist/kernel/app-instance.js +375 -244
- package/dist/kernel/app-instance.js.map +1 -1
- package/dist/kernel/otel.d.ts +19 -19
- package/dist/kernel/otel.d.ts.map +1 -1
- package/dist/kernel/otel.js +22 -22
- package/dist/kernel/otel.js.map +1 -1
- package/dist/lifecycle-observer.d.ts +10 -10
- package/dist/lifecycle-observer.d.ts.map +1 -1
- package/dist/lifecycle-observer.js +1 -1
- package/dist/lifecycle.d.ts +18 -14
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +13 -9
- package/dist/lifecycle.js.map +1 -1
- package/dist/manifest.d.ts +70 -29
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +74 -5
- package/dist/manifest.js.map +1 -1
- package/dist/pip-contract.d.ts +40 -10
- package/dist/pip-contract.d.ts.map +1 -1
- package/dist/pip-contract.js +1 -25
- package/dist/pip-contract.js.map +1 -1
- package/dist/pip.d.ts +7 -4
- package/dist/pip.d.ts.map +1 -1
- package/dist/pip.js +2 -0
- package/dist/pip.js.map +1 -1
- package/dist/plugin-contract.d.ts +335 -143
- package/dist/plugin-contract.d.ts.map +1 -1
- package/dist/plugin-contract.js +201 -36
- package/dist/plugin-contract.js.map +1 -1
- package/dist/plugin.d.ts +18 -15
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +11 -11
- package/dist/plugin.js.map +1 -1
- package/dist/test-harness.d.ts +31 -31
- package/dist/test-harness.d.ts.map +1 -1
- package/dist/test-harness.js +29 -28
- package/dist/test-harness.js.map +1 -1
- package/dist/timeline.d.ts +2 -2
- package/dist/timeline.js +5 -5
- package/dist/timeline.js.map +1 -1
- package/package.json +9 -9
- package/src/cli/discover.ts +2 -2
- package/src/cli/error-codes.ts +12 -0
- package/src/cli/index.ts +93 -15
- package/src/cli/new.ts +1 -1
- package/src/cli/render-doctor.ts +21 -21
- package/src/cli/render-explain.ts +37 -19
- package/src/cli/render-graph.ts +12 -12
- package/src/cli/render-projection.ts +239 -0
- package/src/define-app.ts +85 -22
- package/src/diagnostics.ts +19 -15
- package/src/index.ts +38 -20
- package/src/init-plugins.ts +142 -0
- package/src/kernel/app-instance.ts +428 -282
- package/src/kernel/otel.ts +27 -27
- package/src/lifecycle-observer.ts +10 -10
- package/src/lifecycle.ts +19 -15
- package/src/manifest.ts +128 -31
- package/src/{pip-contract.ts → plugin-contract.ts} +83 -44
- package/src/{pip.ts → plugin.ts} +38 -18
- package/src/test-harness.ts +52 -51
- package/src/timeline.ts +6 -6
- package/templates/app-minimal/AGENTS.md.tmpl +9 -9
- package/templates/app-minimal/README.md.tmpl +1 -1
- package/templates/app-minimal/tests/boot.test.ts.tmpl +1 -1
|
@@ -9,56 +9,61 @@ import { Logger } from '@arki/log';
|
|
|
9
9
|
import { createDebugLogger } from '@arki/log/debug';
|
|
10
10
|
|
|
11
11
|
import type {
|
|
12
|
+
ActionDiagnostic,
|
|
12
13
|
DiagnosticIssue,
|
|
13
14
|
DotDiagnosticsSnapshot,
|
|
14
15
|
LifecycleDiagnostic,
|
|
15
|
-
|
|
16
|
-
RouteDiagnostic,
|
|
16
|
+
PluginDiagnostic,
|
|
17
17
|
ServiceDiagnostic,
|
|
18
18
|
} from '../diagnostics.js';
|
|
19
19
|
import type { DotLifecycleEvent, DotLifecycleObserver } from '../lifecycle-observer.js';
|
|
20
|
-
import type { DotLifecycleHook,
|
|
20
|
+
import type { DotLifecycleHook, DotLifecyclePluginFailure, DotLifecycleState } from '../lifecycle.js';
|
|
21
21
|
import type {
|
|
22
|
+
ActionManifest,
|
|
22
23
|
DependencyEdge,
|
|
23
24
|
DotAppManifest,
|
|
25
|
+
JsonObject,
|
|
24
26
|
LifecycleManifest,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
PluginManifest,
|
|
28
|
+
ProjectionManifest,
|
|
27
29
|
ServiceManifest,
|
|
28
30
|
} from '../manifest.js';
|
|
29
|
-
import type {
|
|
31
|
+
import type { AnyPlugin, DotConfigureContext, Lazy, ServiceRecord } from '../plugin-contract.js';
|
|
30
32
|
import { DotLifecycleError, DotLifecycleErrorCode } from '../lifecycle.js';
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
+
import { toJsonObject } from '../manifest.js';
|
|
34
|
+
import { isLazy, isLazyWitness, lazyOf } from '../plugin-contract.js';
|
|
35
|
+
import { withPhaseSpan, withPluginHookSpan } from './otel.js';
|
|
33
36
|
|
|
34
37
|
const debugKernel = createDebugLogger('arki:dot:kernel');
|
|
35
38
|
|
|
36
39
|
const DOCS_BASE = 'https://docs.arki.dev/dot/diagnostics';
|
|
37
40
|
|
|
38
|
-
/** Per-
|
|
39
|
-
type
|
|
40
|
-
|
|
41
|
+
/** Per-plugin mutable bookkeeping. */
|
|
42
|
+
type PluginRecord = {
|
|
43
|
+
plugin: AnyPlugin;
|
|
41
44
|
/** Declaration order (0-based) — v2 boot order IS declaration order. */
|
|
42
45
|
order: number;
|
|
43
|
-
/**
|
|
44
|
-
|
|
46
|
+
/** Actions declared statically or registered during `configure`. */
|
|
47
|
+
actions: ActionManifest[];
|
|
48
|
+
/** Projection renderers registered during `configure`. */
|
|
49
|
+
projections: ProjectionManifest[];
|
|
45
50
|
/** Services declared during `configure`. */
|
|
46
51
|
services: ServiceManifest[];
|
|
47
|
-
/** Lifecycle hooks the
|
|
52
|
+
/** Lifecycle hooks the plugin participates in. */
|
|
48
53
|
hooks: Set<DotLifecycleHook>;
|
|
49
54
|
/** Provides capability strings declared during `configure`, joined with published wire keys. */
|
|
50
55
|
provides: Set<string>;
|
|
51
56
|
/**
|
|
52
|
-
* Services published by this
|
|
53
|
-
* The
|
|
57
|
+
* Services published by this plugin, keyed by LOCAL publish keys (pre-rename).
|
|
58
|
+
* The plugin's own stop/dispose contexts read these; the app-facing service
|
|
54
59
|
* map uses the renamed wire keys.
|
|
55
60
|
*/
|
|
56
61
|
publishedServices: ServiceRecord;
|
|
57
|
-
/** Whether the
|
|
62
|
+
/** Whether the plugin's `boot` hook completed successfully. */
|
|
58
63
|
booted: boolean;
|
|
59
|
-
/** Whether the
|
|
64
|
+
/** Whether the plugin's `start` hook completed successfully. */
|
|
60
65
|
started: boolean;
|
|
61
|
-
/** Diagnostic issues collected for this
|
|
66
|
+
/** Diagnostic issues collected for this plugin. */
|
|
62
67
|
issues: DiagnosticIssue[];
|
|
63
68
|
/** Per-hook diagnostic entries. */
|
|
64
69
|
lifecycleDiagnostics: LifecycleDiagnostic[];
|
|
@@ -75,7 +80,7 @@ function wireKeyOf(witness: object, alias: string): string {
|
|
|
75
80
|
function makeIssue(args: {
|
|
76
81
|
code: string;
|
|
77
82
|
severity?: 'info' | 'warning' | 'error';
|
|
78
|
-
|
|
83
|
+
plugin?: string;
|
|
79
84
|
message: string;
|
|
80
85
|
remediation: string;
|
|
81
86
|
docsAnchor: string;
|
|
@@ -84,7 +89,7 @@ function makeIssue(args: {
|
|
|
84
89
|
return {
|
|
85
90
|
code: args.code,
|
|
86
91
|
severity: args.severity ?? 'error',
|
|
87
|
-
|
|
92
|
+
plugin: args.plugin,
|
|
88
93
|
message: args.message,
|
|
89
94
|
remediation: args.remediation,
|
|
90
95
|
docsUrl: `${DOCS_BASE}#${args.docsAnchor}`,
|
|
@@ -95,7 +100,7 @@ function makeIssue(args: {
|
|
|
95
100
|
export type DotAppInternalConfig = {
|
|
96
101
|
appName: string;
|
|
97
102
|
appVersion?: string;
|
|
98
|
-
|
|
103
|
+
plugins: readonly AnyPlugin[];
|
|
99
104
|
/** Runtime config bag passed to every `boot` hook. */
|
|
100
105
|
config?: Readonly<Record<string, unknown>>;
|
|
101
106
|
/**
|
|
@@ -117,9 +122,9 @@ export class DotAppImpl {
|
|
|
117
122
|
readonly #appVersion: string | undefined;
|
|
118
123
|
readonly #config: Readonly<Record<string, unknown>>;
|
|
119
124
|
|
|
120
|
-
/**
|
|
121
|
-
readonly #ordered: readonly
|
|
122
|
-
readonly #records: Map<string,
|
|
125
|
+
/** Plugins in declaration order — v2 boot order IS declaration order. */
|
|
126
|
+
readonly #ordered: readonly AnyPlugin[];
|
|
127
|
+
readonly #records: Map<string, PluginRecord>;
|
|
123
128
|
|
|
124
129
|
/** Macro-state of the app. */
|
|
125
130
|
#state: DotLifecycleState = 'defined';
|
|
@@ -130,10 +135,10 @@ export class DotAppImpl {
|
|
|
130
135
|
/** Manifest finalised after `configure`. */
|
|
131
136
|
#manifest: DotAppManifest;
|
|
132
137
|
|
|
133
|
-
/** Wire-keyed services map — populated as
|
|
138
|
+
/** Wire-keyed services map — populated as plugins boot. */
|
|
134
139
|
readonly #serviceMap = new Map<string, unknown>();
|
|
135
140
|
|
|
136
|
-
/** Which
|
|
141
|
+
/** Which plugin provided each wire key — feeds dependency edges + collisions. */
|
|
137
142
|
readonly #providerByWireKey = new Map<string, string>();
|
|
138
143
|
|
|
139
144
|
/** Dependency edges observed during boot (consumer → provider). */
|
|
@@ -167,7 +172,7 @@ export class DotAppImpl {
|
|
|
167
172
|
* Structured lifecycle logger. One per app instance; named so consumers
|
|
168
173
|
* can elevate it to DEBUG via `DEBUG=arki:dot:lifecycle` without
|
|
169
174
|
* touching unrelated namespaces. Every line carries the app name plus
|
|
170
|
-
* any phase/
|
|
175
|
+
* any phase/plugin attributes from the call site. The span helpers
|
|
171
176
|
* (see `./otel.ts`) thread the active span's `traceId`+`spanId` onto
|
|
172
177
|
* forked instances of this logger, so every log record is groupable
|
|
173
178
|
* with its trace in any OTel-compatible backend.
|
|
@@ -192,50 +197,51 @@ export class DotAppImpl {
|
|
|
192
197
|
|
|
193
198
|
// Declaration order is boot order in v2. Duplicate names are surfaced at
|
|
194
199
|
// construction (the `configure` phase pseudo-time).
|
|
195
|
-
debugKernel('[%s] registering %d
|
|
196
|
-
this.#ordered = [...config.
|
|
200
|
+
debugKernel('[%s] registering %d plugin(s) in declaration order', this.#appName, config.plugins.length);
|
|
201
|
+
this.#ordered = [...config.plugins];
|
|
197
202
|
|
|
198
203
|
this.#records = new Map();
|
|
199
|
-
for (const [order,
|
|
200
|
-
if (this.#records.has(
|
|
204
|
+
for (const [order, plugin] of this.#ordered.entries()) {
|
|
205
|
+
if (this.#records.has(plugin.name)) {
|
|
201
206
|
throw new DotLifecycleError({
|
|
202
|
-
code: DotLifecycleErrorCode.
|
|
207
|
+
code: DotLifecycleErrorCode.DuplicatePlugin,
|
|
203
208
|
phase: 'configure',
|
|
204
|
-
|
|
205
|
-
message: `
|
|
209
|
+
plugin: plugin.name,
|
|
210
|
+
message: `Plugin "${plugin.name}" is registered twice`,
|
|
206
211
|
});
|
|
207
212
|
}
|
|
208
|
-
// `$` is the kernel context namespace ($app/$
|
|
213
|
+
// `$` is the kernel context namespace ($app/$plugin/$config). The plugin()
|
|
209
214
|
// constraint bans these at compile time; this is the runtime backstop
|
|
210
|
-
// for erased
|
|
211
|
-
for (const alias of Object.keys(
|
|
215
|
+
// for erased plugins and rename() targets, which types cannot see.
|
|
216
|
+
for (const alias of Object.keys(plugin.needs)) {
|
|
212
217
|
if (alias.startsWith('$')) {
|
|
213
218
|
throw new DotLifecycleError({
|
|
214
219
|
code: DotLifecycleErrorCode.ReservedServiceKey,
|
|
215
220
|
phase: 'configure',
|
|
216
|
-
|
|
221
|
+
plugin: plugin.name,
|
|
217
222
|
message:
|
|
218
|
-
`
|
|
219
|
-
`for kernel context keys ($app, $
|
|
223
|
+
`Plugin "${plugin.name}" declares needs alias "${alias}" — the "$" prefix is reserved ` +
|
|
224
|
+
`for kernel context keys ($app, $plugin, $config). Rename the alias.`,
|
|
220
225
|
});
|
|
221
226
|
}
|
|
222
227
|
}
|
|
223
|
-
for (const [localKey, wireKey] of Object.entries(
|
|
228
|
+
for (const [localKey, wireKey] of Object.entries(plugin.renames)) {
|
|
224
229
|
if (wireKey.startsWith('$')) {
|
|
225
230
|
throw new DotLifecycleError({
|
|
226
231
|
code: DotLifecycleErrorCode.ReservedServiceKey,
|
|
227
232
|
phase: 'configure',
|
|
228
|
-
|
|
233
|
+
plugin: plugin.name,
|
|
229
234
|
message:
|
|
230
|
-
`
|
|
231
|
-
`reserved for kernel context keys ($app, $
|
|
235
|
+
`Plugin "${plugin.name}" renames "${localKey}" to "${wireKey}" — the "$" prefix is ` +
|
|
236
|
+
`reserved for kernel context keys ($app, $plugin, $config). Pick a different wire key.`,
|
|
232
237
|
});
|
|
233
238
|
}
|
|
234
239
|
}
|
|
235
|
-
this.#records.set(
|
|
236
|
-
|
|
240
|
+
this.#records.set(plugin.name, {
|
|
241
|
+
plugin,
|
|
237
242
|
order,
|
|
238
|
-
|
|
243
|
+
actions: [],
|
|
244
|
+
projections: [],
|
|
239
245
|
services: [],
|
|
240
246
|
hooks: new Set<DotLifecycleHook>(),
|
|
241
247
|
provides: new Set<string>(),
|
|
@@ -300,16 +306,16 @@ export class DotAppImpl {
|
|
|
300
306
|
*/
|
|
301
307
|
#emitHook(
|
|
302
308
|
phase: DotLifecycleHook,
|
|
303
|
-
|
|
309
|
+
plugin: string,
|
|
304
310
|
order: number,
|
|
305
311
|
status: 'starting' | 'completed' | 'failed',
|
|
306
312
|
opts?: { durationMs?: number; error?: unknown },
|
|
307
313
|
): void {
|
|
308
314
|
if (this.#observers.size === 0) return;
|
|
309
315
|
const event = {
|
|
310
|
-
kind: '
|
|
316
|
+
kind: 'plugin-hook' as const,
|
|
311
317
|
phase,
|
|
312
|
-
|
|
318
|
+
plugin,
|
|
313
319
|
order,
|
|
314
320
|
status,
|
|
315
321
|
appName: this.#appName,
|
|
@@ -320,6 +326,96 @@ export class DotAppImpl {
|
|
|
320
326
|
this.#emit(event);
|
|
321
327
|
}
|
|
322
328
|
|
|
329
|
+
#registerAction(record: PluginRecord, action: Omit<ActionManifest, 'plugin'>): void {
|
|
330
|
+
let meta: Readonly<JsonObject> | undefined;
|
|
331
|
+
if (action.meta !== undefined) {
|
|
332
|
+
try {
|
|
333
|
+
meta = toJsonObject(action.meta);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
const issue = makeIssue({
|
|
336
|
+
code: DotLifecycleErrorCode.ActionMetaNotJson,
|
|
337
|
+
plugin: record.plugin.name,
|
|
338
|
+
message:
|
|
339
|
+
`action "${action.id}" for binding "${action.binding}" has metadata that is not JSON-round-trip safe: ` +
|
|
340
|
+
stringifyError(error),
|
|
341
|
+
remediation:
|
|
342
|
+
'Only put JSON-serializable data in action meta. Convert Dates to strings, remove functions/undefined fields, and avoid NaN/Infinity.',
|
|
343
|
+
docsAnchor: 'action-meta-not-json',
|
|
344
|
+
metadata: { actionId: action.id, binding: action.binding },
|
|
345
|
+
});
|
|
346
|
+
record.issues.push(issue);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const stamped: ActionManifest = {
|
|
351
|
+
id: action.id,
|
|
352
|
+
plugin: record.plugin.name,
|
|
353
|
+
binding: action.binding,
|
|
354
|
+
direction: action.direction,
|
|
355
|
+
...(action.address === undefined ? {} : { address: action.address }),
|
|
356
|
+
...(action.summary === undefined ? {} : { summary: action.summary }),
|
|
357
|
+
...(meta === undefined ? {} : { meta }),
|
|
358
|
+
...(action.metaSchema === undefined ? {} : { metaSchema: action.metaSchema }),
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
for (const existingRecord of this.#records.values()) {
|
|
362
|
+
const duplicate = existingRecord.actions.find(
|
|
363
|
+
existing => existing.binding === stamped.binding && existing.id === stamped.id,
|
|
364
|
+
);
|
|
365
|
+
if (duplicate === undefined) continue;
|
|
366
|
+
const issue = makeIssue({
|
|
367
|
+
code: DotLifecycleErrorCode.DuplicateAction,
|
|
368
|
+
plugin: record.plugin.name,
|
|
369
|
+
message: `action "${stamped.id}" is registered more than once for binding "${stamped.binding}".`,
|
|
370
|
+
remediation:
|
|
371
|
+
'Action ids must be unique within a binding. Rename one action or move one of them to a different binding.',
|
|
372
|
+
docsAnchor: 'duplicate-action',
|
|
373
|
+
metadata: { binding: stamped.binding, id: stamped.id, firstPlugin: duplicate.plugin },
|
|
374
|
+
});
|
|
375
|
+
record.issues.push(issue);
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
record.actions.push(stamped);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
#registerProjection(record: PluginRecord, projection: Omit<ProjectionManifest, 'plugin'>): void {
|
|
383
|
+
record.projections.push({ ...projection, plugin: record.plugin.name });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
#registerRouteShim(
|
|
387
|
+
record: PluginRecord,
|
|
388
|
+
route: Parameters<DotConfigureContext['registerRoute']>[0],
|
|
389
|
+
): void {
|
|
390
|
+
const meta: JsonObject = {};
|
|
391
|
+
if (route.method !== undefined) meta['method'] = route.method;
|
|
392
|
+
if (route.path !== undefined) meta['path'] = route.path;
|
|
393
|
+
if (route.input !== undefined) {
|
|
394
|
+
const input: JsonObject = {};
|
|
395
|
+
if (route.input.query !== undefined) input['query'] = route.input.query;
|
|
396
|
+
if (route.input.body !== undefined) input['body'] = route.input.body;
|
|
397
|
+
if (Object.keys(input).length > 0) meta['input'] = input;
|
|
398
|
+
}
|
|
399
|
+
if (route.output !== undefined) meta['output'] = route.output;
|
|
400
|
+
if (route.streaming !== undefined) meta['streaming'] = route.streaming;
|
|
401
|
+
|
|
402
|
+
this.#registerAction(record, {
|
|
403
|
+
id: route.id,
|
|
404
|
+
binding: route.transport,
|
|
405
|
+
direction: 'in',
|
|
406
|
+
...(route.path === undefined ? {} : { address: `${route.method ?? 'GET'} ${route.path}` }),
|
|
407
|
+
...(route.description === undefined ? {} : { summary: route.description }),
|
|
408
|
+
...(Object.keys(meta).length === 0 ? {} : { meta }),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#actionFromSource(source: AnyPlugin['actions'][number]): Omit<ActionManifest, 'plugin'> {
|
|
413
|
+
if (hasToDotAction(source)) {
|
|
414
|
+
return source.toDotAction();
|
|
415
|
+
}
|
|
416
|
+
return source;
|
|
417
|
+
}
|
|
418
|
+
|
|
323
419
|
/**
|
|
324
420
|
* Wrap a sync phase body with starting/completed/failed observer events.
|
|
325
421
|
* `configure` is the only sync phase in the kernel today — keep the
|
|
@@ -424,14 +520,14 @@ export class DotAppImpl {
|
|
|
424
520
|
}
|
|
425
521
|
|
|
426
522
|
const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'configure');
|
|
427
|
-
phaseLogger.debug('configure: starting', { 'dot.app.
|
|
523
|
+
phaseLogger.debug('configure: starting', { 'dot.app.plugin.count': this.#ordered.length });
|
|
428
524
|
|
|
429
525
|
this.#withPhaseEmit('configure', () => {
|
|
430
526
|
withPhaseSpan(
|
|
431
527
|
{
|
|
432
528
|
appName: this.#appName,
|
|
433
529
|
phase: 'configure',
|
|
434
|
-
|
|
530
|
+
pluginCount: this.#ordered.length,
|
|
435
531
|
logger: phaseLogger,
|
|
436
532
|
},
|
|
437
533
|
() => this.#runConfigureInner(phaseLogger),
|
|
@@ -445,24 +541,57 @@ export class DotAppImpl {
|
|
|
445
541
|
* intricate error-handling readable.
|
|
446
542
|
*/
|
|
447
543
|
#runConfigureInner(phaseLogger: Logger): void {
|
|
448
|
-
for (const
|
|
449
|
-
const record = this.#records.get(
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
|
|
544
|
+
for (const plugin of this.#ordered) {
|
|
545
|
+
const record = this.#records.get(plugin.name)!;
|
|
546
|
+
const pluginLogger = phaseLogger.withAttribute('dot.plugin.name', plugin.name);
|
|
547
|
+
const declaredStarted = performance.now();
|
|
548
|
+
try {
|
|
549
|
+
for (const source of plugin.actions) this.#registerAction(record, this.#actionFromSource(source));
|
|
550
|
+
} catch (error) {
|
|
551
|
+
const durationMs = performance.now() - declaredStarted;
|
|
552
|
+
const issue = makeIssue({
|
|
553
|
+
code: DotLifecycleErrorCode.ConfigureFailed,
|
|
554
|
+
plugin: plugin.name,
|
|
555
|
+
message: `action declaration threw for plugin "${plugin.name}": ${stringifyError(error)}`,
|
|
556
|
+
remediation: `Fix the action declarations of "${plugin.name}". Declarative actions must be static and synchronous.`,
|
|
557
|
+
docsAnchor: 'configure-failed',
|
|
558
|
+
});
|
|
559
|
+
record.issues.push(issue);
|
|
560
|
+
record.lifecycleDiagnostics.push({
|
|
561
|
+
plugin: plugin.name,
|
|
562
|
+
hook: 'configure',
|
|
563
|
+
state: 'failed',
|
|
564
|
+
order: record.order,
|
|
565
|
+
durationMs,
|
|
566
|
+
issues: [issue],
|
|
567
|
+
});
|
|
568
|
+
this.#emitHook('configure', plugin.name, record.order, 'failed', { durationMs, error });
|
|
569
|
+
this.#state = 'failed';
|
|
570
|
+
this.#manifest = this.#buildManifest();
|
|
571
|
+
throw new DotLifecycleError({
|
|
572
|
+
code: DotLifecycleErrorCode.ConfigureFailed,
|
|
573
|
+
phase: 'configure',
|
|
574
|
+
plugin: plugin.name,
|
|
575
|
+
message: issue.message,
|
|
576
|
+
cause: error,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (!plugin.hooks.configure) {
|
|
581
|
+
pluginLogger.debug('configure: skipped (no hook)');
|
|
453
582
|
continue;
|
|
454
583
|
}
|
|
455
584
|
record.hooks.add('configure');
|
|
456
585
|
|
|
457
586
|
const ctx: DotConfigureContext = {
|
|
458
|
-
|
|
587
|
+
pluginName: plugin.name,
|
|
459
588
|
appName: this.#appName,
|
|
460
589
|
registerService: (name, kind) => {
|
|
461
|
-
record.services.push({ name,
|
|
462
|
-
},
|
|
463
|
-
registerRoute: route => {
|
|
464
|
-
record.routes.push({ ...route, pip: pip.name });
|
|
590
|
+
record.services.push({ name, plugin: plugin.name, kind });
|
|
465
591
|
},
|
|
592
|
+
registerAction: action => this.#registerAction(record, action),
|
|
593
|
+
registerProjection: projection => this.#registerProjection(record, projection),
|
|
594
|
+
registerRoute: route => this.#registerRouteShim(record, route),
|
|
466
595
|
registerLifecycleHook: hook => {
|
|
467
596
|
record.hooks.add(hook);
|
|
468
597
|
},
|
|
@@ -472,45 +601,45 @@ export class DotAppImpl {
|
|
|
472
601
|
};
|
|
473
602
|
|
|
474
603
|
const started = performance.now();
|
|
475
|
-
this.#emitHook('configure',
|
|
604
|
+
this.#emitHook('configure', plugin.name, record.order, 'starting');
|
|
476
605
|
let returned: unknown;
|
|
477
606
|
try {
|
|
478
|
-
returned =
|
|
607
|
+
returned = withPluginHookSpan(
|
|
479
608
|
{
|
|
480
609
|
appName: this.#appName,
|
|
481
|
-
|
|
482
|
-
|
|
610
|
+
pluginName: plugin.name,
|
|
611
|
+
pluginVersion: plugin.version,
|
|
483
612
|
hook: 'configure',
|
|
484
613
|
order: record.order,
|
|
485
|
-
logger:
|
|
614
|
+
logger: pluginLogger,
|
|
486
615
|
},
|
|
487
|
-
() =>
|
|
616
|
+
() => plugin.hooks.configure!(ctx),
|
|
488
617
|
);
|
|
489
618
|
} catch (error) {
|
|
490
619
|
const durationMs = performance.now() - started;
|
|
491
620
|
const issue = makeIssue({
|
|
492
621
|
code: DotLifecycleErrorCode.ConfigureFailed,
|
|
493
|
-
|
|
494
|
-
message: `configure hook threw for
|
|
495
|
-
remediation: `Fix the error in the configure() hook of "${
|
|
622
|
+
plugin: plugin.name,
|
|
623
|
+
message: `configure hook threw for plugin "${plugin.name}": ${stringifyError(error)}`,
|
|
624
|
+
remediation: `Fix the error in the configure() hook of "${plugin.name}". configure() is for synchronous registration only — avoid throwing on declarative work.`,
|
|
496
625
|
docsAnchor: 'configure-failed',
|
|
497
626
|
});
|
|
498
627
|
record.issues.push(issue);
|
|
499
628
|
record.lifecycleDiagnostics.push({
|
|
500
|
-
|
|
629
|
+
plugin: plugin.name,
|
|
501
630
|
hook: 'configure',
|
|
502
631
|
state: 'failed',
|
|
503
632
|
order: record.order,
|
|
504
633
|
durationMs,
|
|
505
634
|
issues: [issue],
|
|
506
635
|
});
|
|
507
|
-
this.#emitHook('configure',
|
|
636
|
+
this.#emitHook('configure', plugin.name, record.order, 'failed', { durationMs, error });
|
|
508
637
|
this.#state = 'failed';
|
|
509
638
|
this.#manifest = this.#buildManifest();
|
|
510
639
|
throw new DotLifecycleError({
|
|
511
640
|
code: DotLifecycleErrorCode.ConfigureFailed,
|
|
512
641
|
phase: 'configure',
|
|
513
|
-
|
|
642
|
+
plugin: plugin.name,
|
|
514
643
|
message: issue.message,
|
|
515
644
|
cause: error,
|
|
516
645
|
});
|
|
@@ -520,21 +649,21 @@ export class DotAppImpl {
|
|
|
520
649
|
const durationMs = performance.now() - started;
|
|
521
650
|
const issue = makeIssue({
|
|
522
651
|
code: DotLifecycleErrorCode.ConfigureAsync,
|
|
523
|
-
|
|
524
|
-
message: `configure hook of
|
|
652
|
+
plugin: plugin.name,
|
|
653
|
+
message: `configure hook of plugin "${plugin.name}" returned a Promise. configure must be synchronous.`,
|
|
525
654
|
remediation: 'Move async work to the boot() hook. configure() is for synchronous registration only.',
|
|
526
655
|
docsAnchor: 'configure-async',
|
|
527
656
|
});
|
|
528
657
|
record.issues.push(issue);
|
|
529
658
|
record.lifecycleDiagnostics.push({
|
|
530
|
-
|
|
659
|
+
plugin: plugin.name,
|
|
531
660
|
hook: 'configure',
|
|
532
661
|
state: 'failed',
|
|
533
662
|
order: record.order,
|
|
534
663
|
durationMs,
|
|
535
664
|
issues: [issue],
|
|
536
665
|
});
|
|
537
|
-
this.#emitHook('configure',
|
|
666
|
+
this.#emitHook('configure', plugin.name, record.order, 'failed', {
|
|
538
667
|
durationMs,
|
|
539
668
|
error: new Error(issue.message),
|
|
540
669
|
});
|
|
@@ -543,38 +672,38 @@ export class DotAppImpl {
|
|
|
543
672
|
throw new DotLifecycleError({
|
|
544
673
|
code: DotLifecycleErrorCode.ConfigureAsync,
|
|
545
674
|
phase: 'configure',
|
|
546
|
-
|
|
675
|
+
plugin: plugin.name,
|
|
547
676
|
message: issue.message,
|
|
548
677
|
});
|
|
549
678
|
}
|
|
550
679
|
|
|
551
680
|
const durationMs = performance.now() - started;
|
|
552
681
|
record.lifecycleDiagnostics.push({
|
|
553
|
-
|
|
682
|
+
plugin: plugin.name,
|
|
554
683
|
hook: 'configure',
|
|
555
684
|
state: 'configured',
|
|
556
685
|
order: record.order,
|
|
557
686
|
durationMs,
|
|
558
687
|
issues: [],
|
|
559
688
|
});
|
|
560
|
-
this.#emitHook('configure',
|
|
561
|
-
|
|
689
|
+
this.#emitHook('configure', plugin.name, record.order, 'completed', { durationMs });
|
|
690
|
+
pluginLogger.debug('configure: done', { 'dot.plugin.duration.ms': durationMs });
|
|
562
691
|
}
|
|
563
692
|
|
|
564
693
|
// Also declare lifecycle-hook participation for non-configure hooks present
|
|
565
|
-
// on the
|
|
566
|
-
for (const
|
|
567
|
-
const record = this.#records.get(
|
|
568
|
-
if (
|
|
569
|
-
if (
|
|
570
|
-
if (
|
|
571
|
-
if (
|
|
694
|
+
// on the plugin object, so the manifest reflects them.
|
|
695
|
+
for (const plugin of this.#ordered) {
|
|
696
|
+
const record = this.#records.get(plugin.name)!;
|
|
697
|
+
if (plugin.hooks.boot) record.hooks.add('boot');
|
|
698
|
+
if (plugin.hooks.start) record.hooks.add('start');
|
|
699
|
+
if (plugin.hooks.stop) record.hooks.add('stop');
|
|
700
|
+
if (plugin.hooks.dispose) record.hooks.add('dispose');
|
|
572
701
|
}
|
|
573
702
|
|
|
574
703
|
this.#configured = true;
|
|
575
704
|
this.#state = 'configured';
|
|
576
705
|
this.#manifest = this.#buildManifest();
|
|
577
|
-
phaseLogger.debug('configure: complete', { 'dot.app.
|
|
706
|
+
phaseLogger.debug('configure: complete', { 'dot.app.plugin.count': this.#ordered.length });
|
|
578
707
|
}
|
|
579
708
|
|
|
580
709
|
/**
|
|
@@ -617,14 +746,14 @@ export class DotAppImpl {
|
|
|
617
746
|
}
|
|
618
747
|
|
|
619
748
|
const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'boot');
|
|
620
|
-
phaseLogger.debug('boot: starting', { 'dot.app.
|
|
749
|
+
phaseLogger.debug('boot: starting', { 'dot.app.plugin.count': this.#ordered.length });
|
|
621
750
|
|
|
622
751
|
return this.#withPhaseEmitAsync('boot', () =>
|
|
623
752
|
withPhaseSpan(
|
|
624
753
|
{
|
|
625
754
|
appName: this.#appName,
|
|
626
755
|
phase: 'boot',
|
|
627
|
-
|
|
756
|
+
pluginCount: this.#ordered.length,
|
|
628
757
|
logger: phaseLogger,
|
|
629
758
|
},
|
|
630
759
|
() => this.#runBootInner(phaseLogger),
|
|
@@ -633,34 +762,34 @@ export class DotAppImpl {
|
|
|
633
762
|
}
|
|
634
763
|
|
|
635
764
|
/**
|
|
636
|
-
* Resolve a
|
|
637
|
-
* the `$`-prefixed kernel keys and — for post-boot hooks — the
|
|
765
|
+
* Resolve a plugin's needs into a hook context (alias-keyed), joined with
|
|
766
|
+
* the `$`-prefixed kernel keys and — for post-boot hooks — the plugin's own
|
|
638
767
|
* published services (local keys).
|
|
639
768
|
*
|
|
640
769
|
* @throws {DotLifecycleError} `DOT_LIFECYCLE_E012` when a need has no
|
|
641
|
-
* provider among earlier-booted
|
|
770
|
+
* provider among earlier-booted plugins. Teardown hooks can never hit
|
|
642
771
|
* this: the service map only grows, and reverse-order teardown keeps
|
|
643
772
|
* providers alive until their consumers are done.
|
|
644
773
|
*/
|
|
645
|
-
#buildHookCtx(record:
|
|
774
|
+
#buildHookCtx(record: PluginRecord, phase: DotLifecycleHook, includeOwnProvides: boolean): Record<string, unknown> {
|
|
646
775
|
const ctx: Record<string, unknown> = {
|
|
647
776
|
$app: this.#appName,
|
|
648
|
-
$
|
|
777
|
+
$plugin: record.plugin.name,
|
|
649
778
|
$config: this.#config,
|
|
650
779
|
};
|
|
651
|
-
for (const [alias, witness] of Object.entries(record.
|
|
780
|
+
for (const [alias, witness] of Object.entries(record.plugin.needs)) {
|
|
652
781
|
const wireKey = wireKeyOf(witness, alias);
|
|
653
782
|
if (!this.#serviceMap.has(wireKey)) {
|
|
654
783
|
throw new DotLifecycleError({
|
|
655
784
|
code: DotLifecycleErrorCode.UnsatisfiedNeed,
|
|
656
785
|
phase,
|
|
657
|
-
|
|
786
|
+
plugin: record.plugin.name,
|
|
658
787
|
message:
|
|
659
|
-
`
|
|
660
|
-
`Register a provider with .use() before this
|
|
661
|
-
`if a later
|
|
788
|
+
`Plugin "${record.plugin.name}" needs service "${wireKey}" but no earlier plugin provides it. ` +
|
|
789
|
+
`Register a provider with .use() before this plugin. Services flow strictly forward — ` +
|
|
790
|
+
`if a later plugin provides "${wireKey}", move that provider earlier; if two plugins need ` +
|
|
662
791
|
`each other's services, that is a cycle: merge them or extract the shared piece into ` +
|
|
663
|
-
`a third
|
|
792
|
+
`a third plugin both consume.`,
|
|
664
793
|
});
|
|
665
794
|
}
|
|
666
795
|
const value = this.#serviceMap.get(wireKey);
|
|
@@ -671,8 +800,8 @@ export class DotAppImpl {
|
|
|
671
800
|
// the underlying value's lifecycle stays with its provider.
|
|
672
801
|
ctx[alias] = isLazyWitness(witness) && !isLazy(value) ? lazyOf(value) : value;
|
|
673
802
|
const provider = this.#providerByWireKey.get(wireKey);
|
|
674
|
-
if (provider !== undefined && !this.#wiringEdges.some(e => e.from === record.
|
|
675
|
-
this.#wiringEdges.push({ from: record.
|
|
803
|
+
if (provider !== undefined && !this.#wiringEdges.some(e => e.from === record.plugin.name && e.to === provider)) {
|
|
804
|
+
this.#wiringEdges.push({ from: record.plugin.name, to: provider, kind: 'requires' });
|
|
676
805
|
}
|
|
677
806
|
}
|
|
678
807
|
if (includeOwnProvides) {
|
|
@@ -684,13 +813,13 @@ export class DotAppImpl {
|
|
|
684
813
|
/**
|
|
685
814
|
* Race one hook invocation against the app's `hookTimeoutMs` watchdog.
|
|
686
815
|
* No budget configured → plain pass-through. On timeout the returned
|
|
687
|
-
* promise rejects with `DOT_LIFECYCLE_E015` naming the
|
|
816
|
+
* promise rejects with `DOT_LIFECYCLE_E015` naming the plugin and hook,
|
|
688
817
|
* and the call site's existing catch path applies the phase's normal
|
|
689
818
|
* failure rules (boot rollback, start cascade, teardown aggregation).
|
|
690
819
|
* The hook's own promise cannot be cancelled — the watchdog makes a
|
|
691
820
|
* hang visible and bounded; it does not kill the hung work.
|
|
692
821
|
*/
|
|
693
|
-
async #withHookBudget<T>(
|
|
822
|
+
async #withHookBudget<T>(pluginName: string, hook: DotLifecycleHook, run: () => Promise<T> | T): Promise<T> {
|
|
694
823
|
const budget = this.#hookTimeoutMs;
|
|
695
824
|
if (budget === undefined) return run();
|
|
696
825
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -700,9 +829,9 @@ export class DotAppImpl {
|
|
|
700
829
|
new DotLifecycleError({
|
|
701
830
|
code: DotLifecycleErrorCode.HookTimeout,
|
|
702
831
|
phase: hook,
|
|
703
|
-
|
|
832
|
+
plugin: pluginName,
|
|
704
833
|
message:
|
|
705
|
-
`${hook} hook of
|
|
834
|
+
`${hook} hook of plugin "${pluginName}" exceeded the ${budget.toString()}ms hookTimeoutMs watchdog. ` +
|
|
706
835
|
`The kernel treats the hook as failed and applies its normal rollback/aggregation rules, but ` +
|
|
707
836
|
`cannot cancel the hook's promise — find the hang (a missing await? a connection that never ` +
|
|
708
837
|
`settles?) or raise the budget in defineApp(name, { hookTimeoutMs }).`,
|
|
@@ -725,11 +854,11 @@ export class DotAppImpl {
|
|
|
725
854
|
* partial failure — stays unindented.
|
|
726
855
|
*/
|
|
727
856
|
async #runBootInner(phaseLogger: Logger): Promise<void> {
|
|
728
|
-
const bootedRecords:
|
|
857
|
+
const bootedRecords: PluginRecord[] = [];
|
|
729
858
|
|
|
730
859
|
/** Shared failure path: mark diagnostics, roll back, throw. */
|
|
731
860
|
const fail = async (args: {
|
|
732
|
-
record:
|
|
861
|
+
record: PluginRecord;
|
|
733
862
|
code: (typeof DotLifecycleErrorCode)[keyof typeof DotLifecycleErrorCode];
|
|
734
863
|
message: string;
|
|
735
864
|
remediation: string;
|
|
@@ -737,30 +866,30 @@ export class DotAppImpl {
|
|
|
737
866
|
durationMs: number;
|
|
738
867
|
cause?: unknown;
|
|
739
868
|
/** Records to dispose (reverse order applied here). */
|
|
740
|
-
rollback: readonly
|
|
869
|
+
rollback: readonly PluginRecord[];
|
|
741
870
|
}): Promise<never> => {
|
|
742
871
|
const issue = makeIssue({
|
|
743
872
|
code: args.code,
|
|
744
|
-
|
|
873
|
+
plugin: args.record.plugin.name,
|
|
745
874
|
message: args.message,
|
|
746
875
|
remediation: args.remediation,
|
|
747
876
|
docsAnchor: args.docsAnchor,
|
|
748
877
|
});
|
|
749
878
|
args.record.issues.push(issue);
|
|
750
879
|
args.record.lifecycleDiagnostics.push({
|
|
751
|
-
|
|
880
|
+
plugin: args.record.plugin.name,
|
|
752
881
|
hook: 'boot',
|
|
753
882
|
state: 'failed',
|
|
754
883
|
order: args.record.order,
|
|
755
884
|
durationMs: args.durationMs,
|
|
756
885
|
issues: [issue],
|
|
757
886
|
});
|
|
758
|
-
this.#emitHook('boot', args.record.
|
|
887
|
+
this.#emitHook('boot', args.record.plugin.name, args.record.order, 'failed', {
|
|
759
888
|
durationMs: args.durationMs,
|
|
760
889
|
error: args.cause ?? new Error(args.message),
|
|
761
890
|
});
|
|
762
|
-
phaseLogger.error('boot: FAILED — rolling back already-booted
|
|
763
|
-
'dot.
|
|
891
|
+
phaseLogger.error('boot: FAILED — rolling back already-booted plugins', {
|
|
892
|
+
'dot.plugin.name': args.record.plugin.name,
|
|
764
893
|
'dot.app.rollback.count': args.rollback.length,
|
|
765
894
|
});
|
|
766
895
|
const disposeFailures = await this.#runDisposeForRecords(reverseRecords(args.rollback), phaseLogger);
|
|
@@ -769,19 +898,19 @@ export class DotAppImpl {
|
|
|
769
898
|
throw new DotLifecycleError({
|
|
770
899
|
code: args.code,
|
|
771
900
|
phase: 'boot',
|
|
772
|
-
|
|
901
|
+
plugin: args.record.plugin.name,
|
|
773
902
|
message: args.message,
|
|
774
903
|
cause: args.cause,
|
|
775
904
|
failures: disposeFailures.length > 0 ? disposeFailures : undefined,
|
|
776
905
|
});
|
|
777
906
|
};
|
|
778
907
|
|
|
779
|
-
for (const
|
|
780
|
-
const record = this.#records.get(
|
|
781
|
-
const
|
|
908
|
+
for (const plugin of this.#ordered) {
|
|
909
|
+
const record = this.#records.get(plugin.name)!;
|
|
910
|
+
const pluginLogger = phaseLogger.withAttribute('dot.plugin.name', plugin.name);
|
|
782
911
|
const started = performance.now();
|
|
783
912
|
|
|
784
|
-
// Resolve needs BEFORE invoking the hook — a
|
|
913
|
+
// Resolve needs BEFORE invoking the hook — a plugin with needs but no
|
|
785
914
|
// boot hook still fails fast when its wiring is unsatisfied.
|
|
786
915
|
let ctx: Record<string, unknown>;
|
|
787
916
|
try {
|
|
@@ -793,40 +922,40 @@ export class DotAppImpl {
|
|
|
793
922
|
code: DotLifecycleErrorCode.UnsatisfiedNeed,
|
|
794
923
|
message,
|
|
795
924
|
remediation:
|
|
796
|
-
`Add a provider for the missing service with .use() before "${
|
|
797
|
-
`existing provider's keys to match. If a later
|
|
798
|
-
`strictly forward. Mutual needs between two
|
|
799
|
-
`shared piece into a third
|
|
925
|
+
`Add a provider for the missing service with .use() before "${plugin.name}", or rename an ` +
|
|
926
|
+
`existing provider's keys to match. If a later plugin provides it, reorder — services flow ` +
|
|
927
|
+
`strictly forward. Mutual needs between two plugins are a cycle: merge them or extract the ` +
|
|
928
|
+
`shared piece into a third plugin both consume.`,
|
|
800
929
|
docsAnchor: 'unsatisfied-need',
|
|
801
930
|
durationMs: performance.now() - started,
|
|
802
931
|
rollback: bootedRecords,
|
|
803
932
|
});
|
|
804
933
|
}
|
|
805
934
|
|
|
806
|
-
if (!
|
|
935
|
+
if (!plugin.hooks.boot) {
|
|
807
936
|
record.booted = true;
|
|
808
937
|
bootedRecords.push(record);
|
|
809
|
-
|
|
938
|
+
pluginLogger.debug('boot: skipped (no hook)');
|
|
810
939
|
continue;
|
|
811
940
|
}
|
|
812
941
|
record.hooks.add('boot');
|
|
813
942
|
|
|
814
|
-
this.#emitHook('boot',
|
|
943
|
+
this.#emitHook('boot', plugin.name, record.order, 'starting');
|
|
815
944
|
let result: ServiceRecord | void;
|
|
816
945
|
try {
|
|
817
|
-
result = await this.#withHookBudget(
|
|
818
|
-
|
|
946
|
+
result = await this.#withHookBudget(plugin.name, 'boot', () =>
|
|
947
|
+
withPluginHookSpan(
|
|
819
948
|
{
|
|
820
949
|
appName: this.#appName,
|
|
821
|
-
|
|
822
|
-
|
|
950
|
+
pluginName: plugin.name,
|
|
951
|
+
pluginVersion: plugin.version,
|
|
823
952
|
hook: 'boot',
|
|
824
953
|
order: record.order,
|
|
825
|
-
logger:
|
|
954
|
+
logger: pluginLogger,
|
|
826
955
|
},
|
|
827
956
|
// Erasure boundary: hooks are stored as `(ctx: never) => ...`;
|
|
828
957
|
// the kernel is the one caller allowed to cross it.
|
|
829
|
-
() =>
|
|
958
|
+
() => plugin.hooks.boot!(ctx as never),
|
|
830
959
|
),
|
|
831
960
|
);
|
|
832
961
|
} catch (error) {
|
|
@@ -836,10 +965,10 @@ export class DotAppImpl {
|
|
|
836
965
|
code: timedOut ? DotLifecycleErrorCode.HookTimeout : DotLifecycleErrorCode.BootFailed,
|
|
837
966
|
message: timedOut
|
|
838
967
|
? error.message
|
|
839
|
-
: `boot hook threw for
|
|
968
|
+
: `boot hook threw for plugin "${plugin.name}": ${stringifyError(error)}`,
|
|
840
969
|
remediation: timedOut
|
|
841
|
-
? `Find the hang in the boot() hook of "${
|
|
842
|
-
: `Fix the error in the boot() hook of "${
|
|
970
|
+
? `Find the hang in the boot() hook of "${plugin.name}" — a missing await or a connection that never settles — or raise defineApp's hookTimeoutMs.`
|
|
971
|
+
: `Fix the error in the boot() hook of "${plugin.name}". If boot opens partial resources before throwing, clean them up locally — DOT only disposes plugins whose boot completed.`,
|
|
843
972
|
docsAnchor: timedOut ? 'hook-timeout' : 'boot-failed',
|
|
844
973
|
durationMs: performance.now() - started,
|
|
845
974
|
cause: error,
|
|
@@ -852,18 +981,18 @@ export class DotAppImpl {
|
|
|
852
981
|
record.booted = true;
|
|
853
982
|
|
|
854
983
|
for (const [localKey, value] of Object.entries(publishedServices)) {
|
|
855
|
-
const wireKey =
|
|
984
|
+
const wireKey = plugin.renames[localKey] ?? localKey;
|
|
856
985
|
if (localKey.startsWith('$') || wireKey.startsWith('$')) {
|
|
857
|
-
// The
|
|
858
|
-
// still reach here. A `$` publish would shadow $app/$
|
|
859
|
-
// in the
|
|
986
|
+
// The plugin() constraint bans this at compile time; erased plugins can
|
|
987
|
+
// still reach here. A `$` publish would shadow $app/$plugin/$config
|
|
988
|
+
// in the plugin's own post-boot hook contexts.
|
|
860
989
|
return fail({
|
|
861
990
|
record,
|
|
862
991
|
code: DotLifecycleErrorCode.ReservedServiceKey,
|
|
863
992
|
message:
|
|
864
|
-
`
|
|
865
|
-
`for kernel context keys ($app, $
|
|
866
|
-
remediation: `Rename the "${wireKey}" key returned from the boot() hook of "${
|
|
993
|
+
`Plugin "${plugin.name}" publishes service "${wireKey}" — the "$" prefix is reserved ` +
|
|
994
|
+
`for kernel context keys ($app, $plugin, $config).`,
|
|
995
|
+
remediation: `Rename the "${wireKey}" key returned from the boot() hook of "${plugin.name}" — "$"-prefixed keys would shadow the kernel context.`,
|
|
867
996
|
docsAnchor: 'reserved-service-key',
|
|
868
997
|
durationMs: performance.now() - started,
|
|
869
998
|
rollback: [...bootedRecords, record],
|
|
@@ -871,19 +1000,19 @@ export class DotAppImpl {
|
|
|
871
1000
|
}
|
|
872
1001
|
if (this.#serviceMap.has(wireKey)) {
|
|
873
1002
|
const owner = this.#providerByWireKey.get(wireKey) ?? 'unknown';
|
|
874
|
-
// The current
|
|
1003
|
+
// The current plugin HAS booted — include it in the rollback.
|
|
875
1004
|
return fail({
|
|
876
1005
|
record,
|
|
877
1006
|
code: DotLifecycleErrorCode.ServiceCollision,
|
|
878
|
-
message: `
|
|
879
|
-
remediation: `Mount one of the two with rename(
|
|
1007
|
+
message: `Plugin "${plugin.name}" publishes service "${wireKey}" which plugin "${owner}" already provides.`,
|
|
1008
|
+
remediation: `Mount one of the two with rename(plugin, { '${wireKey}': '<newKey>' }) to keep both instances, or remove the duplicate provider.`,
|
|
880
1009
|
docsAnchor: 'service-collision',
|
|
881
1010
|
durationMs: performance.now() - started,
|
|
882
1011
|
rollback: [...bootedRecords, record],
|
|
883
1012
|
});
|
|
884
1013
|
}
|
|
885
1014
|
this.#serviceMap.set(wireKey, value);
|
|
886
|
-
this.#providerByWireKey.set(wireKey,
|
|
1015
|
+
this.#providerByWireKey.set(wireKey, plugin.name);
|
|
887
1016
|
this.#aggregatedServices[wireKey] = value;
|
|
888
1017
|
record.provides.add(wireKey);
|
|
889
1018
|
}
|
|
@@ -891,23 +1020,23 @@ export class DotAppImpl {
|
|
|
891
1020
|
|
|
892
1021
|
const durationMs = performance.now() - started;
|
|
893
1022
|
record.lifecycleDiagnostics.push({
|
|
894
|
-
|
|
1023
|
+
plugin: plugin.name,
|
|
895
1024
|
hook: 'boot',
|
|
896
1025
|
state: 'booted',
|
|
897
1026
|
order: record.order,
|
|
898
1027
|
durationMs,
|
|
899
1028
|
issues: [],
|
|
900
1029
|
});
|
|
901
|
-
this.#emitHook('boot',
|
|
902
|
-
|
|
903
|
-
'dot.
|
|
904
|
-
'dot.
|
|
1030
|
+
this.#emitHook('boot', plugin.name, record.order, 'completed', { durationMs });
|
|
1031
|
+
pluginLogger.debug('boot: done', {
|
|
1032
|
+
'dot.plugin.duration.ms': durationMs,
|
|
1033
|
+
'dot.plugin.services.published': Object.keys(publishedServices).length,
|
|
905
1034
|
});
|
|
906
1035
|
}
|
|
907
1036
|
|
|
908
1037
|
this.#state = 'booted';
|
|
909
1038
|
this.#manifest = this.#buildManifest();
|
|
910
|
-
phaseLogger.debug('boot: complete', { 'dot.app.
|
|
1039
|
+
phaseLogger.debug('boot: complete', { 'dot.app.plugin.count': this.#ordered.length });
|
|
911
1040
|
}
|
|
912
1041
|
|
|
913
1042
|
/** Public start(). Boots first if needed. Idempotent + concurrent-safe. */
|
|
@@ -933,14 +1062,14 @@ export class DotAppImpl {
|
|
|
933
1062
|
}
|
|
934
1063
|
// From booted or stopped, run start hooks.
|
|
935
1064
|
const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'start');
|
|
936
|
-
phaseLogger.debug('start: starting', { 'dot.app.
|
|
1065
|
+
phaseLogger.debug('start: starting', { 'dot.app.plugin.count': this.#ordered.length });
|
|
937
1066
|
|
|
938
1067
|
return this.#withPhaseEmitAsync('start', () =>
|
|
939
1068
|
withPhaseSpan(
|
|
940
1069
|
{
|
|
941
1070
|
appName: this.#appName,
|
|
942
1071
|
phase: 'start',
|
|
943
|
-
|
|
1072
|
+
pluginCount: this.#ordered.length,
|
|
944
1073
|
logger: phaseLogger,
|
|
945
1074
|
},
|
|
946
1075
|
() => this.#runStartInner(phaseLogger),
|
|
@@ -953,12 +1082,12 @@ export class DotAppImpl {
|
|
|
953
1082
|
* stays thin and the rollback-cascade error path stays readable.
|
|
954
1083
|
*/
|
|
955
1084
|
async #runStartInner(phaseLogger: Logger): Promise<void> {
|
|
956
|
-
const startedRecords:
|
|
957
|
-
for (const
|
|
958
|
-
const record = this.#records.get(
|
|
959
|
-
const
|
|
960
|
-
if (!
|
|
961
|
-
|
|
1085
|
+
const startedRecords: PluginRecord[] = [];
|
|
1086
|
+
for (const plugin of this.#ordered) {
|
|
1087
|
+
const record = this.#records.get(plugin.name)!;
|
|
1088
|
+
const pluginLogger = phaseLogger.withAttribute('dot.plugin.name', plugin.name);
|
|
1089
|
+
if (!plugin.hooks.start) {
|
|
1090
|
+
pluginLogger.debug('start: skipped (no hook)');
|
|
962
1091
|
continue;
|
|
963
1092
|
}
|
|
964
1093
|
record.hooks.add('start');
|
|
@@ -966,19 +1095,19 @@ export class DotAppImpl {
|
|
|
966
1095
|
const ctx = this.#buildHookCtx(record, 'start', true);
|
|
967
1096
|
|
|
968
1097
|
const startedAt = performance.now();
|
|
969
|
-
this.#emitHook('start',
|
|
1098
|
+
this.#emitHook('start', plugin.name, record.order, 'starting');
|
|
970
1099
|
try {
|
|
971
|
-
await this.#withHookBudget(
|
|
972
|
-
|
|
1100
|
+
await this.#withHookBudget(plugin.name, 'start', () =>
|
|
1101
|
+
withPluginHookSpan(
|
|
973
1102
|
{
|
|
974
1103
|
appName: this.#appName,
|
|
975
|
-
|
|
976
|
-
|
|
1104
|
+
pluginName: plugin.name,
|
|
1105
|
+
pluginVersion: plugin.version,
|
|
977
1106
|
hook: 'start',
|
|
978
1107
|
order: record.order,
|
|
979
|
-
logger:
|
|
1108
|
+
logger: pluginLogger,
|
|
980
1109
|
},
|
|
981
|
-
() =>
|
|
1110
|
+
() => plugin.hooks.start!(ctx as never),
|
|
982
1111
|
),
|
|
983
1112
|
);
|
|
984
1113
|
} catch (error) {
|
|
@@ -986,27 +1115,27 @@ export class DotAppImpl {
|
|
|
986
1115
|
const timedOut = error instanceof DotLifecycleError && error.code === DotLifecycleErrorCode.HookTimeout;
|
|
987
1116
|
const issue = makeIssue({
|
|
988
1117
|
code: timedOut ? DotLifecycleErrorCode.HookTimeout : DotLifecycleErrorCode.StartFailed,
|
|
989
|
-
|
|
1118
|
+
plugin: plugin.name,
|
|
990
1119
|
message: timedOut
|
|
991
1120
|
? error.message
|
|
992
|
-
: `start hook threw for
|
|
1121
|
+
: `start hook threw for plugin "${plugin.name}": ${stringifyError(error)}`,
|
|
993
1122
|
remediation: timedOut
|
|
994
|
-
? `Find the hang in the start() hook of "${
|
|
995
|
-
: `Fix the error in the start() hook of "${
|
|
1123
|
+
? `Find the hang in the start() hook of "${plugin.name}" — or raise defineApp's hookTimeoutMs. DOT rolls back as for any start failure.`
|
|
1124
|
+
: `Fix the error in the start() hook of "${plugin.name}". DOT will stop all already-started plugins and dispose all booted plugins in reverse order.`,
|
|
996
1125
|
docsAnchor: timedOut ? 'hook-timeout' : 'start-failed',
|
|
997
1126
|
});
|
|
998
1127
|
record.issues.push(issue);
|
|
999
1128
|
record.lifecycleDiagnostics.push({
|
|
1000
|
-
|
|
1129
|
+
plugin: plugin.name,
|
|
1001
1130
|
hook: 'start',
|
|
1002
1131
|
state: 'failed',
|
|
1003
1132
|
order: record.order,
|
|
1004
1133
|
durationMs,
|
|
1005
1134
|
issues: [issue],
|
|
1006
1135
|
});
|
|
1007
|
-
this.#emitHook('start',
|
|
1136
|
+
this.#emitHook('start', plugin.name, record.order, 'failed', { durationMs, error });
|
|
1008
1137
|
|
|
1009
|
-
|
|
1138
|
+
pluginLogger.error('start: FAILED — rolling back', {
|
|
1010
1139
|
'dot.app.rollback.started.count': startedRecords.length,
|
|
1011
1140
|
});
|
|
1012
1141
|
const stopFailures = await this.#runStopForRecords(reverseRecords(startedRecords), phaseLogger);
|
|
@@ -1018,7 +1147,7 @@ export class DotAppImpl {
|
|
|
1018
1147
|
throw new DotLifecycleError({
|
|
1019
1148
|
code: timedOut ? DotLifecycleErrorCode.HookTimeout : DotLifecycleErrorCode.StartFailed,
|
|
1020
1149
|
phase: 'start',
|
|
1021
|
-
|
|
1150
|
+
plugin: plugin.name,
|
|
1022
1151
|
message: issue.message,
|
|
1023
1152
|
cause: error,
|
|
1024
1153
|
failures: failures.length > 0 ? failures : undefined,
|
|
@@ -1029,20 +1158,20 @@ export class DotAppImpl {
|
|
|
1029
1158
|
startedRecords.push(record);
|
|
1030
1159
|
const durationMs = performance.now() - startedAt;
|
|
1031
1160
|
record.lifecycleDiagnostics.push({
|
|
1032
|
-
|
|
1161
|
+
plugin: plugin.name,
|
|
1033
1162
|
hook: 'start',
|
|
1034
1163
|
state: 'started',
|
|
1035
1164
|
order: record.order,
|
|
1036
1165
|
durationMs,
|
|
1037
1166
|
issues: [],
|
|
1038
1167
|
});
|
|
1039
|
-
this.#emitHook('start',
|
|
1040
|
-
|
|
1168
|
+
this.#emitHook('start', plugin.name, record.order, 'completed', { durationMs });
|
|
1169
|
+
pluginLogger.debug('start: done', { 'dot.plugin.duration.ms': durationMs });
|
|
1041
1170
|
}
|
|
1042
1171
|
|
|
1043
1172
|
this.#state = 'started';
|
|
1044
1173
|
this.#manifest = this.#buildManifest();
|
|
1045
|
-
phaseLogger.debug('start: complete', { 'dot.app.
|
|
1174
|
+
phaseLogger.debug('start: complete', { 'dot.app.plugin.count': this.#ordered.length });
|
|
1046
1175
|
}
|
|
1047
1176
|
|
|
1048
1177
|
/** Public stop(). Idempotent + concurrent-safe. */
|
|
@@ -1071,14 +1200,14 @@ export class DotAppImpl {
|
|
|
1071
1200
|
|
|
1072
1201
|
async #runStop(): Promise<void> {
|
|
1073
1202
|
const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'stop');
|
|
1074
|
-
phaseLogger.debug('stop: starting', { 'dot.app.
|
|
1203
|
+
phaseLogger.debug('stop: starting', { 'dot.app.plugin.count': this.#ordered.length });
|
|
1075
1204
|
|
|
1076
1205
|
return this.#withPhaseEmitAsync('stop', () =>
|
|
1077
1206
|
withPhaseSpan(
|
|
1078
1207
|
{
|
|
1079
1208
|
appName: this.#appName,
|
|
1080
1209
|
phase: 'stop',
|
|
1081
|
-
|
|
1210
|
+
pluginCount: this.#ordered.length,
|
|
1082
1211
|
logger: phaseLogger,
|
|
1083
1212
|
},
|
|
1084
1213
|
async () => {
|
|
@@ -1091,73 +1220,73 @@ export class DotAppImpl {
|
|
|
1091
1220
|
throw new DotLifecycleError({
|
|
1092
1221
|
code: DotLifecycleErrorCode.StopFailed,
|
|
1093
1222
|
phase: 'stop',
|
|
1094
|
-
message: `${failures.length}
|
|
1223
|
+
message: `${failures.length} plugin(s) failed during stop`,
|
|
1095
1224
|
failures,
|
|
1096
1225
|
});
|
|
1097
1226
|
}
|
|
1098
|
-
phaseLogger.debug('stop: complete', { 'dot.app.
|
|
1227
|
+
phaseLogger.debug('stop: complete', { 'dot.app.plugin.count': this.#ordered.length });
|
|
1099
1228
|
},
|
|
1100
1229
|
),
|
|
1101
1230
|
);
|
|
1102
1231
|
}
|
|
1103
1232
|
|
|
1104
|
-
async #runStopForRecords(records: readonly
|
|
1105
|
-
const failures:
|
|
1233
|
+
async #runStopForRecords(records: readonly PluginRecord[], phaseLogger: Logger): Promise<DotLifecyclePluginFailure[]> {
|
|
1234
|
+
const failures: DotLifecyclePluginFailure[] = [];
|
|
1106
1235
|
for (const record of records) {
|
|
1107
|
-
if (!record.
|
|
1108
|
-
const
|
|
1236
|
+
if (!record.plugin.hooks.stop) continue;
|
|
1237
|
+
const pluginLogger = phaseLogger.withAttribute('dot.plugin.name', record.plugin.name);
|
|
1109
1238
|
record.hooks.add('stop');
|
|
1110
1239
|
const ctx = this.#buildHookCtx(record, 'stop', true);
|
|
1111
1240
|
const startedAt = performance.now();
|
|
1112
|
-
this.#emitHook('stop', record.
|
|
1241
|
+
this.#emitHook('stop', record.plugin.name, record.order, 'starting');
|
|
1113
1242
|
try {
|
|
1114
|
-
await this.#withHookBudget(record.
|
|
1115
|
-
|
|
1243
|
+
await this.#withHookBudget(record.plugin.name, 'stop', () =>
|
|
1244
|
+
withPluginHookSpan(
|
|
1116
1245
|
{
|
|
1117
1246
|
appName: this.#appName,
|
|
1118
|
-
|
|
1119
|
-
|
|
1247
|
+
pluginName: record.plugin.name,
|
|
1248
|
+
pluginVersion: record.plugin.version,
|
|
1120
1249
|
hook: 'stop',
|
|
1121
1250
|
order: record.order,
|
|
1122
|
-
logger:
|
|
1251
|
+
logger: pluginLogger,
|
|
1123
1252
|
},
|
|
1124
|
-
() => record.
|
|
1253
|
+
() => record.plugin.hooks.stop!(ctx as never),
|
|
1125
1254
|
),
|
|
1126
1255
|
);
|
|
1127
1256
|
record.started = false;
|
|
1128
1257
|
const durationMs = performance.now() - startedAt;
|
|
1129
1258
|
record.lifecycleDiagnostics.push({
|
|
1130
|
-
|
|
1259
|
+
plugin: record.plugin.name,
|
|
1131
1260
|
hook: 'stop',
|
|
1132
1261
|
state: 'stopped',
|
|
1133
1262
|
order: record.order,
|
|
1134
1263
|
durationMs,
|
|
1135
1264
|
issues: [],
|
|
1136
1265
|
});
|
|
1137
|
-
this.#emitHook('stop', record.
|
|
1138
|
-
|
|
1266
|
+
this.#emitHook('stop', record.plugin.name, record.order, 'completed', { durationMs });
|
|
1267
|
+
pluginLogger.debug('stop: done', { 'dot.plugin.duration.ms': durationMs });
|
|
1139
1268
|
} catch (error) {
|
|
1140
1269
|
const durationMs = performance.now() - startedAt;
|
|
1141
1270
|
const issue = makeIssue({
|
|
1142
1271
|
code: DotLifecycleErrorCode.StopFailed,
|
|
1143
|
-
|
|
1144
|
-
message: `stop hook threw for
|
|
1145
|
-
remediation: `Fix the error in the stop() hook of "${record.
|
|
1272
|
+
plugin: record.plugin.name,
|
|
1273
|
+
message: `stop hook threw for plugin "${record.plugin.name}": ${stringifyError(error)}`,
|
|
1274
|
+
remediation: `Fix the error in the stop() hook of "${record.plugin.name}". Stop continues through individual failures and reports an aggregate error.`,
|
|
1146
1275
|
docsAnchor: 'stop-failed',
|
|
1147
1276
|
});
|
|
1148
1277
|
record.issues.push(issue);
|
|
1149
1278
|
record.lifecycleDiagnostics.push({
|
|
1150
|
-
|
|
1279
|
+
plugin: record.plugin.name,
|
|
1151
1280
|
hook: 'stop',
|
|
1152
1281
|
state: 'failed',
|
|
1153
1282
|
order: record.order,
|
|
1154
1283
|
durationMs,
|
|
1155
1284
|
issues: [issue],
|
|
1156
1285
|
});
|
|
1157
|
-
this.#emitHook('stop', record.
|
|
1158
|
-
failures.push({
|
|
1159
|
-
|
|
1160
|
-
'dot.
|
|
1286
|
+
this.#emitHook('stop', record.plugin.name, record.order, 'failed', { durationMs, error });
|
|
1287
|
+
failures.push({ plugin: record.plugin.name, phase: 'stop', error });
|
|
1288
|
+
pluginLogger.error('stop: failed (continuing)', {
|
|
1289
|
+
'dot.plugin.error.message': stringifyError(error),
|
|
1161
1290
|
});
|
|
1162
1291
|
}
|
|
1163
1292
|
}
|
|
@@ -1181,14 +1310,14 @@ export class DotAppImpl {
|
|
|
1181
1310
|
|
|
1182
1311
|
async #runDispose(): Promise<void> {
|
|
1183
1312
|
const phaseLogger = this.#logger.withAttribute('dot.app.phase', 'dispose');
|
|
1184
|
-
phaseLogger.debug('dispose: starting', { 'dot.app.
|
|
1313
|
+
phaseLogger.debug('dispose: starting', { 'dot.app.plugin.count': this.#ordered.length });
|
|
1185
1314
|
|
|
1186
1315
|
return this.#withPhaseEmitAsync('dispose', () =>
|
|
1187
1316
|
withPhaseSpan(
|
|
1188
1317
|
{
|
|
1189
1318
|
appName: this.#appName,
|
|
1190
1319
|
phase: 'dispose',
|
|
1191
|
-
|
|
1320
|
+
pluginCount: this.#ordered.length,
|
|
1192
1321
|
logger: phaseLogger,
|
|
1193
1322
|
},
|
|
1194
1323
|
() => this.#runDisposeInner(phaseLogger),
|
|
@@ -1221,7 +1350,7 @@ export class DotAppImpl {
|
|
|
1221
1350
|
throw new DotLifecycleError({
|
|
1222
1351
|
code: DotLifecycleErrorCode.DisposeFailed,
|
|
1223
1352
|
phase: 'dispose',
|
|
1224
|
-
message: `${failures.length}
|
|
1353
|
+
message: `${failures.length} plugin(s) failed during stop+dispose cascade`,
|
|
1225
1354
|
failures,
|
|
1226
1355
|
});
|
|
1227
1356
|
}
|
|
@@ -1229,7 +1358,7 @@ export class DotAppImpl {
|
|
|
1229
1358
|
return;
|
|
1230
1359
|
}
|
|
1231
1360
|
|
|
1232
|
-
// From booted/stopped/configured/defined: only dispose booted
|
|
1361
|
+
// From booted/stopped/configured/defined: only dispose booted plugins.
|
|
1233
1362
|
if (this.#state === 'failed') {
|
|
1234
1363
|
// Already cleaned up at the failure site — mark disposed.
|
|
1235
1364
|
this.#state = 'disposed';
|
|
@@ -1247,15 +1376,15 @@ export class DotAppImpl {
|
|
|
1247
1376
|
throw new DotLifecycleError({
|
|
1248
1377
|
code: DotLifecycleErrorCode.DisposeFailed,
|
|
1249
1378
|
phase: 'dispose',
|
|
1250
|
-
message: `${failures.length}
|
|
1379
|
+
message: `${failures.length} plugin(s) failed during dispose`,
|
|
1251
1380
|
failures,
|
|
1252
1381
|
});
|
|
1253
1382
|
}
|
|
1254
|
-
phaseLogger.debug('dispose: complete', { 'dot.app.
|
|
1383
|
+
phaseLogger.debug('dispose: complete', { 'dot.app.plugin.count': this.#ordered.length });
|
|
1255
1384
|
}
|
|
1256
1385
|
|
|
1257
|
-
async #runDisposeForRecords(records: readonly
|
|
1258
|
-
const failures:
|
|
1386
|
+
async #runDisposeForRecords(records: readonly PluginRecord[], phaseLogger: Logger): Promise<DotLifecyclePluginFailure[]> {
|
|
1387
|
+
const failures: DotLifecyclePluginFailure[] = [];
|
|
1259
1388
|
|
|
1260
1389
|
// A lazy handle can be published under several keys — a `service.lazy`
|
|
1261
1390
|
// consumer republishing its provider's handle passes it through by
|
|
@@ -1263,8 +1392,8 @@ export class DotAppImpl {
|
|
|
1263
1392
|
// (declaration order): dispose runs in reverse, so cleaning the handle
|
|
1264
1393
|
// with a republisher would kill it before the original provider's own
|
|
1265
1394
|
// dispose hook gets to use it. `records` arrives reversed, so plain
|
|
1266
|
-
// overwrite leaves the earliest-declared
|
|
1267
|
-
const ownerOf = new Map<Lazy<unknown>,
|
|
1395
|
+
// overwrite leaves the earliest-declared plugin as each handle's owner.
|
|
1396
|
+
const ownerOf = new Map<Lazy<unknown>, PluginRecord>();
|
|
1268
1397
|
for (const record of records) {
|
|
1269
1398
|
for (const value of Object.values(record.publishedServices)) {
|
|
1270
1399
|
if (isLazy(value)) ownerOf.set(value, record);
|
|
@@ -1281,16 +1410,16 @@ export class DotAppImpl {
|
|
|
1281
1410
|
return true;
|
|
1282
1411
|
},
|
|
1283
1412
|
);
|
|
1284
|
-
const hasHook = record.
|
|
1413
|
+
const hasHook = record.plugin.hooks.dispose !== undefined;
|
|
1285
1414
|
if (!hasHook && lazyPublishes.length === 0) {
|
|
1286
1415
|
record.booted = false;
|
|
1287
1416
|
continue;
|
|
1288
1417
|
}
|
|
1289
|
-
const
|
|
1418
|
+
const pluginLogger = phaseLogger.withAttribute('dot.plugin.name', record.plugin.name);
|
|
1290
1419
|
if (hasHook) {
|
|
1291
|
-
await this.#runDisposeHook(record,
|
|
1420
|
+
await this.#runDisposeHook(record, pluginLogger, failures);
|
|
1292
1421
|
}
|
|
1293
|
-
// Auto-dispose lazy service handles AFTER the
|
|
1422
|
+
// Auto-dispose lazy service handles AFTER the plugin's own dispose hook
|
|
1294
1423
|
// (the hook may still use them). Never-initialized handles no-op.
|
|
1295
1424
|
for (const [serviceKey, handle] of lazyPublishes) {
|
|
1296
1425
|
try {
|
|
@@ -1298,16 +1427,16 @@ export class DotAppImpl {
|
|
|
1298
1427
|
} catch (error) {
|
|
1299
1428
|
const issue = makeIssue({
|
|
1300
1429
|
code: DotLifecycleErrorCode.DisposeFailed,
|
|
1301
|
-
|
|
1302
|
-
message: `lazy service "${serviceKey}" cleanup threw for
|
|
1430
|
+
plugin: record.plugin.name,
|
|
1431
|
+
message: `lazy service "${serviceKey}" cleanup threw for plugin "${record.plugin.name}": ${stringifyError(error)}`,
|
|
1303
1432
|
remediation: `Fix the error in the dispose callback passed to lazy(...) for service "${serviceKey}". Dispose continues through individual failures and reports an aggregate error.`,
|
|
1304
1433
|
docsAnchor: 'dispose-failed',
|
|
1305
1434
|
});
|
|
1306
1435
|
record.issues.push(issue);
|
|
1307
|
-
failures.push({
|
|
1308
|
-
|
|
1309
|
-
'dot.
|
|
1310
|
-
'dot.
|
|
1436
|
+
failures.push({ plugin: record.plugin.name, phase: 'dispose', error });
|
|
1437
|
+
pluginLogger.error('dispose: lazy service cleanup failed (continuing)', {
|
|
1438
|
+
'dot.plugin.service.key': serviceKey,
|
|
1439
|
+
'dot.plugin.error.message': stringifyError(error),
|
|
1311
1440
|
});
|
|
1312
1441
|
}
|
|
1313
1442
|
}
|
|
@@ -1318,60 +1447,60 @@ export class DotAppImpl {
|
|
|
1318
1447
|
return failures;
|
|
1319
1448
|
}
|
|
1320
1449
|
|
|
1321
|
-
/** Run a single
|
|
1322
|
-
async #runDisposeHook(record:
|
|
1450
|
+
/** Run a single plugin's dispose hook with spans/events/diagnostics. */
|
|
1451
|
+
async #runDisposeHook(record: PluginRecord, pluginLogger: Logger, failures: DotLifecyclePluginFailure[]): Promise<void> {
|
|
1323
1452
|
record.hooks.add('dispose');
|
|
1324
1453
|
const ctx = this.#buildHookCtx(record, 'dispose', true);
|
|
1325
1454
|
const startedAt = performance.now();
|
|
1326
|
-
this.#emitHook('dispose', record.
|
|
1455
|
+
this.#emitHook('dispose', record.plugin.name, record.order, 'starting');
|
|
1327
1456
|
try {
|
|
1328
|
-
await this.#withHookBudget(record.
|
|
1329
|
-
|
|
1457
|
+
await this.#withHookBudget(record.plugin.name, 'dispose', () =>
|
|
1458
|
+
withPluginHookSpan(
|
|
1330
1459
|
{
|
|
1331
1460
|
appName: this.#appName,
|
|
1332
|
-
|
|
1333
|
-
|
|
1461
|
+
pluginName: record.plugin.name,
|
|
1462
|
+
pluginVersion: record.plugin.version,
|
|
1334
1463
|
hook: 'dispose',
|
|
1335
1464
|
order: record.order,
|
|
1336
|
-
logger:
|
|
1465
|
+
logger: pluginLogger,
|
|
1337
1466
|
},
|
|
1338
|
-
() => record.
|
|
1467
|
+
() => record.plugin.hooks.dispose!(ctx as never),
|
|
1339
1468
|
),
|
|
1340
1469
|
);
|
|
1341
1470
|
record.booted = false;
|
|
1342
1471
|
const durationMs = performance.now() - startedAt;
|
|
1343
1472
|
record.lifecycleDiagnostics.push({
|
|
1344
|
-
|
|
1473
|
+
plugin: record.plugin.name,
|
|
1345
1474
|
hook: 'dispose',
|
|
1346
1475
|
state: 'disposed',
|
|
1347
1476
|
order: record.order,
|
|
1348
1477
|
durationMs,
|
|
1349
1478
|
issues: [],
|
|
1350
1479
|
});
|
|
1351
|
-
this.#emitHook('dispose', record.
|
|
1352
|
-
|
|
1480
|
+
this.#emitHook('dispose', record.plugin.name, record.order, 'completed', { durationMs });
|
|
1481
|
+
pluginLogger.debug('dispose: done', { 'dot.plugin.duration.ms': durationMs });
|
|
1353
1482
|
} catch (error) {
|
|
1354
1483
|
const durationMs = performance.now() - startedAt;
|
|
1355
1484
|
const issue = makeIssue({
|
|
1356
1485
|
code: DotLifecycleErrorCode.DisposeFailed,
|
|
1357
|
-
|
|
1358
|
-
message: `dispose hook threw for
|
|
1359
|
-
remediation: `Fix the error in the dispose() hook of "${record.
|
|
1486
|
+
plugin: record.plugin.name,
|
|
1487
|
+
message: `dispose hook threw for plugin "${record.plugin.name}": ${stringifyError(error)}`,
|
|
1488
|
+
remediation: `Fix the error in the dispose() hook of "${record.plugin.name}". Dispose continues through individual failures and reports an aggregate error.`,
|
|
1360
1489
|
docsAnchor: 'dispose-failed',
|
|
1361
1490
|
});
|
|
1362
1491
|
record.issues.push(issue);
|
|
1363
1492
|
record.lifecycleDiagnostics.push({
|
|
1364
|
-
|
|
1493
|
+
plugin: record.plugin.name,
|
|
1365
1494
|
hook: 'dispose',
|
|
1366
1495
|
state: 'failed',
|
|
1367
1496
|
order: record.order,
|
|
1368
1497
|
durationMs,
|
|
1369
1498
|
issues: [issue],
|
|
1370
1499
|
});
|
|
1371
|
-
this.#emitHook('dispose', record.
|
|
1372
|
-
failures.push({
|
|
1373
|
-
|
|
1374
|
-
'dot.
|
|
1500
|
+
this.#emitHook('dispose', record.plugin.name, record.order, 'failed', { durationMs, error });
|
|
1501
|
+
failures.push({ plugin: record.plugin.name, phase: 'dispose', error });
|
|
1502
|
+
pluginLogger.error('dispose: failed (continuing)', {
|
|
1503
|
+
'dot.plugin.error.message': stringifyError(error),
|
|
1375
1504
|
});
|
|
1376
1505
|
}
|
|
1377
1506
|
}
|
|
@@ -1388,67 +1517,72 @@ export class DotAppImpl {
|
|
|
1388
1517
|
}
|
|
1389
1518
|
|
|
1390
1519
|
#buildManifest(): DotAppManifest {
|
|
1391
|
-
const
|
|
1392
|
-
const
|
|
1520
|
+
const plugins: PluginManifest[] = [];
|
|
1521
|
+
const actions: ActionManifest[] = [];
|
|
1393
1522
|
const services: ServiceManifest[] = [];
|
|
1394
1523
|
const lifecycle: LifecycleManifest[] = [];
|
|
1395
|
-
|
|
1396
|
-
//
|
|
1524
|
+
const projections: ProjectionManifest[] = [];
|
|
1525
|
+
// Edges are observed at boot time: consumer plugin → the plugin whose
|
|
1526
|
+
// published wire key satisfied its need. Before boot, the per-plugin
|
|
1397
1527
|
// `dependencies` array (wire-key strings) is the declarative view.
|
|
1398
1528
|
const dependencies: DependencyEdge[] = this.#wiringEdges.map(e => ({ ...e }));
|
|
1399
1529
|
|
|
1400
|
-
for (const
|
|
1401
|
-
const record = this.#records.get(
|
|
1402
|
-
const needWireKeys = Object.entries(
|
|
1403
|
-
|
|
1404
|
-
name:
|
|
1405
|
-
version:
|
|
1530
|
+
for (const plugin of this.#ordered) {
|
|
1531
|
+
const record = this.#records.get(plugin.name)!;
|
|
1532
|
+
const needWireKeys = Object.entries(plugin.needs).map(([alias, witness]) => wireKeyOf(witness, alias));
|
|
1533
|
+
plugins.push({
|
|
1534
|
+
name: plugin.name,
|
|
1535
|
+
version: plugin.version,
|
|
1406
1536
|
dependencies: needWireKeys,
|
|
1407
1537
|
provides: [...record.provides],
|
|
1408
1538
|
});
|
|
1409
|
-
|
|
1539
|
+
actions.push(...record.actions);
|
|
1410
1540
|
services.push(...record.services);
|
|
1541
|
+
projections.push(...record.projections);
|
|
1411
1542
|
lifecycle.push({
|
|
1412
|
-
|
|
1543
|
+
plugin: plugin.name,
|
|
1413
1544
|
hooks: [...record.hooks],
|
|
1414
1545
|
});
|
|
1415
1546
|
}
|
|
1416
1547
|
|
|
1417
1548
|
return {
|
|
1549
|
+
manifestVersion: 2,
|
|
1418
1550
|
app: {
|
|
1419
1551
|
name: this.#appName,
|
|
1420
1552
|
version: this.#appVersion,
|
|
1421
1553
|
},
|
|
1422
|
-
|
|
1423
|
-
|
|
1554
|
+
plugins,
|
|
1555
|
+
actions,
|
|
1424
1556
|
services,
|
|
1425
1557
|
lifecycle,
|
|
1426
1558
|
dependencies,
|
|
1559
|
+
projections,
|
|
1427
1560
|
};
|
|
1428
1561
|
}
|
|
1429
1562
|
|
|
1430
1563
|
#buildDiagnostics(): DotDiagnosticsSnapshot {
|
|
1431
|
-
const
|
|
1432
|
-
const
|
|
1564
|
+
const pluginDiagnostics: PluginDiagnostic[] = [];
|
|
1565
|
+
const actionDiagnostics: ActionDiagnostic[] = [];
|
|
1433
1566
|
const serviceDiagnostics: ServiceDiagnostic[] = [];
|
|
1434
1567
|
const lifecycleDiagnostics: LifecycleDiagnostic[] = [];
|
|
1435
1568
|
const issues: DiagnosticIssue[] = [];
|
|
1436
1569
|
|
|
1437
|
-
for (const
|
|
1438
|
-
const record = this.#records.get(
|
|
1439
|
-
// Per-
|
|
1570
|
+
for (const plugin of this.#ordered) {
|
|
1571
|
+
const record = this.#records.get(plugin.name)!;
|
|
1572
|
+
// Per-plugin status: failed if any issue with severity error exists, ok otherwise.
|
|
1440
1573
|
const hasError = record.issues.some(i => i.severity === 'error');
|
|
1441
|
-
const status:
|
|
1442
|
-
|
|
1443
|
-
|
|
1574
|
+
const status: PluginDiagnostic['status'] = hasError ? 'failed' : 'ok';
|
|
1575
|
+
pluginDiagnostics.push({
|
|
1576
|
+
plugin: plugin.name,
|
|
1444
1577
|
status,
|
|
1445
1578
|
issues: [...record.issues],
|
|
1446
1579
|
});
|
|
1447
1580
|
|
|
1448
|
-
for (const
|
|
1449
|
-
|
|
1450
|
-
id:
|
|
1451
|
-
|
|
1581
|
+
for (const action of record.actions) {
|
|
1582
|
+
actionDiagnostics.push({
|
|
1583
|
+
id: action.id,
|
|
1584
|
+
plugin: plugin.name,
|
|
1585
|
+
binding: action.binding,
|
|
1452
1586
|
status: hasError ? 'failed' : 'ok',
|
|
1453
1587
|
issues: [],
|
|
1454
1588
|
});
|
|
@@ -1456,7 +1590,7 @@ export class DotAppImpl {
|
|
|
1456
1590
|
for (const svc of record.services) {
|
|
1457
1591
|
serviceDiagnostics.push({
|
|
1458
1592
|
service: svc.name,
|
|
1459
|
-
|
|
1593
|
+
plugin: plugin.name,
|
|
1460
1594
|
status: hasError
|
|
1461
1595
|
? 'failed'
|
|
1462
1596
|
: record.booted || this.#state === 'defined' || this.#state === 'configured'
|
|
@@ -1470,13 +1604,14 @@ export class DotAppImpl {
|
|
|
1470
1604
|
}
|
|
1471
1605
|
|
|
1472
1606
|
return {
|
|
1607
|
+
snapshotVersion: 2,
|
|
1473
1608
|
generatedAt: new Date().toISOString(),
|
|
1474
1609
|
app: {
|
|
1475
1610
|
name: this.#appName,
|
|
1476
1611
|
state: this.#state,
|
|
1477
1612
|
},
|
|
1478
|
-
|
|
1479
|
-
|
|
1613
|
+
plugins: pluginDiagnostics,
|
|
1614
|
+
actions: actionDiagnostics,
|
|
1480
1615
|
services: serviceDiagnostics,
|
|
1481
1616
|
lifecycle: lifecycleDiagnostics,
|
|
1482
1617
|
issues,
|
|
@@ -1503,12 +1638,23 @@ function stringifyError(error: unknown): string {
|
|
|
1503
1638
|
}
|
|
1504
1639
|
}
|
|
1505
1640
|
|
|
1506
|
-
function reverseRecords(records: readonly
|
|
1641
|
+
function reverseRecords(records: readonly PluginRecord[]): readonly PluginRecord[] {
|
|
1507
1642
|
// eslint-disable-next-line unicorn/no-array-reverse -- lib target is ES2022, toReversed is ES2023.
|
|
1508
1643
|
return [...records].reverse();
|
|
1509
1644
|
}
|
|
1510
1645
|
|
|
1511
|
-
|
|
1646
|
+
function hasToDotAction(
|
|
1647
|
+
source: AnyPlugin['actions'][number],
|
|
1648
|
+
): source is { toDotAction(): Omit<ActionManifest, 'plugin'> } {
|
|
1649
|
+
return (
|
|
1650
|
+
typeof source === 'object' &&
|
|
1651
|
+
source !== null &&
|
|
1652
|
+
'toDotAction' in source &&
|
|
1653
|
+
typeof source.toDotAction === 'function'
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/** Re-export `ServiceKind` for the kernel's internal use. */
|
|
1512
1658
|
|
|
1513
|
-
export { type
|
|
1514
|
-
export { type
|
|
1659
|
+
export { type ServiceKind } from '../manifest.js';
|
|
1660
|
+
export { type Plugin } from '../plugin-contract.js';
|