@arki/dot 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +57 -0
  2. package/dist/cli/index.d.ts +5 -0
  3. package/dist/cli/index.d.ts.map +1 -1
  4. package/dist/cli/index.js +21 -1
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/cli/render-graph.d.ts +36 -0
  7. package/dist/cli/render-graph.d.ts.map +1 -0
  8. package/dist/cli/render-graph.js +70 -0
  9. package/dist/cli/render-graph.js.map +1 -0
  10. package/dist/define-app.d.ts +10 -0
  11. package/dist/define-app.d.ts.map +1 -1
  12. package/dist/define-app.js +2 -0
  13. package/dist/define-app.js.map +1 -1
  14. package/dist/index.d.ts +4 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/kernel/app-instance.d.ts +2 -0
  19. package/dist/kernel/app-instance.d.ts.map +1 -1
  20. package/dist/kernel/app-instance.js +67 -17
  21. package/dist/kernel/app-instance.js.map +1 -1
  22. package/dist/lifecycle.d.ts +2 -0
  23. package/dist/lifecycle.d.ts.map +1 -1
  24. package/dist/lifecycle.js +2 -0
  25. package/dist/lifecycle.js.map +1 -1
  26. package/dist/signals.d.ts +65 -0
  27. package/dist/signals.d.ts.map +1 -0
  28. package/dist/signals.js +97 -0
  29. package/dist/signals.js.map +1 -0
  30. package/dist/test-harness.d.ts +75 -1
  31. package/dist/test-harness.d.ts.map +1 -1
  32. package/dist/test-harness.js +71 -0
  33. package/dist/test-harness.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/cli/index.ts +36 -2
  36. package/src/cli/render-graph.ts +88 -0
  37. package/src/define-app.ts +13 -0
  38. package/src/index.ts +5 -2
  39. package/src/kernel/app-instance.ts +113 -51
  40. package/src/lifecycle.ts +2 -0
  41. package/src/signals.ts +134 -0
  42. package/src/test-harness.ts +140 -1
@@ -104,6 +104,8 @@ export type DotAppInternalConfig = {
104
104
  * happen before there's a public seam to call `subscribe()` on.
105
105
  */
106
106
  observers?: readonly DotLifecycleObserver[];
107
+ /** Per-hook watchdog budget in ms — see `defineApp`'s option of the same name. */
108
+ hookTimeoutMs?: number;
107
109
  };
108
110
 
109
111
  /**
@@ -122,6 +124,9 @@ export class DotAppImpl {
122
124
  /** Macro-state of the app. */
123
125
  #state: DotLifecycleState = 'defined';
124
126
 
127
+ /** Per-hook watchdog budget (ms); `undefined` = no watchdog. */
128
+ readonly #hookTimeoutMs: number | undefined;
129
+
125
130
  /** Manifest finalised after `configure`. */
126
131
  #manifest: DotAppManifest;
127
132
 
@@ -180,6 +185,7 @@ export class DotAppImpl {
180
185
  constructor(config: DotAppInternalConfig) {
181
186
  this.#appName = config.appName;
182
187
  this.#appVersion = config.appVersion;
188
+ this.#hookTimeoutMs = config.hookTimeoutMs;
183
189
  this.#config = Object.freeze({ ...config.config });
184
190
  this.#logger = new Logger('arki:dot:lifecycle', { 'dot.app.name': config.appName });
185
191
  this.#observers = new Set(config.observers);
@@ -675,6 +681,44 @@ export class DotAppImpl {
675
681
  return ctx;
676
682
  }
677
683
 
684
+ /**
685
+ * Race one hook invocation against the app's `hookTimeoutMs` watchdog.
686
+ * No budget configured → plain pass-through. On timeout the returned
687
+ * promise rejects with `DOT_LIFECYCLE_E015` naming the pip and hook,
688
+ * and the call site's existing catch path applies the phase's normal
689
+ * failure rules (boot rollback, start cascade, teardown aggregation).
690
+ * The hook's own promise cannot be cancelled — the watchdog makes a
691
+ * hang visible and bounded; it does not kill the hung work.
692
+ */
693
+ async #withHookBudget<T>(pipName: string, hook: DotLifecycleHook, run: () => Promise<T> | T): Promise<T> {
694
+ const budget = this.#hookTimeoutMs;
695
+ if (budget === undefined) return run();
696
+ let timer: ReturnType<typeof setTimeout> | undefined;
697
+ const watchdog = new Promise<never>((_resolve, reject) => {
698
+ timer = setTimeout(() => {
699
+ reject(
700
+ new DotLifecycleError({
701
+ code: DotLifecycleErrorCode.HookTimeout,
702
+ phase: hook,
703
+ pip: pipName,
704
+ message:
705
+ `${hook} hook of pip "${pipName}" exceeded the ${budget.toString()}ms hookTimeoutMs watchdog. ` +
706
+ `The kernel treats the hook as failed and applies its normal rollback/aggregation rules, but ` +
707
+ `cannot cancel the hook's promise — find the hang (a missing await? a connection that never ` +
708
+ `settles?) or raise the budget in defineApp(name, { hookTimeoutMs }).`,
709
+ }),
710
+ );
711
+ }, budget);
712
+ // A pending watchdog must never keep an otherwise-finished process alive.
713
+ timer.unref?.();
714
+ });
715
+ try {
716
+ return await Promise.race([Promise.resolve(run()), watchdog]);
717
+ } finally {
718
+ if (timer !== undefined) clearTimeout(timer);
719
+ }
720
+ }
721
+
678
722
  /**
679
723
  * Inner boot loop. Separated from `#runBoot` so the phase span wrapper
680
724
  * stays thin and the loop body — which orchestrates rollback on
@@ -770,26 +814,33 @@ export class DotAppImpl {
770
814
  this.#emitHook('boot', pip.name, record.order, 'starting');
771
815
  let result: ServiceRecord | void;
772
816
  try {
773
- result = await withPipHookSpan(
774
- {
775
- appName: this.#appName,
776
- pipName: pip.name,
777
- pipVersion: pip.version,
778
- hook: 'boot',
779
- order: record.order,
780
- logger: pipLogger,
781
- },
782
- // Erasure boundary: hooks are stored as `(ctx: never) => ...`;
783
- // the kernel is the one caller allowed to cross it.
784
- () => pip.hooks.boot!(ctx as never),
817
+ result = await this.#withHookBudget(pip.name, 'boot', () =>
818
+ withPipHookSpan(
819
+ {
820
+ appName: this.#appName,
821
+ pipName: pip.name,
822
+ pipVersion: pip.version,
823
+ hook: 'boot',
824
+ order: record.order,
825
+ logger: pipLogger,
826
+ },
827
+ // Erasure boundary: hooks are stored as `(ctx: never) => ...`;
828
+ // the kernel is the one caller allowed to cross it.
829
+ () => pip.hooks.boot!(ctx as never),
830
+ ),
785
831
  );
786
832
  } catch (error) {
833
+ const timedOut = error instanceof DotLifecycleError && error.code === DotLifecycleErrorCode.HookTimeout;
787
834
  return fail({
788
835
  record,
789
- code: DotLifecycleErrorCode.BootFailed,
790
- message: `boot hook threw for pip "${pip.name}": ${stringifyError(error)}`,
791
- remediation: `Fix the error in the boot() hook of "${pip.name}". If boot opens partial resources before throwing, clean them up locally — DOT only disposes pips whose boot completed.`,
792
- docsAnchor: 'boot-failed',
836
+ code: timedOut ? DotLifecycleErrorCode.HookTimeout : DotLifecycleErrorCode.BootFailed,
837
+ message: timedOut
838
+ ? error.message
839
+ : `boot hook threw for pip "${pip.name}": ${stringifyError(error)}`,
840
+ remediation: timedOut
841
+ ? `Find the hang in the boot() hook of "${pip.name}" — a missing await or a connection that never settles — or raise defineApp's hookTimeoutMs.`
842
+ : `Fix the error in the boot() hook of "${pip.name}". If boot opens partial resources before throwing, clean them up locally — DOT only disposes pips whose boot completed.`,
843
+ docsAnchor: timedOut ? 'hook-timeout' : 'boot-failed',
793
844
  durationMs: performance.now() - started,
794
845
  cause: error,
795
846
  rollback: bootedRecords,
@@ -917,25 +968,32 @@ export class DotAppImpl {
917
968
  const startedAt = performance.now();
918
969
  this.#emitHook('start', pip.name, record.order, 'starting');
919
970
  try {
920
- await withPipHookSpan(
921
- {
922
- appName: this.#appName,
923
- pipName: pip.name,
924
- pipVersion: pip.version,
925
- hook: 'start',
926
- order: record.order,
927
- logger: pipLogger,
928
- },
929
- () => pip.hooks.start!(ctx as never),
971
+ await this.#withHookBudget(pip.name, 'start', () =>
972
+ withPipHookSpan(
973
+ {
974
+ appName: this.#appName,
975
+ pipName: pip.name,
976
+ pipVersion: pip.version,
977
+ hook: 'start',
978
+ order: record.order,
979
+ logger: pipLogger,
980
+ },
981
+ () => pip.hooks.start!(ctx as never),
982
+ ),
930
983
  );
931
984
  } catch (error) {
932
985
  const durationMs = performance.now() - startedAt;
986
+ const timedOut = error instanceof DotLifecycleError && error.code === DotLifecycleErrorCode.HookTimeout;
933
987
  const issue = makeIssue({
934
- code: DotLifecycleErrorCode.StartFailed,
988
+ code: timedOut ? DotLifecycleErrorCode.HookTimeout : DotLifecycleErrorCode.StartFailed,
935
989
  pip: pip.name,
936
- message: `start hook threw for pip "${pip.name}": ${stringifyError(error)}`,
937
- remediation: `Fix the error in the start() hook of "${pip.name}". DOT will stop all already-started pips and dispose all booted pips in reverse order.`,
938
- docsAnchor: 'start-failed',
990
+ message: timedOut
991
+ ? error.message
992
+ : `start hook threw for pip "${pip.name}": ${stringifyError(error)}`,
993
+ remediation: timedOut
994
+ ? `Find the hang in the start() hook of "${pip.name}" — or raise defineApp's hookTimeoutMs. DOT rolls back as for any start failure.`
995
+ : `Fix the error in the start() hook of "${pip.name}". DOT will stop all already-started pips and dispose all booted pips in reverse order.`,
996
+ docsAnchor: timedOut ? 'hook-timeout' : 'start-failed',
939
997
  });
940
998
  record.issues.push(issue);
941
999
  record.lifecycleDiagnostics.push({
@@ -958,7 +1016,7 @@ export class DotAppImpl {
958
1016
  this.#manifest = this.#buildManifest();
959
1017
  const failures = [...stopFailures, ...disposeFailures];
960
1018
  throw new DotLifecycleError({
961
- code: DotLifecycleErrorCode.StartFailed,
1019
+ code: timedOut ? DotLifecycleErrorCode.HookTimeout : DotLifecycleErrorCode.StartFailed,
962
1020
  phase: 'start',
963
1021
  pip: pip.name,
964
1022
  message: issue.message,
@@ -1053,16 +1111,18 @@ export class DotAppImpl {
1053
1111
  const startedAt = performance.now();
1054
1112
  this.#emitHook('stop', record.pip.name, record.order, 'starting');
1055
1113
  try {
1056
- await withPipHookSpan(
1057
- {
1058
- appName: this.#appName,
1059
- pipName: record.pip.name,
1060
- pipVersion: record.pip.version,
1061
- hook: 'stop',
1062
- order: record.order,
1063
- logger: pipLogger,
1064
- },
1065
- () => record.pip.hooks.stop!(ctx as never),
1114
+ await this.#withHookBudget(record.pip.name, 'stop', () =>
1115
+ withPipHookSpan(
1116
+ {
1117
+ appName: this.#appName,
1118
+ pipName: record.pip.name,
1119
+ pipVersion: record.pip.version,
1120
+ hook: 'stop',
1121
+ order: record.order,
1122
+ logger: pipLogger,
1123
+ },
1124
+ () => record.pip.hooks.stop!(ctx as never),
1125
+ ),
1066
1126
  );
1067
1127
  record.started = false;
1068
1128
  const durationMs = performance.now() - startedAt;
@@ -1265,16 +1325,18 @@ export class DotAppImpl {
1265
1325
  const startedAt = performance.now();
1266
1326
  this.#emitHook('dispose', record.pip.name, record.order, 'starting');
1267
1327
  try {
1268
- await withPipHookSpan(
1269
- {
1270
- appName: this.#appName,
1271
- pipName: record.pip.name,
1272
- pipVersion: record.pip.version,
1273
- hook: 'dispose',
1274
- order: record.order,
1275
- logger: pipLogger,
1276
- },
1277
- () => record.pip.hooks.dispose!(ctx as never),
1328
+ await this.#withHookBudget(record.pip.name, 'dispose', () =>
1329
+ withPipHookSpan(
1330
+ {
1331
+ appName: this.#appName,
1332
+ pipName: record.pip.name,
1333
+ pipVersion: record.pip.version,
1334
+ hook: 'dispose',
1335
+ order: record.order,
1336
+ logger: pipLogger,
1337
+ },
1338
+ () => record.pip.hooks.dispose!(ctx as never),
1339
+ ),
1278
1340
  );
1279
1341
  record.booted = false;
1280
1342
  const durationMs = performance.now() - startedAt;
package/src/lifecycle.ts CHANGED
@@ -76,6 +76,8 @@ export const DotLifecycleErrorCode = {
76
76
  ServiceCollision: 'DOT_LIFECYCLE_E013',
77
77
  /** A needs alias, publish key, or rename target uses the reserved `$` prefix. */
78
78
  ReservedServiceKey: 'DOT_LIFECYCLE_E014',
79
+ /** A lifecycle hook exceeded the app's `hookTimeoutMs` watchdog. */
80
+ HookTimeout: 'DOT_LIFECYCLE_E015',
79
81
  } as const;
80
82
 
81
83
  export type DotLifecycleErrorCodeValue = (typeof DotLifecycleErrorCode)[keyof typeof DotLifecycleErrorCode];
package/src/signals.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Signal wiring for graceful shutdown.
3
+ *
4
+ * `hookSignals(app)` connects OS termination signals to the app's
5
+ * lifecycle: on the first SIGTERM/SIGINT the app is disposed (which
6
+ * cascades `stop()` when started — transitions are serialized, so a
7
+ * signal racing a slow `start()` still ends in `disposed`), then the
8
+ * signal is re-raised with the default handler restored so the process
9
+ * terminates with conventional signal semantics.
10
+ *
11
+ * Design points:
12
+ *
13
+ * - **First signal drains, second signal kills.** All handlers are
14
+ * removed the moment the first signal fires, so an impatient second
15
+ * Ctrl-C hits the runtime default (immediate termination) instead of
16
+ * a stuck drain.
17
+ * - **Bounded.** `timeoutMs` caps the drain. On timeout or dispose
18
+ * failure the process still terminates — exit code reflects the
19
+ * failure via the re-raised signal; the error is logged on
20
+ * `arki:dot:signals`.
21
+ * - **No `process.exit()`.** The signal is re-raised via
22
+ * `proc.kill(proc.pid, signal)` so the exit status is the standard
23
+ * 128+n signal encoding — supervisors (systemd, Docker, Coolify)
24
+ * read it correctly.
25
+ */
26
+
27
+ import { Logger } from '@arki/log';
28
+
29
+ /**
30
+ * The slice of `process` the signal hook touches. Injectable so tests can
31
+ * drive signals through a fake without terminating the test runner —
32
+ * defaults to the real `process`.
33
+ */
34
+ export type SignalTarget = {
35
+ readonly pid: number;
36
+ once(event: string, listener: (...args: never[]) => void): unknown;
37
+ off(event: string, listener: (...args: never[]) => void): unknown;
38
+ kill(pid: number, signal?: string): unknown;
39
+ };
40
+
41
+ export type HookSignalsOptions = {
42
+ /** Signals to intercept. Default: `['SIGTERM', 'SIGINT']`. */
43
+ readonly signals?: readonly string[];
44
+ /**
45
+ * Maximum time the drain (`dispose()`) may take before the process is
46
+ * terminated anyway. Default: 10 000 ms.
47
+ */
48
+ readonly timeoutMs?: number;
49
+ /** Test seam — a process-like target. Default: the real `process`. */
50
+ readonly proc?: SignalTarget;
51
+ };
52
+
53
+ /** The app surface the hook needs — satisfied by any `DotApp`. */
54
+ export type Disposable = {
55
+ readonly name: string;
56
+ dispose(): Promise<void>;
57
+ };
58
+
59
+ /**
60
+ * Wire termination signals to `app.dispose()`. Returns an unhook function
61
+ * that removes the handlers without disposing — call it if the app is
62
+ * torn down through another path first.
63
+ *
64
+ * ```ts
65
+ * const app = await defineApp('shop').use(...).start();
66
+ * hookSignals(app); // SIGTERM/SIGINT → drain → exit
67
+ * hookSignals(app, { timeoutMs: 30_000 }); // slower drain budget
68
+ * ```
69
+ */
70
+ export function hookSignals(app: Disposable, options: HookSignalsOptions = {}): () => void {
71
+ const signals = options.signals ?? ['SIGTERM', 'SIGINT'];
72
+ const timeoutMs = options.timeoutMs ?? 10_000;
73
+ const proc: SignalTarget = options.proc ?? (process as unknown as SignalTarget);
74
+ const logger = new Logger('arki:dot:signals', { 'dot.app.name': app.name });
75
+
76
+ const handlers = new Map<string, () => void>();
77
+
78
+ const unhook = (): void => {
79
+ for (const [signal, handler] of handlers) proc.off(signal, handler);
80
+ handlers.clear();
81
+ };
82
+
83
+ for (const signal of signals) {
84
+ const handler = (): void => {
85
+ // First signal wins; the rest fall through to runtime defaults.
86
+ unhook();
87
+ logger.info('signal received — draining', { 'dot.signal': signal, 'dot.drain.timeout.ms': timeoutMs });
88
+ void drainAndReraise({ app, signal, timeoutMs, proc, logger });
89
+ };
90
+ handlers.set(signal, handler);
91
+ proc.once(signal, handler);
92
+ }
93
+
94
+ return unhook;
95
+ }
96
+
97
+ /** Dispose with a timeout cap, then re-raise the signal for default handling. */
98
+ async function drainAndReraise(args: {
99
+ app: Disposable;
100
+ signal: string;
101
+ timeoutMs: number;
102
+ proc: SignalTarget;
103
+ logger: Logger;
104
+ }): Promise<void> {
105
+ const { app, signal, timeoutMs, proc, logger } = args;
106
+ let timer: ReturnType<typeof setTimeout> | undefined;
107
+ const timedOut = new Promise<'timeout'>(resolve => {
108
+ timer = setTimeout(() => {
109
+ resolve('timeout');
110
+ }, timeoutMs);
111
+ // A pending drain timer must never keep an otherwise-finished process alive.
112
+ if (typeof timer === 'object' && 'unref' in timer) timer.unref();
113
+ });
114
+
115
+ try {
116
+ const outcome = await Promise.race([app.dispose().then(() => 'disposed' as const), timedOut]);
117
+ if (outcome === 'timeout') {
118
+ logger.error('drain timed out — terminating without a clean dispose', {
119
+ 'dot.signal': signal,
120
+ 'dot.drain.timeout.ms': timeoutMs,
121
+ });
122
+ } else {
123
+ logger.info('drain complete', { 'dot.signal': signal });
124
+ }
125
+ } catch (error) {
126
+ logger.error('dispose failed during drain — terminating', {
127
+ 'dot.signal': signal,
128
+ 'dot.error.message': error instanceof Error ? error.message : String(error),
129
+ });
130
+ } finally {
131
+ if (timer !== undefined) clearTimeout(timer);
132
+ proc.kill(proc.pid, signal);
133
+ }
134
+ }
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import type { DotApp, DotAppBuilder } from './define-app.js';
29
- import type { AnyPip, EmptyShape, ServiceRecord } from './pip-contract.js';
29
+ import type { AnyPip, EmptyShape, Pip, ServiceRecord, Token } from './pip-contract.js';
30
30
  import { defineApp } from './define-app.js';
31
31
 
32
32
  export type TestAppOptions = {
@@ -70,3 +70,142 @@ export async function bootTestApp<TServices extends ServiceRecord = EmptyShape>(
70
70
  ): Promise<DotApp<TServices>> {
71
71
  return testApp<TServices>(pips, options).boot();
72
72
  }
73
+
74
+ /**
75
+ * Rest-tuple guard for {@link TestPipBuilder.boot} — the same trick the app
76
+ * builder's `UseGuard` uses. While any need is still unprovided, `boot()`
77
+ * demands an impossible second argument, so the call site fails with
78
+ * "Expected 2 arguments, but got 1" and the error payload names the
79
+ * missing wire keys and their types.
80
+ */
81
+ type TestPipBootGuard<TRemaining extends ServiceRecord> = keyof TRemaining extends never
82
+ ? readonly []
83
+ : readonly [
84
+ needs: {
85
+ readonly 'DOT-TEST: pip needs still unprovided — call .provide() for each': {
86
+ readonly [K in keyof TRemaining]: TRemaining[K];
87
+ };
88
+ },
89
+ ];
90
+
91
+ /**
92
+ * Typed unit-test builder for a single pip — see {@link testPip}.
93
+ *
94
+ * `TRemaining` tracks the wire keys not yet covered by `.provide()`;
95
+ * `TServices` accumulates what the booted app will expose (the fakes plus
96
+ * the pip's own provides).
97
+ */
98
+ export type TestPipBuilder<TRemaining extends ServiceRecord, TServices extends ServiceRecord> = {
99
+ /**
100
+ * Satisfy one need directly with a fake. Token form — the token names
101
+ * the wire key and carries the type:
102
+ *
103
+ * ```ts
104
+ * testPip(reports).provide(Db, fakeDb)
105
+ * ```
106
+ */
107
+ provide<K extends keyof TRemaining & string, T extends TRemaining[K]>(
108
+ token: Token<T, K>,
109
+ value: T,
110
+ ): TestPipBuilder<Omit<TRemaining, K>, TServices & Readonly<Record<K, T>>>;
111
+ /**
112
+ * Satisfy one need directly with a fake. Key form — for anonymous
113
+ * `service<T>()` needs, where the wire key is the property name:
114
+ *
115
+ * ```ts
116
+ * testPip(billing).provide('db', fakeDb)
117
+ * ```
118
+ */
119
+ provide<K extends keyof TRemaining & string>(
120
+ key: K,
121
+ value: TRemaining[K],
122
+ ): TestPipBuilder<Omit<TRemaining, K>, TServices & Readonly<Record<K, TRemaining[K]>>>;
123
+ /** Boot the pip against the provided fakes. Compile error until every need is provided. */
124
+ boot(...guard: TestPipBootGuard<TRemaining>): Promise<DotApp<TServices>>;
125
+ /** Boot + start. Same guard as {@link TestPipBuilder.boot}. */
126
+ start(...guard: TestPipBootGuard<TRemaining>): Promise<DotApp<TServices>>;
127
+ };
128
+
129
+ /** Internal accumulating state for {@link testPip}. */
130
+ type TestPipState = {
131
+ readonly pip: AnyPip;
132
+ readonly fakes: Readonly<Record<string, unknown>>;
133
+ readonly options: TestAppOptions;
134
+ };
135
+
136
+ /**
137
+ * Unit-test a single pip with **typed overrides** — the compile-checked
138
+ * counterpart to {@link testApp}'s erased arrays.
139
+ *
140
+ * Each `.provide()` satisfies one of the pip's needs directly (no real
141
+ * provider pip, no dependency chain) and removes it from the builder's
142
+ * remaining-needs type. `boot()` only compiles once every need is covered,
143
+ * and a fake of the wrong shape fails at the `.provide()` call site:
144
+ *
145
+ * ```ts
146
+ * import { testPip } from '@arki/dot/test-harness';
147
+ *
148
+ * const app = await testPip(catalog)
149
+ * .provide(Db, fakeDb) // token need
150
+ * .provide('cache', fakeKv) // anonymous need — wire key is the alias
151
+ * .boot();
152
+ *
153
+ * expect(app.services.catalog.list()).toEqual([]);
154
+ * await app.dispose();
155
+ * ```
156
+ *
157
+ * `service.lazy<T>()` needs accept either a plain `T` fake (the kernel
158
+ * lifts it) or a `Lazy<T>` handle (`lazyOf(value)` is handy here).
159
+ * Lifecycle semantics are the real kernel's — the fakes are published by a
160
+ * synthetic first pip, so reverse-order teardown and lazy auto-dispose
161
+ * behave exactly as in production.
162
+ */
163
+ export function testPip<TNeeds extends ServiceRecord, TProvides extends ServiceRecord>(
164
+ pip: Pip<TNeeds, TProvides>,
165
+ options: TestAppOptions = {},
166
+ ): TestPipBuilder<TNeeds, TProvides> {
167
+ // Erasure seam — same boundary as `makeBuilder` in define-app.ts: the
168
+ // runtime impl is untyped, the type parameters do all the guarding.
169
+ return makeTestPipBuilder({ pip: pip as AnyPip, fakes: {}, options }) as TestPipBuilder<TNeeds, TProvides>;
170
+ }
171
+
172
+ /**
173
+ * Erased implementation behind {@link testPip}. Mirrors `makeBuilder` in
174
+ * `define-app.ts`: the runtime is untyped, the single cast at the return
175
+ * is the seam, and the type parameters do all the guarding.
176
+ */
177
+ function makeTestPipBuilder(state: TestPipState): TestPipBuilder<ServiceRecord, ServiceRecord> {
178
+ const bootApp = async (): Promise<DotApp<ServiceRecord>> => {
179
+ const pips: AnyPip[] = [];
180
+ if (Object.keys(state.fakes).length > 0) {
181
+ const fakes = { ...state.fakes };
182
+ pips.push({
183
+ name: `test:fakes(${state.pip.name})`,
184
+ needs: {},
185
+ renames: {},
186
+ hooks: { boot: () => fakes },
187
+ });
188
+ }
189
+ pips.push(state.pip);
190
+ return testApp<ServiceRecord>(pips, {
191
+ name: state.options.name ?? `test:${state.pip.name}`,
192
+ ...(state.options.config === undefined ? {} : { config: state.options.config }),
193
+ }).boot();
194
+ };
195
+
196
+ const impl = {
197
+ provide(tokenOrKey: Token<unknown, string> | string, value: unknown) {
198
+ const key = typeof tokenOrKey === 'string' ? tokenOrKey : tokenOrKey.key;
199
+ return makeTestPipBuilder({ ...state, fakes: { ...state.fakes, [key]: value } });
200
+ },
201
+ async boot(..._guard: readonly unknown[]) {
202
+ return bootApp();
203
+ },
204
+ async start(..._guard: readonly unknown[]) {
205
+ const app = await bootApp();
206
+ await app.start();
207
+ return app;
208
+ },
209
+ };
210
+ return impl as TestPipBuilder<ServiceRecord, ServiceRecord>;
211
+ }