@arki/dot 0.1.0 → 0.1.2
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 +36 -31
- package/dist/define-app.d.ts +58 -16
- package/dist/define-app.d.ts.map +1 -1
- package/dist/define-app.js +23 -13
- package/dist/define-app.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/kernel/app-instance.d.ts +3 -3
- package/dist/kernel/app-instance.d.ts.map +1 -1
- package/dist/kernel/app-instance.js +265 -159
- package/dist/kernel/app-instance.js.map +1 -1
- package/dist/lifecycle.d.ts +11 -9
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +15 -9
- package/dist/lifecycle.js.map +1 -1
- package/dist/pip-contract.d.ts +254 -149
- package/dist/pip-contract.d.ts.map +1 -1
- package/dist/pip-contract.js +185 -41
- package/dist/pip-contract.js.map +1 -1
- package/dist/pip.d.ts +9 -12
- package/dist/pip.d.ts.map +1 -1
- package/dist/pip.js +8 -11
- package/dist/pip.js.map +1 -1
- package/dist/test-harness.d.ts +13 -10
- package/dist/test-harness.d.ts.map +1 -1
- package/dist/test-harness.js +12 -12
- package/dist/test-harness.js.map +1 -1
- package/package.json +10 -4
- package/src/cli/discover.ts +223 -0
- package/src/cli/error-codes.ts +74 -0
- package/src/cli/files.ts +120 -0
- package/src/cli/index.ts +539 -0
- package/src/cli/json.ts +49 -0
- package/src/cli/new.ts +420 -0
- package/src/cli/observability-probe.ts +51 -0
- package/src/cli/render-doctor.ts +199 -0
- package/src/cli/render-explain.ts +161 -0
- package/src/define-app.ts +310 -0
- package/src/diagnostics.ts +91 -0
- package/src/index.ts +89 -0
- package/src/kernel/app-instance.ts +1341 -0
- package/src/kernel/otel.ts +265 -0
- package/src/lifecycle-observer.ts +100 -0
- package/src/lifecycle.ts +121 -0
- package/src/manifest.ts +94 -0
- package/src/pip-contract.ts +477 -0
- package/src/pip.ts +84 -0
- package/src/test-harness.ts +72 -0
- package/src/timeline.ts +137 -0
- package/templates/app-minimal/AGENTS.md.tmpl +2 -2
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenTelemetry instrumentation for the DOT kernel.
|
|
3
|
+
*
|
|
4
|
+
* DOT is OTel-first: every lifecycle phase and every per-pip hook
|
|
5
|
+
* automatically emits a span and a duration histogram. When no SDK is
|
|
6
|
+
* registered, the OTel API returns no-op implementations and the kernel
|
|
7
|
+
* pays zero allocation per call — the discipline that makes "OTel-first"
|
|
8
|
+
* compatible with principle 5 (zero optional dependencies in the kernel).
|
|
9
|
+
*
|
|
10
|
+
* Consumers wire the SDK however they like (`@opentelemetry/sdk-node`,
|
|
11
|
+
* `@arki/meter`, etc.); the kernel never touches the SDK directly.
|
|
12
|
+
*
|
|
13
|
+
* Span / attribute conventions:
|
|
14
|
+
*
|
|
15
|
+
* - Phase span: `dot.app.<phase>` — one root span per `configure()` /
|
|
16
|
+
* `boot()` / `start()` / `stop()` / `dispose()` call. Children
|
|
17
|
+
* auto-link via async-context.
|
|
18
|
+
* - Hook span: `dot.pip.<hook>` — one child span per pip's hook
|
|
19
|
+
* execution. The pip name lives on `dot.pip.name`, not in the span
|
|
20
|
+
* name, so backends can aggregate by hook across pips.
|
|
21
|
+
* - Attributes (`dot.app.name`, `dot.pip.name`, `dot.pip.version`,
|
|
22
|
+
* `dot.hook`, `dot.pip.order`) follow the OTel convention of
|
|
23
|
+
* namespacing under the library's prefix.
|
|
24
|
+
*
|
|
25
|
+
* @see packages/dot/docs/principles.md — principle 3 (deterministic) +
|
|
26
|
+
* principle 4 (agent-discoverable).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { metrics, SpanStatusCode, trace, type Span } from '@opentelemetry/api';
|
|
30
|
+
import type { Logger } from '@arki/log';
|
|
31
|
+
|
|
32
|
+
import type { DotLifecycleHook } from '../lifecycle.js';
|
|
33
|
+
|
|
34
|
+
const INSTRUMENTATION_NAME = '@arki/dot';
|
|
35
|
+
const INSTRUMENTATION_VERSION = '0.1.0';
|
|
36
|
+
|
|
37
|
+
/** OTel tracer for the DOT kernel. No-op when no SDK is registered. */
|
|
38
|
+
export const tracer = trace.getTracer(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION);
|
|
39
|
+
|
|
40
|
+
/** OTel meter for the DOT kernel. No-op when no SDK is registered. */
|
|
41
|
+
export const meter = metrics.getMeter(INSTRUMENTATION_NAME, INSTRUMENTATION_VERSION);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Histogram recording per-pip per-hook execution duration in milliseconds.
|
|
45
|
+
*
|
|
46
|
+
* Attributes:
|
|
47
|
+
* - `dot.app.name` — app name
|
|
48
|
+
* - `dot.pip.name` — pip name
|
|
49
|
+
* - `dot.hook` — which lifecycle hook (`configure` | `boot` | ...)
|
|
50
|
+
* - `dot.status` — `ok` | `failed`
|
|
51
|
+
*/
|
|
52
|
+
export const hookDurationHistogram = meter.createHistogram('dot.pip.hook.duration', {
|
|
53
|
+
description: 'Wall-clock duration of a single pip lifecycle hook execution.',
|
|
54
|
+
unit: 'ms',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Histogram recording per-phase (app-level) execution duration.
|
|
59
|
+
*
|
|
60
|
+
* Attributes:
|
|
61
|
+
* - `dot.app.name` — app name
|
|
62
|
+
* - `dot.app.phase` — which phase (`configure` | `boot` | `start` | `stop` | `dispose`)
|
|
63
|
+
* - `dot.status` — `ok` | `failed`
|
|
64
|
+
*/
|
|
65
|
+
export const phaseDurationHistogram = meter.createHistogram('dot.app.phase.duration', {
|
|
66
|
+
description: 'Wall-clock duration of a complete app-level lifecycle phase.',
|
|
67
|
+
unit: 'ms',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Sanitize attribute values — OTel rejects `undefined` and strips out
|
|
72
|
+
* keys whose values aren't primitives or arrays of primitives. We drop
|
|
73
|
+
* `undefined` here so call sites can pass optional fields without
|
|
74
|
+
* branching.
|
|
75
|
+
*/
|
|
76
|
+
function attrs(
|
|
77
|
+
source: Readonly<Record<string, string | number | boolean | undefined>>,
|
|
78
|
+
): Record<string, string | number | boolean> {
|
|
79
|
+
const out: Record<string, string | number | boolean> = {};
|
|
80
|
+
for (const [k, v] of Object.entries(source)) {
|
|
81
|
+
if (v !== undefined) out[k] = v;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Stringify any thrown value for span status / exception recording. */
|
|
87
|
+
function stringifyForSpan(value: unknown): string {
|
|
88
|
+
if (value instanceof Error) return value.message;
|
|
89
|
+
if (typeof value === 'string') return value;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.stringify(value);
|
|
92
|
+
} catch {
|
|
93
|
+
return String(value);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Wrap a single pip-hook execution in a child span and record its
|
|
99
|
+
* duration in the histogram. The span auto-links to the active phase
|
|
100
|
+
* span (set by {@link withPhaseSpan}) so traces show a clean parent-child
|
|
101
|
+
* hierarchy:
|
|
102
|
+
*
|
|
103
|
+
* dot.app.boot
|
|
104
|
+
* ├── dot.pip.boot (dot.pip.name=env, dot.hook=boot)
|
|
105
|
+
* ├── dot.pip.boot (dot.pip.name=db, dot.hook=boot)
|
|
106
|
+
* └── dot.pip.boot (dot.pip.name=kv, dot.hook=boot)
|
|
107
|
+
*
|
|
108
|
+
* Re-throws on failure after marking the span ERROR so the caller's
|
|
109
|
+
* existing error-handling path stays intact.
|
|
110
|
+
*/
|
|
111
|
+
export function withPipHookSpan<R>(
|
|
112
|
+
opts: {
|
|
113
|
+
readonly appName: string;
|
|
114
|
+
readonly pipName: string;
|
|
115
|
+
readonly pipVersion?: string;
|
|
116
|
+
readonly hook: DotLifecycleHook;
|
|
117
|
+
readonly order: number;
|
|
118
|
+
/**
|
|
119
|
+
* Optional pip-scoped logger. When present, the helper threads the
|
|
120
|
+
* span's `traceId` + `spanId` onto the logger via `setTraceContext`
|
|
121
|
+
* before invoking the body — every log line emitted from within the
|
|
122
|
+
* pip's hook is automatically correlated with this span in the
|
|
123
|
+
* `traceId`/`spanId` fields of `LogRecord`.
|
|
124
|
+
*/
|
|
125
|
+
readonly logger?: Logger;
|
|
126
|
+
},
|
|
127
|
+
fn: (span: Span) => R,
|
|
128
|
+
): R {
|
|
129
|
+
const span = tracer.startSpan(`dot.pip.${opts.hook}`, {
|
|
130
|
+
attributes: attrs({
|
|
131
|
+
'dot.app.name': opts.appName,
|
|
132
|
+
'dot.pip.name': opts.pipName,
|
|
133
|
+
'dot.pip.version': opts.pipVersion,
|
|
134
|
+
'dot.hook': opts.hook,
|
|
135
|
+
'dot.pip.order': opts.order,
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
if (opts.logger) {
|
|
139
|
+
const sc = span.spanContext();
|
|
140
|
+
opts.logger.setTraceContext(sc.traceId, sc.spanId);
|
|
141
|
+
}
|
|
142
|
+
const started = performance.now();
|
|
143
|
+
let status: 'ok' | 'failed' = 'ok';
|
|
144
|
+
const finish = (): void => {
|
|
145
|
+
const durationMs = performance.now() - started;
|
|
146
|
+
hookDurationHistogram.record(durationMs, {
|
|
147
|
+
'dot.app.name': opts.appName,
|
|
148
|
+
'dot.pip.name': opts.pipName,
|
|
149
|
+
'dot.hook': opts.hook,
|
|
150
|
+
'dot.status': status,
|
|
151
|
+
});
|
|
152
|
+
span.end();
|
|
153
|
+
};
|
|
154
|
+
try {
|
|
155
|
+
const result = fn(span);
|
|
156
|
+
if (result instanceof Promise) {
|
|
157
|
+
return result.then(
|
|
158
|
+
v => {
|
|
159
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
160
|
+
finish();
|
|
161
|
+
return v;
|
|
162
|
+
},
|
|
163
|
+
(e: unknown) => {
|
|
164
|
+
status = 'failed';
|
|
165
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: stringifyForSpan(e) });
|
|
166
|
+
if (e instanceof Error) span.recordException(e);
|
|
167
|
+
finish();
|
|
168
|
+
throw e;
|
|
169
|
+
},
|
|
170
|
+
) as R;
|
|
171
|
+
}
|
|
172
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
173
|
+
finish();
|
|
174
|
+
return result;
|
|
175
|
+
} catch (e) {
|
|
176
|
+
status = 'failed';
|
|
177
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: stringifyForSpan(e) });
|
|
178
|
+
if (e instanceof Error) span.recordException(e);
|
|
179
|
+
finish();
|
|
180
|
+
throw e;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Wrap an entire app-level phase (`configure`/`boot`/`start`/`stop`/`dispose`)
|
|
186
|
+
* in an active span. Children created by {@link withPipHookSpan} inherit
|
|
187
|
+
* this span as their parent via async-context.
|
|
188
|
+
*
|
|
189
|
+
* The callback's return value (sync or Promise) is propagated unchanged
|
|
190
|
+
* to preserve the kernel's existing control flow.
|
|
191
|
+
*/
|
|
192
|
+
export function withPhaseSpan<R>(
|
|
193
|
+
opts: {
|
|
194
|
+
readonly appName: string;
|
|
195
|
+
readonly appVersion?: string;
|
|
196
|
+
readonly phase: DotLifecycleHook;
|
|
197
|
+
readonly pipCount: number;
|
|
198
|
+
/**
|
|
199
|
+
* Optional phase-scoped logger. When present, the helper threads
|
|
200
|
+
* the span's `traceId` + `spanId` onto the logger before invoking
|
|
201
|
+
* the body — every log line written during the phase carries the
|
|
202
|
+
* trace context so it's groupable with the corresponding span in
|
|
203
|
+
* any OTel-compatible backend.
|
|
204
|
+
*/
|
|
205
|
+
readonly logger?: Logger;
|
|
206
|
+
},
|
|
207
|
+
fn: (span: Span) => R,
|
|
208
|
+
): R {
|
|
209
|
+
return tracer.startActiveSpan(
|
|
210
|
+
`dot.app.${opts.phase}`,
|
|
211
|
+
{
|
|
212
|
+
attributes: attrs({
|
|
213
|
+
'dot.app.name': opts.appName,
|
|
214
|
+
'dot.app.version': opts.appVersion,
|
|
215
|
+
'dot.app.phase': opts.phase,
|
|
216
|
+
'dot.app.pip.count': opts.pipCount,
|
|
217
|
+
}),
|
|
218
|
+
},
|
|
219
|
+
(span): R => {
|
|
220
|
+
if (opts.logger) {
|
|
221
|
+
const sc = span.spanContext();
|
|
222
|
+
opts.logger.setTraceContext(sc.traceId, sc.spanId);
|
|
223
|
+
}
|
|
224
|
+
const started = performance.now();
|
|
225
|
+
let status: 'ok' | 'failed' = 'ok';
|
|
226
|
+
const finish = (): void => {
|
|
227
|
+
const durationMs = performance.now() - started;
|
|
228
|
+
phaseDurationHistogram.record(durationMs, {
|
|
229
|
+
'dot.app.name': opts.appName,
|
|
230
|
+
'dot.app.phase': opts.phase,
|
|
231
|
+
'dot.status': status,
|
|
232
|
+
});
|
|
233
|
+
span.end();
|
|
234
|
+
};
|
|
235
|
+
try {
|
|
236
|
+
const result = fn(span);
|
|
237
|
+
if (result instanceof Promise) {
|
|
238
|
+
return result.then(
|
|
239
|
+
v => {
|
|
240
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
241
|
+
finish();
|
|
242
|
+
return v;
|
|
243
|
+
},
|
|
244
|
+
(e: unknown) => {
|
|
245
|
+
status = 'failed';
|
|
246
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: stringifyForSpan(e) });
|
|
247
|
+
if (e instanceof Error) span.recordException(e);
|
|
248
|
+
finish();
|
|
249
|
+
throw e;
|
|
250
|
+
},
|
|
251
|
+
) as R;
|
|
252
|
+
}
|
|
253
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
254
|
+
finish();
|
|
255
|
+
return result;
|
|
256
|
+
} catch (e) {
|
|
257
|
+
status = 'failed';
|
|
258
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: stringifyForSpan(e) });
|
|
259
|
+
if (e instanceof Error) span.recordException(e);
|
|
260
|
+
finish();
|
|
261
|
+
throw e;
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle observer surface for the DOT kernel.
|
|
3
|
+
*
|
|
4
|
+
* `DotLifecycleObserver` is the in-process companion to the OTel signals
|
|
5
|
+
* emitted by {@link withPhaseSpan} / {@link withPipHookSpan}. Where OTel
|
|
6
|
+
* is the contract for cross-process tracing (consumers register an SDK
|
|
7
|
+
* and ship spans to a backend), the observer is the contract for *local*
|
|
8
|
+
* programmatic inspection — used by tests, CLI tooling, ASCII waterfalls,
|
|
9
|
+
* and ad-hoc diagnostics that don't require an SDK to be registered.
|
|
10
|
+
*
|
|
11
|
+
* Both signals are emitted from the same kernel sites; neither is layered
|
|
12
|
+
* under the other. A consumer can use one, both, or neither — the kernel
|
|
13
|
+
* pays zero allocation for the observer fan-out when no observers are
|
|
14
|
+
* registered.
|
|
15
|
+
*
|
|
16
|
+
* @see packages/dot/docs/observability.md — consumer-facing surface
|
|
17
|
+
* @see packages/dot/docs/principles.md — principle 5 (zero optional deps)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { DotLifecycleHook } from './lifecycle.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The single fan-out event type emitted by the kernel to every registered
|
|
24
|
+
* observer. Discriminated by `kind`:
|
|
25
|
+
*
|
|
26
|
+
* - `'phase'` — boundary of a top-level lifecycle phase
|
|
27
|
+
* - `'pip-hook'` — boundary of a single pip's hook execution
|
|
28
|
+
*
|
|
29
|
+
* Status discriminates the boundary itself:
|
|
30
|
+
*
|
|
31
|
+
* - `'starting'` — emitted *before* the work begins
|
|
32
|
+
* - `'completed'` — emitted *after* the work finished successfully
|
|
33
|
+
* - `'failed'` — emitted *after* the work threw or rejected
|
|
34
|
+
*
|
|
35
|
+
* `starting` events never carry `durationMs` / `error`. `completed` events
|
|
36
|
+
* carry `durationMs`. `failed` events carry both `durationMs` and `error`.
|
|
37
|
+
*/
|
|
38
|
+
export type DotLifecycleEvent = DotPhaseLifecycleEvent | DotPipHookLifecycleEvent;
|
|
39
|
+
|
|
40
|
+
/** Possible event statuses. See {@link DotLifecycleEvent}. */
|
|
41
|
+
export type DotLifecycleEventStatus = 'starting' | 'completed' | 'failed';
|
|
42
|
+
|
|
43
|
+
/** Boundary of a top-level lifecycle phase. */
|
|
44
|
+
export type DotPhaseLifecycleEvent = {
|
|
45
|
+
readonly kind: 'phase';
|
|
46
|
+
/** The phase the event belongs to. */
|
|
47
|
+
readonly phase: DotLifecycleHook;
|
|
48
|
+
readonly status: DotLifecycleEventStatus;
|
|
49
|
+
/** App name (so multiplexed observers can disambiguate). */
|
|
50
|
+
readonly appName: string;
|
|
51
|
+
/** Wall-clock duration in milliseconds. Present on `completed` / `failed`. */
|
|
52
|
+
readonly durationMs?: number;
|
|
53
|
+
/** The error value, if `status === 'failed'`. */
|
|
54
|
+
readonly error?: unknown;
|
|
55
|
+
/** Wall-clock timestamp in ms-since-epoch (`Date.now()` semantics). */
|
|
56
|
+
readonly timestamp: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Boundary of a single pip's hook execution. */
|
|
60
|
+
export type DotPipHookLifecycleEvent = {
|
|
61
|
+
readonly kind: 'pip-hook';
|
|
62
|
+
/** The phase the hook belongs to. */
|
|
63
|
+
readonly phase: DotLifecycleHook;
|
|
64
|
+
/** The pip whose hook is being executed. */
|
|
65
|
+
readonly pip: string;
|
|
66
|
+
/** Topological order of the pip within the phase (0-based). */
|
|
67
|
+
readonly order: number;
|
|
68
|
+
readonly status: DotLifecycleEventStatus;
|
|
69
|
+
readonly appName: string;
|
|
70
|
+
readonly durationMs?: number;
|
|
71
|
+
readonly error?: unknown;
|
|
72
|
+
readonly timestamp: number;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Observer function signature. Synchronous by contract — observers MUST
|
|
77
|
+
* NOT block the lifecycle. If a long-running side effect is needed
|
|
78
|
+
* (e.g. shipping events to a remote sink), the observer should buffer
|
|
79
|
+
* the event and process it on a separate cadence.
|
|
80
|
+
*
|
|
81
|
+
* Exceptions thrown by an observer are caught and dropped on the kernel
|
|
82
|
+
* floor (with a DEBUG log on `arki:dot:lifecycle`). One observer's
|
|
83
|
+
* misbehaviour can never cause another observer to miss an event, nor
|
|
84
|
+
* can it break the lifecycle.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* import { defineApp } from '@arki/dot';
|
|
89
|
+
*
|
|
90
|
+
* const events: DotLifecycleEvent[] = [];
|
|
91
|
+
* const app = await defineApp('my-app', {
|
|
92
|
+
* observers: [(event) => events.push(event)],
|
|
93
|
+
* })
|
|
94
|
+
* .use(myPip)
|
|
95
|
+
* .boot();
|
|
96
|
+
*
|
|
97
|
+
* // events now contains the full configure + boot event stream.
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export type DotLifecycleObserver = (event: DotLifecycleEvent) => void;
|
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle primitives for the DOT kernel.
|
|
3
|
+
*
|
|
4
|
+
* The kernel uses a 5-hook lifecycle that runs in declaration order
|
|
5
|
+
* (providers are `.use()`d before their consumers — the app builder's
|
|
6
|
+
* type-level guard enforces this at compile time):
|
|
7
|
+
*
|
|
8
|
+
* - `configure` — synchronous registration of metadata, routes, services
|
|
9
|
+
* - `boot` — async open of resources, publishes services into `app.services`
|
|
10
|
+
* - `start` — async start of active work (workers, schedulers, listeners)
|
|
11
|
+
* - `stop` — async stop of active work, runs in reverse declaration order
|
|
12
|
+
* - `dispose` — async release of booted resources, runs in reverse declaration order
|
|
13
|
+
*
|
|
14
|
+
* Hook semantics, failure ordering, and idempotency rules are documented on the
|
|
15
|
+
* public `DotApp` interface in `./define-app.ts`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Identifier of a single hook in the DOT lifecycle. */
|
|
19
|
+
export type DotLifecycleHook = 'configure' | 'boot' | 'start' | 'stop' | 'dispose';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The complete set of lifecycle hooks in execution order.
|
|
23
|
+
* `stop` and `dispose` run in reverse declaration order across pips, but the
|
|
24
|
+
* sequence of hooks themselves is always `configure -> boot -> start -> stop -> dispose`.
|
|
25
|
+
*/
|
|
26
|
+
export const DOT_LIFECYCLE_HOOKS: readonly DotLifecycleHook[] = [
|
|
27
|
+
'configure',
|
|
28
|
+
'boot',
|
|
29
|
+
'start',
|
|
30
|
+
'stop',
|
|
31
|
+
'dispose',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Macro-states the DotApp can occupy.
|
|
36
|
+
*
|
|
37
|
+
* State transitions:
|
|
38
|
+
*
|
|
39
|
+
* defined -> configured -> booted -> started -> stopped -> disposed
|
|
40
|
+
* |
|
|
41
|
+
* +-> disposed (skip stopped if never started)
|
|
42
|
+
*
|
|
43
|
+
* any-of(configure|boot|start) failure -> failed
|
|
44
|
+
*
|
|
45
|
+
* `failed` and `disposed` are terminal — callers must create a new instance.
|
|
46
|
+
*/
|
|
47
|
+
export type DotLifecycleState = 'defined' | 'configured' | 'booted' | 'started' | 'stopped' | 'disposed' | 'failed';
|
|
48
|
+
|
|
49
|
+
/** Stable error codes for lifecycle failures. */
|
|
50
|
+
export const DotLifecycleErrorCode = {
|
|
51
|
+
/** A `configure` hook attempted async work (returned a Promise). */
|
|
52
|
+
ConfigureAsync: 'DOT_LIFECYCLE_E001',
|
|
53
|
+
/** A `configure` hook threw. */
|
|
54
|
+
ConfigureFailed: 'DOT_LIFECYCLE_E002',
|
|
55
|
+
/** A `boot` hook threw. */
|
|
56
|
+
BootFailed: 'DOT_LIFECYCLE_E003',
|
|
57
|
+
/** A `start` hook threw. */
|
|
58
|
+
StartFailed: 'DOT_LIFECYCLE_E004',
|
|
59
|
+
/** One or more `stop` hooks threw — aggregate. */
|
|
60
|
+
StopFailed: 'DOT_LIFECYCLE_E005',
|
|
61
|
+
/** One or more `dispose` hooks threw — aggregate. */
|
|
62
|
+
DisposeFailed: 'DOT_LIFECYCLE_E006',
|
|
63
|
+
/** Caller tried to reuse the app after dispose. */
|
|
64
|
+
ReuseAfterDispose: 'DOT_LIFECYCLE_E007',
|
|
65
|
+
/** Caller tried to reuse the app after a failed lifecycle. */
|
|
66
|
+
ReuseAfterFailure: 'DOT_LIFECYCLE_E008',
|
|
67
|
+
// E009 (DependencyCycle) and E010 (MissingDependency) are RETIRED with the
|
|
68
|
+
// v2 wiring model — declaration order is boot order, so cycles and
|
|
69
|
+
// name-based dependency declarations no longer exist. Codes are never
|
|
70
|
+
// reused for new meanings.
|
|
71
|
+
/** Pip registered twice. */
|
|
72
|
+
DuplicatePip: 'DOT_LIFECYCLE_E011',
|
|
73
|
+
/** A pip's `needs` entry has no provider among earlier-booted pips. */
|
|
74
|
+
UnsatisfiedNeed: 'DOT_LIFECYCLE_E012',
|
|
75
|
+
/** A pip published a wire key that an earlier pip already provides. */
|
|
76
|
+
ServiceCollision: 'DOT_LIFECYCLE_E013',
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
export type DotLifecycleErrorCodeValue = (typeof DotLifecycleErrorCode)[keyof typeof DotLifecycleErrorCode];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Structured error thrown for any lifecycle failure or misuse.
|
|
83
|
+
*
|
|
84
|
+
* Carries:
|
|
85
|
+
* - `code` — stable machine-readable error code (see {@link DotLifecycleErrorCode}).
|
|
86
|
+
* - `phase` — which hook (or pseudo-hook) failed.
|
|
87
|
+
* - `pip` — which pip name, when applicable.
|
|
88
|
+
* - `cause` — original error if wrapped from a hook throw.
|
|
89
|
+
* - `failures` — for aggregate errors (stop/dispose), the per-pip failures.
|
|
90
|
+
*/
|
|
91
|
+
export class DotLifecycleError extends Error {
|
|
92
|
+
readonly code: DotLifecycleErrorCodeValue;
|
|
93
|
+
readonly phase: DotLifecycleHook;
|
|
94
|
+
readonly pip?: string;
|
|
95
|
+
override readonly cause?: unknown;
|
|
96
|
+
readonly failures?: readonly DotLifecyclePipFailure[];
|
|
97
|
+
|
|
98
|
+
constructor(args: {
|
|
99
|
+
code: DotLifecycleErrorCodeValue;
|
|
100
|
+
phase: DotLifecycleHook;
|
|
101
|
+
message: string;
|
|
102
|
+
pip?: string;
|
|
103
|
+
cause?: unknown;
|
|
104
|
+
failures?: readonly DotLifecyclePipFailure[];
|
|
105
|
+
}) {
|
|
106
|
+
super(args.message);
|
|
107
|
+
this.name = 'DotLifecycleError';
|
|
108
|
+
this.code = args.code;
|
|
109
|
+
this.phase = args.phase;
|
|
110
|
+
this.pip = args.pip;
|
|
111
|
+
this.cause = args.cause;
|
|
112
|
+
this.failures = args.failures;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Single pip failure inside an aggregate lifecycle error (stop/dispose). */
|
|
117
|
+
export type DotLifecyclePipFailure = {
|
|
118
|
+
pip: string;
|
|
119
|
+
phase: DotLifecycleHook;
|
|
120
|
+
error: unknown;
|
|
121
|
+
};
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest types for the DOT kernel.
|
|
3
|
+
*
|
|
4
|
+
* A `DotAppManifest` is the static, declarative description of an app: which
|
|
5
|
+
* pips are registered, what routes/services they contribute, and how they
|
|
6
|
+
* depend on each other. It is built up during the `configure` phase from
|
|
7
|
+
* registration calls and finalised once `configure` completes.
|
|
8
|
+
*
|
|
9
|
+
* CONTRACT: `DotAppManifest` always exposes the same five top-level arrays
|
|
10
|
+
* (`pips`, `routes`, `services`, `lifecycle`, `dependencies`). Consumers
|
|
11
|
+
* MUST NOT see an omitted array — empty is empty, but never missing.
|
|
12
|
+
* This shape is referenced by Task 7's scorecard.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { DotLifecycleHook } from './lifecycle.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Kind of service that a pip can publish.
|
|
19
|
+
*
|
|
20
|
+
* Well-known kinds map 1:1 to the canonical `@arki/*` adapters. `custom`
|
|
21
|
+
* is the escape hatch for pip-defined service kinds — use it whenever a
|
|
22
|
+
* pip publishes something that does not fit a reserved shape.
|
|
23
|
+
*/
|
|
24
|
+
export type ServiceKind =
|
|
25
|
+
| 'env'
|
|
26
|
+
| 'db'
|
|
27
|
+
| 'kv'
|
|
28
|
+
| 'queue'
|
|
29
|
+
| 'auth'
|
|
30
|
+
| 'email'
|
|
31
|
+
| 'logger'
|
|
32
|
+
| 'event-store'
|
|
33
|
+
| 'message-bus'
|
|
34
|
+
| 'custom';
|
|
35
|
+
|
|
36
|
+
/** Transport that a route is exposed under. */
|
|
37
|
+
export type RouteTransport = 'http' | 'orpc' | 'trpc' | 'rpc' | 'custom';
|
|
38
|
+
|
|
39
|
+
/** Kind of dependency edge between two pips. */
|
|
40
|
+
export type DependencyEdgeKind = 'requires' | 'provides' | 'uses';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Top-level manifest describing the static shape of a DOT app.
|
|
44
|
+
*
|
|
45
|
+
* Always carries the five arrays — never omits any of them.
|
|
46
|
+
*/
|
|
47
|
+
export type DotAppManifest = {
|
|
48
|
+
app: {
|
|
49
|
+
name: string;
|
|
50
|
+
version?: string;
|
|
51
|
+
};
|
|
52
|
+
pips: PipManifest[];
|
|
53
|
+
routes: RouteManifest[];
|
|
54
|
+
services: ServiceManifest[];
|
|
55
|
+
lifecycle: LifecycleManifest[];
|
|
56
|
+
dependencies: DependencyEdge[];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Single pip's declarative metadata. */
|
|
60
|
+
export type PipManifest = {
|
|
61
|
+
name: string;
|
|
62
|
+
version?: string;
|
|
63
|
+
dependencies: readonly string[];
|
|
64
|
+
provides: readonly string[];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Single route exposed by a pip. */
|
|
68
|
+
export type RouteManifest = {
|
|
69
|
+
id: string;
|
|
70
|
+
pip: string;
|
|
71
|
+
method?: string;
|
|
72
|
+
path?: string;
|
|
73
|
+
transport: RouteTransport;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Single service published by a pip. */
|
|
77
|
+
export type ServiceManifest = {
|
|
78
|
+
name: string;
|
|
79
|
+
pip: string;
|
|
80
|
+
kind: ServiceKind;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Which lifecycle hooks a pip participates in. */
|
|
84
|
+
export type LifecycleManifest = {
|
|
85
|
+
pip: string;
|
|
86
|
+
hooks: readonly DotLifecycleHook[];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Directed edge in the pip dependency graph. */
|
|
90
|
+
export type DependencyEdge = {
|
|
91
|
+
from: string;
|
|
92
|
+
to: string;
|
|
93
|
+
kind: DependencyEdgeKind;
|
|
94
|
+
};
|