@arki/dot 0.1.1 → 0.1.3

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.
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Public pip contract for the DOT kernel (v2).
3
+ *
4
+ * A pip declares what it **needs** as a shape of type witnesses and
5
+ * publishes what it **provides** from its `boot` hook. The kernel wires
6
+ * services by key, injects them into hook contexts under the pip's local
7
+ * aliases, and fails loudly (coded errors) on unsatisfied needs or key
8
+ * collisions.
9
+ *
10
+ * Design constraints:
11
+ *
12
+ * - `configure` is SYNC. Returning a Promise is an error — the kernel
13
+ * throws {@link DotLifecycleError} with code `DOT_LIFECYCLE_E001`.
14
+ * - Declaration order IS boot order. The app builder's type-level guard
15
+ * makes out-of-order composition a compile error; the kernel re-validates
16
+ * at runtime for erased/dynamic composition.
17
+ * - `stop` and `dispose` run in reverse declaration order and continue
18
+ * through individual pip failures, reporting an aggregate error.
19
+ */
20
+
21
+ import type { RouteTransport, ServiceKind } from './manifest.js';
22
+
23
+ declare const TypeOf: unique symbol;
24
+
25
+ /**
26
+ * Phantom type witness for a service. Carries `T` at the type level only —
27
+ * the runtime value is an empty object.
28
+ */
29
+ export type Service<T> = { readonly [TypeOf]?: T };
30
+
31
+ declare const LazyInner: unique symbol;
32
+
33
+ /**
34
+ * A lazy-lifting witness (create via {@link service.lazy}). The consumer
35
+ * always receives a `Lazy<T>` handle, regardless of whether the provider
36
+ * published a plain `T` (the kernel lifts it into a pre-initialized
37
+ * handle) or a `Lazy<T>` (passed through). This decouples consumers from
38
+ * the provider's eager-vs-lazy strategy.
39
+ *
40
+ * The phantom `Service<T | Lazy<T>>` base is what the wiring guard checks
41
+ * against — either provider shape satisfies it structurally.
42
+ */
43
+ export type LazyService<T> = Service<T | Lazy<T>> & {
44
+ readonly lazy: true;
45
+ readonly [LazyInner]?: T;
46
+ };
47
+
48
+ /**
49
+ * Create an app-local (anonymous) service witness for a `needs` shape.
50
+ *
51
+ * `service.lazy<T>()` creates a lifting witness instead — the hook context
52
+ * delivers a `Lazy<T>` handle whether the provider is eager or lazy.
53
+ */
54
+ export const service: (<T>() => Service<T>) & { readonly lazy: <T>() => LazyService<T> } = Object.assign(
55
+ <T,>(): Service<T> => ({}),
56
+ { lazy: <T,>(): LazyService<T> => ({ lazy: true }) },
57
+ );
58
+
59
+ /** Internal (kernel) check: is this witness a lazy-lifting one? */
60
+ export function isLazyWitness(witness: object): boolean {
61
+ return 'lazy' in witness && (witness as { lazy?: unknown }).lazy === true;
62
+ }
63
+
64
+ /**
65
+ * Shared, named witness — a cross-package service contract. The `key` is
66
+ * the wire name used for satisfaction checks and runtime lookup; the local
67
+ * property name in a `needs` shape becomes a free-choice alias.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * // packages/db owns the contract:
72
+ * export const Db = token<NodePgDatabase<Schema>>()('arki.db');
73
+ *
74
+ * // any consumer, any local alias:
75
+ * const reports = pip({
76
+ * name: 'reports',
77
+ * needs: { db: Db },
78
+ * async boot({ db }) { ... },
79
+ * });
80
+ * ```
81
+ */
82
+ export type Token<T, K extends string = string> = Service<T> & {
83
+ readonly key: K;
84
+ /** Derive a token for an additional instance of the same contract. */
85
+ instance<N extends string>(name: N): Token<T, `${K}#${N}`>;
86
+ };
87
+
88
+ /**
89
+ * Create a token. Curried so `T` is explicit while `K` is inferred as a
90
+ * literal: `const Db = token<DbHandle>()('arki.db')`.
91
+ */
92
+ export function token<T>(): <K extends string>(key: K) => Token<T, K> {
93
+ return <K extends string>(key: K): Token<T, K> => ({
94
+ key,
95
+ instance<N extends string>(name: N): Token<T, `${K}#${N}`> {
96
+ // Safe by construction: the runtime string is exactly the template type.
97
+ return token<T>()(`${key}#${name}` as `${K}#${N}`);
98
+ },
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Publish a value under a token's wire key. Returns a record typed by the
104
+ * token's literal key, so `boot: () => provide(Db, handle)` infers
105
+ * `TProvides = { 'arki.db': DbHandle }`.
106
+ */
107
+ export function provide<T, K extends string>(tok: Token<T, K>, value: T): { [P in K]: T } {
108
+ // Safe by construction: the computed key is exactly `tok.key: K`.
109
+ return { [tok.key]: value } as { [P in K]: T };
110
+ }
111
+
112
+ /** Record of wire-keyed services. */
113
+ export type ServiceRecord = Record<string, unknown>;
114
+
115
+ /** Runtime brand for lazy service handles (kernel detects these for auto-dispose). */
116
+ export const LazyTag: unique symbol = Symbol('dot.lazy');
117
+
118
+ /**
119
+ * A lazily-initialized service handle. Publish one from `boot` to defer an
120
+ * expensive open until first use:
121
+ *
122
+ * ```ts
123
+ * boot: () => ({ db: lazy(() => openDb(), { dispose: db => db.close() }) })
124
+ * ```
125
+ *
126
+ * Laziness is visible in the type — consumers declare
127
+ * `needs: { db: service<Lazy<Db>>() }` and call `await db.get()`. The
128
+ * kernel auto-disposes initialized handles in reverse declaration order
129
+ * during `dispose` (and boot rollback), even when the publishing pip has
130
+ * no `dispose` hook. A handle that was never `get()`ed never initializes
131
+ * and never runs cleanup.
132
+ */
133
+ export type Lazy<T> = {
134
+ readonly [LazyTag]: true;
135
+ /**
136
+ * Initialize (once) and return the value. Concurrent callers share one
137
+ * attempt. A failed attempt is NOT cached — the next call retries.
138
+ * Throws if the handle has been disposed.
139
+ */
140
+ get(): Promise<T>;
141
+ /** True once an initialization attempt has succeeded. */
142
+ readonly initialized: boolean;
143
+ /** The value if initialized, else `undefined`. Never triggers initialization. */
144
+ peek(): T | undefined;
145
+ /**
146
+ * Run cleanup if initialized (awaits an in-flight initialization first).
147
+ * Idempotent. Called automatically by the kernel for published handles.
148
+ */
149
+ dispose(): Promise<void>;
150
+ };
151
+
152
+ type LazyState<T> =
153
+ | { readonly status: 'idle' }
154
+ | { readonly status: 'pending'; readonly promise: Promise<T> }
155
+ | { readonly status: 'ready'; readonly value: T }
156
+ | { readonly status: 'disposed' };
157
+
158
+ /**
159
+ * Create a lazy service handle. See {@link Lazy} for semantics.
160
+ *
161
+ * @param init - Opens the resource. Runs at most once concurrently; a
162
+ * rejected attempt is not cached, so a later `get()` retries.
163
+ * @param options.dispose - Cleanup for the initialized value. Skipped when
164
+ * the handle was never initialized.
165
+ */
166
+ export function lazy<T>(
167
+ init: () => Promise<T> | T,
168
+ options: { readonly dispose?: (value: T) => Promise<void> | void } = {},
169
+ ): Lazy<T> {
170
+ let state: LazyState<T> = { status: 'idle' };
171
+
172
+ return {
173
+ [LazyTag]: true,
174
+ get(): Promise<T> {
175
+ if (state.status === 'ready') return Promise.resolve(state.value);
176
+ if (state.status === 'pending') return state.promise;
177
+ if (state.status === 'disposed') {
178
+ return Promise.reject(new Error('Lazy service handle is disposed — the app has shut down.'));
179
+ }
180
+ const promise = Promise.resolve()
181
+ .then(init)
182
+ .then(
183
+ value => {
184
+ state = { status: 'ready', value };
185
+ return value;
186
+ },
187
+ (error: unknown) => {
188
+ // Failed initialization is not cached — allow retry.
189
+ state = { status: 'idle' };
190
+ throw error;
191
+ },
192
+ );
193
+ state = { status: 'pending', promise };
194
+ return promise;
195
+ },
196
+ get initialized(): boolean {
197
+ return state.status === 'ready';
198
+ },
199
+ peek(): T | undefined {
200
+ return state.status === 'ready' ? state.value : undefined;
201
+ },
202
+ async dispose(): Promise<void> {
203
+ if (state.status === 'pending') {
204
+ try {
205
+ await state.promise;
206
+ } catch {
207
+ // Failed init: nothing to clean up.
208
+ }
209
+ }
210
+ if (state.status === 'ready') {
211
+ const { value } = state;
212
+ state = { status: 'disposed' };
213
+ await options.dispose?.(value);
214
+ return;
215
+ }
216
+ state = { status: 'disposed' };
217
+ },
218
+ };
219
+ }
220
+
221
+ /** Type guard: is this published value a lazy service handle? */
222
+ export function isLazy(value: unknown): value is Lazy<unknown> {
223
+ return typeof value === 'object' && value !== null && LazyTag in value;
224
+ }
225
+
226
+ /**
227
+ * Wrap an already-available value in a pre-initialized `Lazy<T>` handle.
228
+ * Used by the kernel to lift eager provides for `service.lazy` consumers;
229
+ * also handy for handing fakes to lazy-consuming pips in tests. The handle
230
+ * has no cleanup of its own — the underlying value's lifecycle belongs to
231
+ * whoever created it.
232
+ */
233
+ export function lazyOf<T>(value: T): Lazy<T> {
234
+ let disposed = false;
235
+ return {
236
+ [LazyTag]: true,
237
+ get: () =>
238
+ disposed
239
+ ? Promise.reject(new Error('Lazy service handle is disposed — the app has shut down.'))
240
+ : Promise.resolve(value),
241
+ get initialized(): boolean {
242
+ return !disposed;
243
+ },
244
+ peek: () => (disposed ? undefined : value),
245
+ async dispose(): Promise<void> {
246
+ disposed = true;
247
+ },
248
+ };
249
+ }
250
+
251
+ /** A `needs` declaration: local alias → witness (anonymous or token). */
252
+ export type NeedsShape = Record<string, Service<unknown>>;
253
+
254
+ // `{}` is the correct identity for intersection-accumulation in the app
255
+ // builder and the correct "no needs / no provides" default.
256
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
257
+ export type EmptyShape = {};
258
+
259
+ /**
260
+ * Local-alias view of a needs shape — what lifecycle hooks destructure.
261
+ * `{ cache: Token<KV, 'arki.kv'> }` → `{ cache: KV }`.
262
+ * `{ search: service.lazy<S>() }` → `{ search: Lazy<S> }`.
263
+ */
264
+ export type CtxOf<S extends NeedsShape> = {
265
+ [K in keyof S]: S[K] extends LazyService<infer T> ? Lazy<T> : S[K] extends Service<infer T> ? T : never;
266
+ };
267
+
268
+ /**
269
+ * Wire-key view of a needs shape — what the app builder checks
270
+ * satisfaction against. Tokens contribute their owned key; anonymous
271
+ * witnesses contribute the property name.
272
+ * `{ cache: Token<KV, 'arki.kv'> }` → `{ 'arki.kv': KV }`.
273
+ */
274
+ export type WireNeeds<S extends NeedsShape> = {
275
+ [K in keyof S as S[K] extends Token<unknown, infer WK> ? WK : K]: S[K] extends Service<infer T> ? T : never;
276
+ };
277
+
278
+ /**
279
+ * No `$`-prefixed keys — that prefix is the kernel context namespace
280
+ * ({@link KernelCtx}). Used as a constraint on needs shapes and provides
281
+ * records: a matching key makes the property type `never`, which no
282
+ * witness or service value satisfies, so the violation errors at the
283
+ * exact offending property. The kernel re-validates at runtime with
284
+ * `DOT_LIFECYCLE_E014` for paths the constraint cannot see (renames,
285
+ * erased pips).
286
+ */
287
+ export type NoReservedKeys = Readonly<Record<`$${string}`, never>>;
288
+
289
+ /**
290
+ * Kernel-supplied context keys, present in every service-carrying hook
291
+ * context. The `$` prefix is a reserved namespace: `pip()` rejects
292
+ * `$`-prefixed needs aliases and publish keys at compile time (via
293
+ * {@link NoReservedKeys}) and the kernel enforces it at runtime
294
+ * (`DOT_LIFECYCLE_E014`), so these keys can never be shadowed.
295
+ */
296
+ export type KernelCtx = {
297
+ /** App name (passed to `defineApp`). */
298
+ readonly $app: string;
299
+ /** This pip's name. */
300
+ readonly $pip: string;
301
+ /** Read-only runtime config bag from `defineApp(name, { config })`. */
302
+ readonly $config: Readonly<Record<string, unknown>>;
303
+ };
304
+
305
+ /** Context provided to a `configure` hook (sync registration only). */
306
+ export type DotConfigureContext = {
307
+ pipName: string;
308
+ /** App name. */
309
+ appName: string;
310
+ /**
311
+ * Register a service this pip publishes, for manifest/diagnostics
312
+ * purposes. Registration is metadata-only — the actual service instance
313
+ * is returned from the `boot` hook.
314
+ */
315
+ registerService(name: string, kind: ServiceKind): void;
316
+ /** Register a route this pip exposes. */
317
+ registerRoute(route: { id: string; method?: string; path?: string; transport: RouteTransport }): void;
318
+ /** Mark the pip as participating in a lifecycle hook. */
319
+ registerLifecycleHook(hook: 'configure' | 'boot' | 'start' | 'stop' | 'dispose'): void;
320
+ /** Append `provides` capability strings (informational, manifest-only). */
321
+ declareProvides(...capabilities: string[]): void;
322
+ };
323
+
324
+ type MaybePromise<T> = T | Promise<T>;
325
+
326
+ declare const NeedsSym: unique symbol;
327
+ declare const ProvidesSym: unique symbol;
328
+
329
+ /**
330
+ * The DOT pip (v2). Author through {@link pip} — never construct directly.
331
+ *
332
+ * Hook signatures are type-erased here (`ctx: never`): the typed view
333
+ * lives on `pip()`'s parameter, and `(ctx: Typed) => R` is assignable to
334
+ * `(ctx: never) => R` without casts (parameter contravariance from the
335
+ * bottom type). The kernel crosses the erasure boundary at the call site.
336
+ *
337
+ * The phantom symbol properties carry `TNeeds` / `TProvides` for the app
338
+ * builder's compile-time wiring check; they never exist at runtime.
339
+ */
340
+ export type Pip<
341
+ TNeeds extends ServiceRecord = ServiceRecord,
342
+ TProvides extends ServiceRecord = ServiceRecord,
343
+ > = {
344
+ /** Unique identifier for this pip within the app. */
345
+ readonly name: string;
346
+ /** Optional semantic version string. */
347
+ readonly version?: string;
348
+ /** Runtime needs shape (local alias → witness). */
349
+ readonly needs: NeedsShape;
350
+ /** Mount-time renames: publish key → new wire key (see {@link rename}). */
351
+ readonly renames: Readonly<Record<string, string>>;
352
+ readonly hooks: {
353
+ readonly configure?: (ctx: DotConfigureContext) => void;
354
+ readonly boot?: (ctx: never) => MaybePromise<ServiceRecord | void>;
355
+ readonly start?: (ctx: never) => MaybePromise<void>;
356
+ readonly stop?: (ctx: never) => MaybePromise<void>;
357
+ readonly dispose?: (ctx: never) => MaybePromise<void>;
358
+ };
359
+ readonly [NeedsSym]?: TNeeds;
360
+ readonly [ProvidesSym]?: TProvides;
361
+ };
362
+
363
+ /** Extract a pip's (wire-keyed) needs record. */
364
+ export type PipNeeds<P> = P extends Pip<infer N, ServiceRecord> ? N : never;
365
+ /** Extract a pip's (wire-keyed) provides record. */
366
+ export type PipProvides<P> = P extends Pip<ServiceRecord, infer Pr> ? Pr : never;
367
+
368
+ /** Internal type alias used by the kernel to erase pip service generics. */
369
+ export type AnyPip = Pip<ServiceRecord, ServiceRecord>;
370
+
371
+ /**
372
+ * Author a DOT pip.
373
+ *
374
+ * - `TShape` is inferred from the `needs` object literal.
375
+ * - `TProvides` is inferred from `boot`'s return type — no generic argument.
376
+ * - `boot({ db, log, $app })` destructures typed services under the local
377
+ * aliases declared in `needs`, plus the `$`-prefixed kernel keys.
378
+ * - `start` / `stop` / `dispose` additionally receive the pip's own
379
+ * provides. Reverse-order teardown guarantees needs are still alive in
380
+ * `dispose`.
381
+ *
382
+ * @example
383
+ * ```ts
384
+ * export const billing = pip({
385
+ * name: 'billing',
386
+ * needs: { db: service<Db>(), log: service<Logger>() },
387
+ * async boot({ db, log }) {
388
+ * return { billing: new BillingService(db, log) };
389
+ * },
390
+ * async dispose({ billing }) {
391
+ * await billing.flush();
392
+ * },
393
+ * });
394
+ * ```
395
+ */
396
+ export function pip<
397
+ TShape extends NeedsShape & NoReservedKeys = EmptyShape,
398
+ TProvides extends ServiceRecord & NoReservedKeys = EmptyShape,
399
+ >(def: {
400
+ readonly name: string;
401
+ readonly version?: string;
402
+ readonly needs?: TShape;
403
+ readonly configure?: (ctx: DotConfigureContext) => void;
404
+ readonly boot?: (ctx: CtxOf<TShape> & KernelCtx) => MaybePromise<TProvides | void>;
405
+ readonly start?: (ctx: CtxOf<TShape> & TProvides & KernelCtx) => MaybePromise<void>;
406
+ readonly stop?: (ctx: CtxOf<TShape> & TProvides & KernelCtx) => MaybePromise<void>;
407
+ readonly dispose?: (ctx: CtxOf<TShape> & TProvides & KernelCtx) => MaybePromise<void>;
408
+ }): Pip<WireNeeds<TShape>, TProvides> {
409
+ return {
410
+ name: def.name,
411
+ ...(def.version === undefined ? {} : { version: def.version }),
412
+ needs: def.needs ?? {},
413
+ renames: {},
414
+ hooks: {
415
+ ...(def.configure === undefined ? {} : { configure: def.configure }),
416
+ ...(def.boot === undefined ? {} : { boot: def.boot }),
417
+ ...(def.start === undefined ? {} : { start: def.start }),
418
+ ...(def.stop === undefined ? {} : { stop: def.stop }),
419
+ ...(def.dispose === undefined ? {} : { dispose: def.dispose }),
420
+ },
421
+ };
422
+ }
423
+
424
+ /** Rename a pip's published wire keys — the multi-instance primitive. */
425
+ export type RenamedProvides<TP, M> = {
426
+ [K in keyof TP as K extends keyof M ? (M[K] extends string ? M[K] : K) : K]: TP[K];
427
+ };
428
+
429
+ /**
430
+ * Mount-time rename. `rename(dbPip, { db: 'reportsDb' }, 'reports-db')`
431
+ * publishes the same service under a different wire key, retyped
432
+ * accordingly — the way to mount a second instance of an adapter without
433
+ * a key collision. Renames compose: renaming an already-renamed key
434
+ * rewrites the earlier entry.
435
+ */
436
+ export function rename<
437
+ TN extends ServiceRecord,
438
+ TP extends ServiceRecord,
439
+ const M extends { readonly [K in keyof TP]?: string },
440
+ >(p: Pip<TN, TP>, map: M, newName?: string): Pip<TN, RenamedProvides<TP, M>> {
441
+ const renames: Record<string, string> = { ...p.renames };
442
+ for (const [wireKey, next] of Object.entries(map)) {
443
+ if (typeof next !== 'string') continue;
444
+ // If `wireKey` is itself the *result* of an earlier rename, rewrite that
445
+ // entry (compose); otherwise it is a local publish key.
446
+ const localKey = Object.entries(renames).find(([, w]) => w === wireKey)?.[0] ?? wireKey;
447
+ renames[localKey] = next;
448
+ }
449
+ // Phantom-only cast: runtime fields are unchanged except `renames`/`name`;
450
+ // only the [ProvidesSym] carrier retypes.
451
+ return { ...p, name: newName ?? p.name, renames } as Pip<TN, RenamedProvides<TP, M>>;
452
+ }
453
+
454
+ /**
455
+ * Stable error thrown by DOT pip adapters.
456
+ *
457
+ * Adapters MUST throw `DotPipError` (not raw `Error`) when surfacing a
458
+ * misconfiguration, missing-input, or other fail-fast condition. Consumers
459
+ * and coding agents can then match on a stable `code`, follow `docsUrl`,
460
+ * and apply `remediation` without parsing the message.
461
+ *
462
+ * Codes are per-adapter. Recommended prefix is `<PKG>_PIP_E<NNN>` (e.g.
463
+ * `KV_PIP_E001`, `DB_PIP_E001`). The kernel does not own the code
464
+ * namespace — each adapter defines its own constants and links them in
465
+ * its README.
466
+ *
467
+ * @see packages/dot/docs/principles.md — principle 1.3 ("errors are part
468
+ * of the API") and principle 4 ("agent-discoverable everywhere").
469
+ */
470
+ export class DotPipError extends Error {
471
+ /** Stable error code, e.g. `KV_PIP_E001`. */
472
+ readonly code: string;
473
+ /** One-sentence guidance on how to fix the underlying problem. */
474
+ readonly remediation: string;
475
+ /** URL of the documentation page that explains this error. */
476
+ readonly docsUrl: string;
477
+
478
+ constructor(args: {
479
+ readonly code: string;
480
+ readonly message: string;
481
+ readonly remediation: string;
482
+ readonly docsUrl: string;
483
+ }) {
484
+ super(args.message);
485
+ this.name = 'DotPipError';
486
+ this.code = args.code;
487
+ this.remediation = args.remediation;
488
+ this.docsUrl = args.docsUrl;
489
+ }
490
+ }
491
+
492
+ /** Re-exported for downstream typing. */
493
+
494
+ export { type DotAppManifest, type PipManifest } from './manifest.js';
package/src/pip.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Narrow public surface for pip authors.
3
+ *
4
+ * Exposes the pip contract and the entry points a pip author needs to
5
+ * author + test pips — `pip`, `service`, `token`, `provide`, `rename`,
6
+ * `defineApp`, `testApp` / `bootTestApp`, plus lifecycle / manifest /
7
+ * diagnostics types.
8
+ *
9
+ * Adapter packages (e.g. `@arki/env/dot`, `@arki/kv/dot`, `@arki/db/dot`)
10
+ * import from this subpath so their `*.d.ts` graphs stay tight.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { pip, service, type Pip } from '@arki/dot/pip';
15
+ * ```
16
+ */
17
+
18
+ export { isLazy, lazy, lazyOf, pip, provide, rename, service, token, DotPipError } from './pip-contract.js';
19
+ export type {
20
+ AnyPip,
21
+ CtxOf,
22
+ DotConfigureContext,
23
+ EmptyShape,
24
+ KernelCtx,
25
+ Lazy,
26
+ LazyService,
27
+ NeedsShape,
28
+ Pip,
29
+ PipNeeds,
30
+ PipProvides,
31
+ RenamedProvides,
32
+ Service,
33
+ ServiceRecord,
34
+ Token,
35
+ WireNeeds,
36
+ } from './pip-contract.js';
37
+
38
+ export { defineApp } from './define-app.js';
39
+ export type { DotApp, DotAppBuilder, DotAppConfigured } from './define-app.js';
40
+
41
+ export { testApp, bootTestApp } from './test-harness.js';
42
+ export type { TestAppOptions } from './test-harness.js';
43
+
44
+ export type {
45
+ DotLifecycleHook,
46
+ DotLifecycleState,
47
+ DotLifecyclePipFailure,
48
+ DotLifecycleErrorCodeValue,
49
+ } from './lifecycle.js';
50
+ export { DotLifecycleError, DotLifecycleErrorCode, DOT_LIFECYCLE_HOOKS } from './lifecycle.js';
51
+
52
+ export type {
53
+ DotAppManifest,
54
+ PipManifest,
55
+ RouteManifest,
56
+ ServiceManifest,
57
+ LifecycleManifest,
58
+ DependencyEdge,
59
+ DependencyEdgeKind,
60
+ ServiceKind,
61
+ RouteTransport,
62
+ } from './manifest.js';
63
+
64
+ export type {
65
+ DotDiagnosticsSnapshot,
66
+ PipDiagnostic,
67
+ RouteDiagnostic,
68
+ ServiceDiagnostic,
69
+ LifecycleDiagnostic,
70
+ DiagnosticIssue,
71
+ DiagnosticSeverity,
72
+ DiagnosticStatus,
73
+ } from './diagnostics.js';
74
+
75
+ export type {
76
+ DotLifecycleEvent,
77
+ DotLifecycleEventStatus,
78
+ DotLifecycleObserver,
79
+ DotPhaseLifecycleEvent,
80
+ DotPipHookLifecycleEvent,
81
+ } from './lifecycle-observer.js';
82
+
83
+ export { renderTimeline } from './timeline.js';
84
+ export type { RenderTimelineOptions } from './timeline.js';
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Test harness for unit-testing DOT pips.
3
+ *
4
+ * Provides `testApp` — a convenience wrapper that lets pip authors verify
5
+ * lifecycle behaviour, registration, and service publishing without dragging
6
+ * in concrete framework dependencies.
7
+ *
8
+ * NOTE: `testApp` takes an erased pip array, so the compile-time wiring
9
+ * guard does not apply here — the kernel's runtime validation (unsatisfied
10
+ * needs, collisions) still does. Use `defineApp(...).use(...)` chains in
11
+ * tests that should exercise the type-level guard.
12
+ *
13
+ * @example
14
+ * import { testApp, pip } from '@arki/dot';
15
+ *
16
+ * const myPip = pip({
17
+ * name: 'counter',
18
+ * boot: () => ({ counter: { value: 0 } }),
19
+ * });
20
+ *
21
+ * it('publishes a counter service', async () => {
22
+ * const app = await bootTestApp<{ counter: { value: number } }>([myPip]);
23
+ * expect(app.services.counter.value).toBe(0);
24
+ * await app.dispose();
25
+ * });
26
+ */
27
+
28
+ import type { DotApp, DotAppBuilder } from './define-app.js';
29
+ import type { AnyPip, EmptyShape, ServiceRecord } from './pip-contract.js';
30
+ import { defineApp } from './define-app.js';
31
+
32
+ export type TestAppOptions = {
33
+ /** App name used in the manifest. Defaults to `'test-app'`. */
34
+ name?: string;
35
+ /** Runtime config bag exposed to hooks as `$config`. */
36
+ config?: Readonly<Record<string, unknown>>;
37
+ };
38
+
39
+ /**
40
+ * Guard-free view of the builder for erased pip arrays. The runtime `use`
41
+ * implementation accepts the missing guard argument (it is type-level
42
+ * only), so this seam is safe — the kernel still validates wiring at boot.
43
+ */
44
+ type LooseBuilder = {
45
+ use(pip: AnyPip): LooseBuilder;
46
+ };
47
+
48
+ /**
49
+ * Build a DOT app builder pre-populated with the given pips, ready to
50
+ * `.configure()`, `.boot()` or `.start()` from a test.
51
+ */
52
+ export function testApp<TServices extends ServiceRecord = EmptyShape>(
53
+ pips: readonly AnyPip[] = [],
54
+ options: TestAppOptions = {},
55
+ ): DotAppBuilder<TServices> {
56
+ let builder: unknown = defineApp(options.name ?? 'test-app', { config: options.config });
57
+ for (const p of pips) {
58
+ builder = (builder as LooseBuilder).use(p);
59
+ }
60
+ return builder as DotAppBuilder<TServices>;
61
+ }
62
+
63
+ /**
64
+ * Convenience: build, boot, return the running app. Caller is responsible for
65
+ * calling `app.dispose()` when finished.
66
+ */
67
+ export async function bootTestApp<TServices extends ServiceRecord = EmptyShape>(
68
+ pips: readonly AnyPip[] = [],
69
+ options: TestAppOptions = {},
70
+ ): Promise<DotApp<TServices>> {
71
+ return testApp<TServices>(pips, options).boot();
72
+ }