@zudojs/lifecycle 0.1.0 → 1.1.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 (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -12
  3. package/dist/lifecycleExecutor/lifecycleExecutor.core.js +36 -5
  4. package/dist/lifecycleInternal/asyncUtils.core.js +58 -14
  5. package/dist/lifecycleManager/lifecycleManager.context.d.ts +53 -0
  6. package/dist/lifecycleManager/lifecycleManager.context.js +25 -0
  7. package/dist/lifecycleManager/lifecycleManager.core.d.ts +6 -2
  8. package/dist/lifecycleManager/lifecycleManager.core.js +12 -7
  9. package/dist/lifecycleManager/lifecycleManager.shutdown.d.ts +4 -1
  10. package/dist/lifecycleManager/lifecycleManager.shutdown.js +123 -14
  11. package/dist/lifecycleManager/lifecycleManager.startup.d.ts +6 -0
  12. package/dist/lifecycleManager/lifecycleManager.startup.js +165 -36
  13. package/dist/lifecyclePhase/lifecyclePhase.core.d.ts +7 -1
  14. package/dist/lifecyclePhase/lifecyclePhase.core.js +8 -13
  15. package/dist/lifecyclePlan/lifecyclePlan.core.js +7 -3
  16. package/dist/lifecycleSignal/lifecycleSignal.handler.d.ts +2 -2
  17. package/dist/lifecycleSignal/lifecycleSignal.handler.js +6 -3
  18. package/package.json +25 -14
  19. package/dist/index.d.ts.map +0 -1
  20. package/dist/index.js.map +0 -1
  21. package/dist/lifecycleComponent/index.d.ts.map +0 -1
  22. package/dist/lifecycleComponent/index.js.map +0 -1
  23. package/dist/lifecycleComponent/lifecycleComponent.type.d.ts.map +0 -1
  24. package/dist/lifecycleComponent/lifecycleComponent.type.js.map +0 -1
  25. package/dist/lifecycleContext/index.d.ts.map +0 -1
  26. package/dist/lifecycleContext/index.js.map +0 -1
  27. package/dist/lifecycleContext/lifecycleContext.type.d.ts.map +0 -1
  28. package/dist/lifecycleContext/lifecycleContext.type.js.map +0 -1
  29. package/dist/lifecycleEvents/index.d.ts.map +0 -1
  30. package/dist/lifecycleEvents/index.js.map +0 -1
  31. package/dist/lifecycleEvents/lifecycleEvents.core.d.ts.map +0 -1
  32. package/dist/lifecycleEvents/lifecycleEvents.core.js.map +0 -1
  33. package/dist/lifecycleExecutor/index.d.ts.map +0 -1
  34. package/dist/lifecycleExecutor/index.js.map +0 -1
  35. package/dist/lifecycleExecutor/lifecycleExecutor.core.d.ts.map +0 -1
  36. package/dist/lifecycleExecutor/lifecycleExecutor.core.js.map +0 -1
  37. package/dist/lifecycleInternal/asyncUtils.core.d.ts.map +0 -1
  38. package/dist/lifecycleInternal/asyncUtils.core.js.map +0 -1
  39. package/dist/lifecycleInternal/dependencyGraph.core.d.ts.map +0 -1
  40. package/dist/lifecycleInternal/dependencyGraph.core.js.map +0 -1
  41. package/dist/lifecycleInternal/index.d.ts.map +0 -1
  42. package/dist/lifecycleInternal/index.js.map +0 -1
  43. package/dist/lifecycleInternal/topologicalSort.core.d.ts.map +0 -1
  44. package/dist/lifecycleInternal/topologicalSort.core.js.map +0 -1
  45. package/dist/lifecycleManager/index.d.ts.map +0 -1
  46. package/dist/lifecycleManager/index.js.map +0 -1
  47. package/dist/lifecycleManager/lifecycleManager.context.d.ts.map +0 -1
  48. package/dist/lifecycleManager/lifecycleManager.context.js.map +0 -1
  49. package/dist/lifecycleManager/lifecycleManager.core.d.ts.map +0 -1
  50. package/dist/lifecycleManager/lifecycleManager.core.js.map +0 -1
  51. package/dist/lifecycleManager/lifecycleManager.shutdown.d.ts.map +0 -1
  52. package/dist/lifecycleManager/lifecycleManager.shutdown.js.map +0 -1
  53. package/dist/lifecycleManager/lifecycleManager.startup.d.ts.map +0 -1
  54. package/dist/lifecycleManager/lifecycleManager.startup.js.map +0 -1
  55. package/dist/lifecyclePhase/index.d.ts.map +0 -1
  56. package/dist/lifecyclePhase/index.js.map +0 -1
  57. package/dist/lifecyclePhase/lifecyclePhase.core.d.ts.map +0 -1
  58. package/dist/lifecyclePhase/lifecyclePhase.core.js.map +0 -1
  59. package/dist/lifecyclePhase/lifecyclePhase.type.d.ts +0 -16
  60. package/dist/lifecyclePhase/lifecyclePhase.type.d.ts.map +0 -1
  61. package/dist/lifecyclePhase/lifecyclePhase.type.js +0 -17
  62. package/dist/lifecyclePhase/lifecyclePhase.type.js.map +0 -1
  63. package/dist/lifecyclePlan/index.d.ts.map +0 -1
  64. package/dist/lifecyclePlan/index.js.map +0 -1
  65. package/dist/lifecyclePlan/lifecyclePlan.core.d.ts.map +0 -1
  66. package/dist/lifecyclePlan/lifecyclePlan.core.js.map +0 -1
  67. package/dist/lifecycleRegistry/index.d.ts.map +0 -1
  68. package/dist/lifecycleRegistry/index.js.map +0 -1
  69. package/dist/lifecycleRegistry/lifecycleRegistry.core.d.ts.map +0 -1
  70. package/dist/lifecycleRegistry/lifecycleRegistry.core.js.map +0 -1
  71. package/dist/lifecycleSignal/index.d.ts.map +0 -1
  72. package/dist/lifecycleSignal/index.js.map +0 -1
  73. package/dist/lifecycleSignal/lifecycleSignal.handler.d.ts.map +0 -1
  74. package/dist/lifecycleSignal/lifecycleSignal.handler.js.map +0 -1
  75. package/dist/lifecycleState/index.d.ts.map +0 -1
  76. package/dist/lifecycleState/index.js.map +0 -1
  77. package/dist/lifecycleState/lifecycleState.machine.d.ts.map +0 -1
  78. package/dist/lifecycleState/lifecycleState.machine.js.map +0 -1
@@ -6,27 +6,58 @@
6
6
  import { LifecyclePhase, LifecycleState } from "@zudojs/constants";
7
7
  import { buildExecutionPlan } from "../lifecyclePlan/lifecyclePlan.core.js";
8
8
  import { createLifecycleContext } from "../lifecycleContext/lifecycleContext.type.js";
9
- import { transitionComponent } from "./lifecycleManager.context.js";
9
+ import { emitComponentFailed, recordResult, transitionComponent, wasAttempted, } from "./lifecycleManager.context.js";
10
10
  /** Shutdown phases in execution order. */
11
- const SHUTDOWN_PHASES = ["stop", "dispose"];
11
+ const SHUTDOWN_PHASES = [LifecyclePhase.STOP, LifecyclePhase.DISPOSE];
12
12
  /**
13
13
  * Performs the full shutdown sequence: stop → dispose.
14
- * Idempotent — safe to call multiple times.
14
+ *
15
+ * Idempotent and single-flight: concurrent callers (a signal handler
16
+ * and a failing startup, say) all await the SAME teardown rather than
17
+ * running overlapping ones.
15
18
  */
16
- export async function performShutdown(ctx) {
19
+ export function performShutdown(ctx) {
20
+ ctx.shutdownPromise ??= runShutdown(ctx);
21
+ return ctx.shutdownPromise;
22
+ }
23
+ async function runShutdown(ctx) {
17
24
  if (ctx.state.state === LifecycleState.DISPOSED)
18
25
  return;
26
+ // Shutdown must always be possible, including mid-startup. A plain
27
+ // transition() threw LifecycleStateError when shutdown() was called
28
+ // while the application was still INITIALIZING or STARTING, so a
29
+ // signal arriving during startup crashed instead of tearing down.
19
30
  if (ctx.state.state !== LifecycleState.STOPPING &&
20
31
  ctx.state.state !== LifecycleState.FAILED) {
21
- ctx.state.transition(LifecycleState.STOPPING);
32
+ if (ctx.state.canTransition(LifecycleState.STOPPING)) {
33
+ ctx.state.transition(LifecycleState.STOPPING);
34
+ }
35
+ else {
36
+ ctx.state.forceState(LifecycleState.STOPPING);
37
+ }
22
38
  }
23
39
  ctx.events.emit("application:stopping", {});
24
40
  const deadline = Date.now() + ctx.shutdownTimeout;
41
+ // A startup stage still executing must settle before its components
42
+ // are stopped, otherwise `stop()` overlaps the component's own
43
+ // `start()`. Startup itself refuses to launch further stages once
44
+ // `shutdownPromise` is set, so this wait is bounded by one stage.
45
+ if (ctx.inFlight !== undefined) {
46
+ await raceDeadline(ctx, ctx.inFlight.then(() => undefined, () => undefined), Math.max(deadline - Date.now(), 1));
47
+ }
48
+ // The shutdown deadline used to be checked only BETWEEN the two
49
+ // phases, so a single hook that never settled hung shutdown (and the
50
+ // process) forever. Race the whole phase against the remaining
51
+ // budget and abort the run's signal when it expires, so hooks that
52
+ // honour cancellation stop and the rest are abandoned.
25
53
  for (const phase of SHUTDOWN_PHASES) {
26
- if (deadline - Date.now() <= 0)
54
+ const remaining = deadline - Date.now();
55
+ if (remaining <= 0) {
56
+ ctx.controller.abort(new Error(`Lifecycle shutdown exceeded its ${ctx.shutdownTimeout}ms deadline.`));
27
57
  break;
58
+ }
28
59
  try {
29
- await executeShutdownPhase(ctx, phase);
60
+ await raceDeadline(ctx, executeShutdownPhase(ctx, phase), remaining);
30
61
  }
31
62
  catch {
32
63
  // Shutdown must continue even if individual components fail.
@@ -36,24 +67,102 @@ export async function performShutdown(ctx) {
36
67
  ctx.events.emit("application:stopped", {
37
68
  duration: Date.now() - ctx.startTime,
38
69
  });
70
+ ctx.events.emit("application:disposed", {
71
+ duration: Date.now() - ctx.startTime,
72
+ });
39
73
  }
40
- /** Executes a single shutdown phase across all registered components. */
74
+ /**
75
+ * Resolves when the phase finishes or the shutdown budget runs out.
76
+ *
77
+ * On expiry the run's AbortController is aborted so in-flight hooks
78
+ * observing `context.signal` unwind, and the timer is always cleared
79
+ * so it can never hold the event loop open.
80
+ */
81
+ async function raceDeadline(ctx, phase, remainingMs) {
82
+ let timer;
83
+ const expiry = new Promise((resolve) => {
84
+ timer = setTimeout(() => {
85
+ ctx.controller.abort(new Error(`Lifecycle shutdown exceeded its ${ctx.shutdownTimeout}ms deadline.`));
86
+ resolve();
87
+ }, remainingMs);
88
+ });
89
+ try {
90
+ await Promise.race([phase, expiry]);
91
+ }
92
+ finally {
93
+ if (timer !== undefined) {
94
+ clearTimeout(timer);
95
+ }
96
+ }
97
+ // The abandoned phase promise must never surface as an unhandled
98
+ // rejection once the race has been decided.
99
+ void phase.catch(() => { });
100
+ }
101
+ /**
102
+ * Executes a single shutdown phase across all registered components.
103
+ *
104
+ * A hook only runs for components that reached the matching startup
105
+ * phase: `stop()` when `start` ran, `dispose()` when `initialize` ran.
106
+ * Rollback after an early failure — and `shutdown()` on a manager that
107
+ * was never started — used to call `stop()` on components that had
108
+ * never started; a real server's `close()` throws in that situation
109
+ * and the phantom failure was then recorded against the component.
110
+ */
41
111
  async function executeShutdownPhase(ctx, phase) {
42
112
  const plan = buildExecutionPlan(ctx.registry.getAll(), phase);
43
- const context = createLifecycleContext(phase, ctx.startTime);
113
+ const context = createLifecycleContext(phase, ctx.startTime, ctx.controller.signal);
114
+ const isStop = phase === LifecyclePhase.STOP;
115
+ const prerequisite = isStop
116
+ ? LifecyclePhase.START
117
+ : LifecyclePhase.INITIALIZE;
118
+ const successState = isStop
119
+ ? LifecycleState.STOPPED
120
+ : LifecycleState.DISPOSED;
44
121
  for (const stage of plan.stages) {
45
122
  const stageRegs = stage.components
46
123
  .map((id) => ctx.registry.get(id))
47
124
  .filter((r) => r !== undefined);
48
125
  if (stageRegs.length === 0)
49
126
  continue;
127
+ const runnable = [];
50
128
  for (const reg of stageRegs) {
51
- transitionComponent(ctx, reg.id, LifecycleState.STOPPING);
129
+ if (!wasAttempted(ctx, reg.id, prerequisite)) {
130
+ // Never reached the phase this hook undoes. It still ends up
131
+ // DISPOSED so status reflects the teardown.
132
+ if (!isStop) {
133
+ transitionComponent(ctx, reg.id, LifecycleState.DISPOSED);
134
+ }
135
+ continue;
136
+ }
137
+ runnable.push(reg);
138
+ // Only the stop phase moves a component into STOPPING; dispose
139
+ // runs from STOPPED (or FAILED) and transitions straight to
140
+ // DISPOSED.
141
+ if (isStop) {
142
+ transitionComponent(ctx, reg.id, LifecycleState.STOPPING);
143
+ }
144
+ ctx.events.emit("component:stopping", {
145
+ component: { componentId: reg.id },
146
+ });
52
147
  }
53
- await ctx.executor.executeStage(stageRegs, phase, context, ctx.concurrency);
54
- const targetState = phase === "stop" ? LifecycleState.STOPPED : LifecycleState.DISPOSED;
55
- for (const reg of stageRegs) {
56
- transitionComponent(ctx, reg.id, targetState);
148
+ if (runnable.length === 0)
149
+ continue;
150
+ const results = await ctx.executor.executeStage(runnable, phase, context, ctx.concurrency);
151
+ // Shutdown results used to be discarded entirely: a component whose
152
+ // stop() or dispose() threw was still reported as cleanly STOPPED,
153
+ // its failure never reached getStatus() or the event stream, and
154
+ // operators had no way to learn a resource had leaked.
155
+ for (const result of results) {
156
+ recordResult(ctx, result);
157
+ if (result.success) {
158
+ transitionComponent(ctx, result.id, successState);
159
+ ctx.events.emit("component:stopped", {
160
+ component: { componentId: result.id, duration: result.duration },
161
+ });
162
+ continue;
163
+ }
164
+ transitionComponent(ctx, result.id, LifecycleState.FAILED);
165
+ emitComponentFailed(ctx, result);
57
166
  }
58
167
  }
59
168
  }
@@ -6,6 +6,12 @@
6
6
  import type { LifecycleManagerContext } from "./lifecycleManager.context.js";
7
7
  /**
8
8
  * Performs the full startup sequence: initialize → start → ready.
9
+ *
10
+ * When a CRITICAL component fails, the application is rolled back
11
+ * (stop → dispose) and this function REJECTS with a LifecycleStartError.
12
+ * It previously resolved normally after rolling everything back, so
13
+ * `await manager.start()` reported success for an application that had
14
+ * just been torn down.
9
15
  */
10
16
  export declare function performStartup(ctx: LifecycleManagerContext): Promise<void>;
11
17
  //# sourceMappingURL=lifecycleManager.startup.d.ts.map
@@ -4,97 +4,226 @@
4
4
  * Startup orchestration — initializes, starts, and readies components.
5
5
  */
6
6
  import { LifecyclePhase, LifecycleState } from "@zudojs/constants";
7
+ import { LifecycleComponentError, LifecycleStartError } from "@zudojs/errors";
7
8
  import { buildExecutionPlan } from "../lifecyclePlan/lifecyclePlan.core.js";
8
9
  import { createLifecycleContext } from "../lifecycleContext/lifecycleContext.type.js";
9
- import { transitionComponent, transitionComponentBatch, emitComponentFailed, } from "./lifecycleManager.context.js";
10
+ import { transitionComponent, transitionComponentBatch, emitComponentFailed, failComponent, markAttempted, recordResult, } from "./lifecycleManager.context.js";
10
11
  import { performShutdown } from "./lifecycleManager.shutdown.js";
11
12
  /** Maps startup phases to their target component state after success. */
12
13
  const SUCCESS_STATE = {
13
- initialize: LifecycleState.INITIALIZED,
14
- start: LifecycleState.STARTED,
15
- ready: LifecycleState.READY,
14
+ [LifecyclePhase.INITIALIZE]: LifecycleState.INITIALIZED,
15
+ [LifecyclePhase.START]: LifecycleState.STARTED,
16
+ [LifecyclePhase.READY]: LifecycleState.READY,
16
17
  };
17
18
  /** Maps startup phases to their component state during execution. */
18
19
  const EXECUTING_STATE = {
19
- initialize: LifecycleState.INITIALIZING,
20
- start: LifecycleState.STARTING,
21
- ready: LifecycleState.STARTED,
20
+ [LifecyclePhase.INITIALIZE]: LifecycleState.INITIALIZING,
21
+ [LifecyclePhase.START]: LifecycleState.STARTING,
22
+ [LifecyclePhase.READY]: LifecycleState.STARTED,
23
+ };
24
+ /**
25
+ * Per-phase component events.
26
+ *
27
+ * Every one of these event types was declared by LifecycleEventType
28
+ * from the first release, but startup only ever emitted the generic
29
+ * "component:starting"/"component:started" pair — and the "starting"
30
+ * event carried a comma-joined list of ids in the `componentId` field
31
+ * instead of a real component id.
32
+ */
33
+ const PHASE_EVENTS = {
34
+ [LifecyclePhase.INITIALIZE]: {
35
+ begin: "component:initializing",
36
+ end: "component:initialized",
37
+ },
38
+ [LifecyclePhase.START]: {
39
+ begin: "component:starting",
40
+ end: "component:started",
41
+ },
42
+ [LifecyclePhase.READY]: {
43
+ begin: "component:starting",
44
+ end: "component:ready",
45
+ },
46
+ };
47
+ /** Application-level event emitted when a startup phase begins. */
48
+ const PHASE_APPLICATION_EVENT = {
49
+ [LifecyclePhase.INITIALIZE]: "application:initializing",
50
+ [LifecyclePhase.START]: "application:starting",
51
+ [LifecyclePhase.READY]: "application:starting",
22
52
  };
23
53
  /**
24
54
  * Performs the full startup sequence: initialize → start → ready.
55
+ *
56
+ * When a CRITICAL component fails, the application is rolled back
57
+ * (stop → dispose) and this function REJECTS with a LifecycleStartError.
58
+ * It previously resolved normally after rolling everything back, so
59
+ * `await manager.start()` reported success for an application that had
60
+ * just been torn down.
25
61
  */
26
62
  export async function performStartup(ctx) {
27
63
  ctx.startTime = Date.now();
28
64
  ctx.state.transition(LifecycleState.INITIALIZING);
29
- ctx.events.emit("application:initializing", {});
30
65
  try {
31
66
  ctx.registry.freeze();
32
- const initOk = await executePhase(ctx, "initialize");
33
- if (!initOk) {
34
- await performShutdown(ctx);
35
- return;
67
+ const failedInit = await executePhase(ctx, LifecyclePhase.INITIALIZE);
68
+ if (failedInit) {
69
+ throw new LifecycleStartError(failedInit.id, failedInit.error);
36
70
  }
71
+ assertNotShuttingDown(ctx, LifecyclePhase.INITIALIZE);
37
72
  ctx.state.transition(LifecycleState.INITIALIZED);
73
+ ctx.events.emit("application:initialized", {
74
+ duration: Date.now() - ctx.startTime,
75
+ });
38
76
  ctx.state.transition(LifecycleState.STARTING);
39
- const startOk = await executePhase(ctx, "start");
40
- if (!startOk) {
41
- await performShutdown(ctx);
42
- return;
77
+ const failedStart = await executePhase(ctx, LifecyclePhase.START);
78
+ if (failedStart) {
79
+ throw new LifecycleStartError(failedStart.id, failedStart.error);
43
80
  }
81
+ assertNotShuttingDown(ctx, LifecyclePhase.START);
44
82
  ctx.state.transition(LifecycleState.STARTED);
45
- const readyOk = await executePhase(ctx, "ready");
46
- if (readyOk) {
47
- ctx.state.transition(LifecycleState.READY);
48
- ctx.events.emit("application:ready", {
49
- duration: Date.now() - ctx.startTime,
50
- });
83
+ // A failing `ready` hook on a critical component used to be
84
+ // ignored completely: no state change, no rollback, and start()
85
+ // resolved with the application stuck in STARTED.
86
+ const failedReady = await executePhase(ctx, LifecyclePhase.READY);
87
+ if (failedReady) {
88
+ throw new LifecycleStartError(failedReady.id, failedReady.error);
51
89
  }
90
+ assertNotShuttingDown(ctx, LifecyclePhase.READY);
91
+ ctx.state.transition(LifecycleState.READY);
92
+ ctx.events.emit("application:ready", {
93
+ duration: Date.now() - ctx.startTime,
94
+ });
52
95
  }
53
96
  catch (error) {
54
- if (ctx.state.state !== LifecycleState.FAILED) {
97
+ // Rollback happens on exactly one path, so a completed teardown is
98
+ // never re-entered and its DISPOSED state is never overwritten
99
+ // with FAILED.
100
+ // When the failure IS a requested shutdown, the teardown already
101
+ // owns the application state.
102
+ if (ctx.shutdownPromise === undefined &&
103
+ ctx.state.state !== LifecycleState.FAILED &&
104
+ ctx.state.state !== LifecycleState.DISPOSED) {
55
105
  ctx.state.forceState(LifecycleState.FAILED);
56
106
  }
57
107
  await performShutdown(ctx);
58
108
  throw error;
59
109
  }
60
110
  }
111
+ /**
112
+ * Throws when a shutdown has been requested while startup is running.
113
+ *
114
+ * Startup used to keep launching later stages after `shutdown()` had
115
+ * already torn everything down: a component started that way was
116
+ * never stopped, and the eventual failure was an opaque
117
+ * LifecycleStateError from the DISPOSED → INITIALIZED transition.
118
+ */
119
+ function assertNotShuttingDown(ctx, phase) {
120
+ if (ctx.shutdownPromise !== undefined) {
121
+ throw new LifecycleStartError("application", new LifecycleComponentError("application", phase, new Error("Startup was cancelled because shutdown was requested while the application was starting.")));
122
+ }
123
+ }
61
124
  /**
62
125
  * Executes a single startup phase across all registered components.
63
- * Returns false if a critical component failed.
126
+ * Returns the failure of the first critical component, or undefined.
127
+ *
128
+ * Two bookkeeping rules apply to every stage:
129
+ *
130
+ * - A component that FAILED an earlier phase, or whose dependency has
131
+ * failed, does not enter this phase. It used to have `start()` and
132
+ * `ready()` invoked after its own `initialize()` had thrown, and its
133
+ * dependents were started as if the dependency were healthy —
134
+ * silently voiding the `dependsOn` contract. A skipped dependent is
135
+ * recorded as FAILED with a LifecycleComponentError naming the
136
+ * failed dependency, and its own `critical` flag decides whether
137
+ * startup aborts.
138
+ *
139
+ * - Every result of a stage is recorded, transitioned and announced
140
+ * before a critical failure aborts the phase. Returning on the first
141
+ * failed result dropped the results of siblings in the same stage,
142
+ * which were then left in INITIALIZING / STARTING forever (no
143
+ * transition leads out of those states except to their success or
144
+ * FAILED) even after rollback had disposed them.
64
145
  */
65
146
  async function executePhase(ctx, phase) {
66
147
  const plan = buildExecutionPlan(ctx.registry.getAll(), phase);
67
- const context = createLifecycleContext(phase, ctx.startTime);
148
+ const context = createLifecycleContext(phase, ctx.startTime, ctx.controller.signal);
149
+ const events = PHASE_EVENTS[phase];
150
+ ctx.events.emit(PHASE_APPLICATION_EVENT[phase], {});
68
151
  for (const stage of plan.stages) {
69
152
  const stageRegs = stage.components
70
153
  .map((id) => ctx.registry.get(id))
71
154
  .filter((r) => r !== undefined);
72
155
  if (stageRegs.length === 0)
73
156
  continue;
74
- transitionComponentBatch(ctx, stageRegs.map((r) => r.id), EXECUTING_STATE[phase]);
75
- ctx.events.emit("component:starting", {
76
- component: { componentId: stage.components.join(",") },
77
- });
78
- const results = await ctx.executor.executeStage(stageRegs, phase, context, ctx.concurrency);
157
+ assertNotShuttingDown(ctx, phase);
158
+ const runnable = [];
159
+ let criticalFailure;
160
+ for (const reg of stageRegs) {
161
+ if (ctx.componentStates.get(reg.id)?.state === LifecycleState.FAILED) {
162
+ // Already failed in an earlier phase; nothing more to run.
163
+ continue;
164
+ }
165
+ const failedDependency = reg.dependsOn.find((dep) => ctx.componentStates.get(dep)?.state === LifecycleState.FAILED);
166
+ if (failedDependency === undefined) {
167
+ runnable.push(reg);
168
+ continue;
169
+ }
170
+ const skipped = {
171
+ id: reg.id,
172
+ phase,
173
+ duration: 0,
174
+ success: false,
175
+ error: new LifecycleComponentError(reg.id, phase, new Error(`Component "${reg.id}" was not started because its dependency "${failedDependency}" failed.`)),
176
+ };
177
+ recordResult(ctx, skipped);
178
+ failComponent(ctx, reg.id);
179
+ emitComponentFailed(ctx, skipped);
180
+ if (reg.critical) {
181
+ criticalFailure ??= { id: reg.id, error: skipped.error };
182
+ }
183
+ }
184
+ if (criticalFailure) {
185
+ ctx.state.forceState(LifecycleState.FAILED);
186
+ return criticalFailure;
187
+ }
188
+ if (runnable.length === 0)
189
+ continue;
190
+ transitionComponentBatch(ctx, runnable.map((r) => r.id), EXECUTING_STATE[phase]);
191
+ for (const reg of runnable) {
192
+ markAttempted(ctx, reg.id, phase);
193
+ ctx.events.emit(events.begin, {
194
+ component: { componentId: reg.id },
195
+ });
196
+ }
197
+ const pending = ctx.executor.executeStage(runnable, phase, context, ctx.concurrency);
198
+ ctx.inFlight = pending;
199
+ let results;
200
+ try {
201
+ results = await pending;
202
+ }
203
+ finally {
204
+ ctx.inFlight = undefined;
205
+ }
79
206
  for (const result of results) {
80
- const existing = ctx.results.get(result.id) ?? [];
81
- ctx.results.set(result.id, [...existing, result]);
207
+ recordResult(ctx, result);
82
208
  if (!result.success) {
83
209
  transitionComponent(ctx, result.id, LifecycleState.FAILED);
84
210
  emitComponentFailed(ctx, result);
85
211
  if (ctx.registry.get(result.id)?.critical) {
86
- ctx.state.forceState(LifecycleState.FAILED);
87
- return false;
212
+ criticalFailure ??= { id: result.id, error: result.error };
88
213
  }
89
214
  }
90
215
  else {
91
216
  transitionComponent(ctx, result.id, SUCCESS_STATE[phase]);
92
- ctx.events.emit("component:started", {
217
+ ctx.events.emit(events.end, {
93
218
  component: { componentId: result.id, duration: result.duration },
94
219
  });
95
220
  }
96
221
  }
222
+ if (criticalFailure) {
223
+ ctx.state.forceState(LifecycleState.FAILED);
224
+ return criticalFailure;
225
+ }
97
226
  }
98
- return true;
227
+ return undefined;
99
228
  }
100
229
  //# sourceMappingURL=lifecycleManager.startup.js.map
@@ -13,7 +13,13 @@ export declare const SHUTDOWN_PHASES: readonly LifecyclePhase[];
13
13
  */
14
14
  export declare function getPhaseHookName(phase: LifecyclePhase): string;
15
15
  /**
16
- * Returns the on-prefixed hook name for a component method.
16
+ * Returns the name of the LifecycleComponent method a phase invokes.
17
+ *
18
+ * This is the same mapping as {@link getPhaseHookName}; the two names
19
+ * exist for readability at the call site. The doc comment previously
20
+ * claimed an "on-prefixed" name, which this function has never
21
+ * returned — component hooks are `initialize`, `start`, `ready`,
22
+ * `stop` and `dispose`.
17
23
  */
18
24
  export declare function getComponentMethod(phase: LifecyclePhase): string;
19
25
  //# sourceMappingURL=lifecyclePhase.core.d.ts.map
@@ -33,20 +33,15 @@ export function getPhaseHookName(phase) {
33
33
  }
34
34
  }
35
35
  /**
36
- * Returns the on-prefixed hook name for a component method.
36
+ * Returns the name of the LifecycleComponent method a phase invokes.
37
+ *
38
+ * This is the same mapping as {@link getPhaseHookName}; the two names
39
+ * exist for readability at the call site. The doc comment previously
40
+ * claimed an "on-prefixed" name, which this function has never
41
+ * returned — component hooks are `initialize`, `start`, `ready`,
42
+ * `stop` and `dispose`.
37
43
  */
38
44
  export function getComponentMethod(phase) {
39
- switch (phase) {
40
- case LifecyclePhase.INITIALIZE:
41
- return "initialize";
42
- case LifecyclePhase.START:
43
- return "start";
44
- case LifecyclePhase.READY:
45
- return "ready";
46
- case LifecyclePhase.STOP:
47
- return "stop";
48
- case LifecyclePhase.DISPOSE:
49
- return "dispose";
50
- }
45
+ return getPhaseHookName(phase);
51
46
  }
52
47
  //# sourceMappingURL=lifecyclePhase.core.js.map
@@ -12,7 +12,7 @@ export function buildExecutionPlan(registrations, phase) {
12
12
  for (const reg of registrations) {
13
13
  priorities.set(reg.id, reg.priority);
14
14
  }
15
- const graph = buildGraphForPhase(registrations, phase);
15
+ const graph = buildGraphForPhase(registrations);
16
16
  const isShutdown = phase === "stop" || phase === "dispose";
17
17
  const sorted = isShutdown
18
18
  ? reverseTopologicalSort(graph, priorities)
@@ -24,9 +24,13 @@ export function buildExecutionPlan(registrations, phase) {
24
24
  return { stages, phase };
25
25
  }
26
26
  /**
27
- * Builds a dependency graph for a specific phase.
27
+ * Builds the component dependency graph.
28
+ *
29
+ * The graph is phase-independent: startup and shutdown share the same
30
+ * edges and differ only in traversal direction, which
31
+ * buildExecutionPlan applies.
28
32
  */
29
- function buildGraphForPhase(registrations, phase) {
33
+ function buildGraphForPhase(registrations) {
30
34
  const graph = new DependencyGraph();
31
35
  for (const reg of registrations) {
32
36
  graph.addNode(reg.id);
@@ -10,11 +10,11 @@ export interface SignalHandlerOptions {
10
10
  /** Function to call when a signal is received. */
11
11
  readonly handler: () => void;
12
12
  }
13
+ /** Default signal configuration for graceful shutdown. */
14
+ export declare const DEFAULT_SHUTDOWN_SIGNALS: readonly NodeJS.Signals[];
13
15
  /**
14
16
  * Installs process signal handlers that trigger lifecycle shutdown.
15
17
  * Returns a cleanup function to remove the handlers.
16
18
  */
17
19
  export declare function installSignalHandlers(options: SignalHandlerOptions): () => void;
18
- /** Default signal configuration for graceful shutdown. */
19
- export declare const DEFAULT_SHUTDOWN_SIGNALS: readonly NodeJS.Signals[];
20
20
  //# sourceMappingURL=lifecycleSignal.handler.d.ts.map
@@ -3,12 +3,17 @@
3
3
  *
4
4
  * Process signal handler — manages SIGTERM, SIGINT, SIGHUP for graceful shutdown.
5
5
  */
6
+ /** Default signal configuration for graceful shutdown. */
7
+ export const DEFAULT_SHUTDOWN_SIGNALS = Object.freeze(["SIGINT", "SIGTERM"]);
6
8
  /**
7
9
  * Installs process signal handlers that trigger lifecycle shutdown.
8
10
  * Returns a cleanup function to remove the handlers.
9
11
  */
10
12
  export function installSignalHandlers(options) {
11
- const signals = options.signals ?? ["SIGINT", "SIGTERM"];
13
+ // DEFAULT_SHUTDOWN_SIGNALS was exported as the documented default
14
+ // while this function hard-coded its own copy of the same list, so
15
+ // changing the constant had no effect on the actual default.
16
+ const signals = options.signals ?? DEFAULT_SHUTDOWN_SIGNALS;
12
17
  const handler = options.handler;
13
18
  const installed = [];
14
19
  for (const signal of signals) {
@@ -24,6 +29,4 @@ export function installSignalHandlers(options) {
24
29
  }
25
30
  };
26
31
  }
27
- /** Default signal configuration for graceful shutdown. */
28
- export const DEFAULT_SHUTDOWN_SIGNALS = Object.freeze(["SIGINT", "SIGTERM"]);
29
32
  //# sourceMappingURL=lifecycleSignal.handler.js.map
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@zudojs/lifecycle",
3
- "version": "0.1.0",
3
+ "version": "1.1.0",
4
4
  "description": "Application and component lifecycle orchestration with state machine, dependency ordering, graceful shutdown, rollback, and signals.",
5
5
  "license": "MIT",
6
+ "author": {
7
+ "name": "Oluwayemi Oyinlola",
8
+ "url": "https://github.com/oyinlola-tech"
9
+ },
6
10
  "type": "module",
7
11
  "main": "./dist/index.js",
8
12
  "module": "./dist/index.js",
@@ -14,22 +18,18 @@
14
18
  }
15
19
  },
16
20
  "files": [
17
- "dist"
21
+ "dist",
22
+ "!dist/**/*.map",
23
+ "!dist/**/*.tsbuildinfo",
24
+ "!dist/.tsbuildinfo"
18
25
  ],
19
- "scripts": {
20
- "build": "tsc -p tsconfig.json",
21
- "typecheck": "tsc -p tsconfig.json --noEmit",
22
- "clean": "rm -rf dist",
23
- "test": "vitest run",
24
- "test:watch": "vitest"
25
- },
26
26
  "dependencies": {
27
- "@zudojs/errors": "0.1.0",
28
- "@zudojs/constants": "0.1.0"
27
+ "@zudojs/errors": "1.0.1",
28
+ "@zudojs/constants": "1.0.1"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^26.4.1",
32
- "typescript": "^7.0.2",
32
+ "typescript": "7.0.2",
33
33
  "vitest": "^4.1.11"
34
34
  },
35
35
  "engines": {
@@ -46,8 +46,19 @@
46
46
  "shutdown"
47
47
  ],
48
48
  "homepage": "https://github.com/oyinlola-tech/zudo#readme",
49
+ "bugs": {
50
+ "url": "https://github.com/oyinlola-tech/zudo/issues"
51
+ },
49
52
  "repository": {
50
53
  "type": "git",
51
- "url": "https://github.com/oyinlola-tech/zudo"
54
+ "url": "https://github.com/oyinlola-tech/zudo",
55
+ "directory": "packages/lifecycle"
56
+ },
57
+ "scripts": {
58
+ "build": "tsc -p tsconfig.json",
59
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
60
+ "clean": "rm -rf dist",
61
+ "test": "vitest run",
62
+ "test:watch": "vitest"
52
63
  }
53
- }
64
+ }
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAClE,OAAO,EACL,cAAc,EACd,eAAe,EACf,gBAAgB,EAChB,kBAAkB,GACnB,MAAM,2BAA2B,CAAC;AACnC,YAAY,EACV,kBAAkB,EAClB,4BAA4B,EAC5B,qBAAqB,EACrB,qBAAqB,GACtB,MAAM,+BAA+B,CAAC;AACvC,YAAY,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AACpE,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AACrE,OAAO,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AACjE,OAAO,EACL,eAAe,EACf,eAAe,EACf,sBAAsB,EACtB,WAAW,EACX,SAAS,EACT,eAAe,GAChB,MAAM,8BAA8B,CAAC;AACtC,YAAY,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AACrE,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAC9D,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAC9E,OAAO,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AACjE,YAAY,EAAE,eAAe,EAAE,MAAM,8BAA8B,CAAC;AACpE,OAAO,EACL,gBAAgB,EAChB,sBAAsB,GACvB,MAAM,6BAA6B,CAAC;AACrC,YAAY,EAAE,uBAAuB,EAAE,MAAM,6BAA6B,CAAC;AAC3E,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AACnE,YAAY,EACV,kBAAkB,EAClB,uBAAuB,EACvB,yBAAyB,EACzB,cAAc,EACd,sBAAsB,GACvB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,qBAAqB,EACrB,wBAAwB,GACzB,MAAM,4BAA4B,CAAC;AACpC,YAAY,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAC"}
package/dist/index.js.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAClE,OAAO,EACL,cAAc,EACd,eAAe,EACf,gBAAgB,EAChB,kBAAkB,GACnB,MAAM,2BAA2B,CAAC;AAQnC,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AACrE,OAAO,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AACjE,OAAO,EACL,eAAe,EACf,eAAe,EACf,sBAAsB,EACtB,WAAW,EACX,SAAS,EACT,eAAe,GAChB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAC;AAE9D,OAAO,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAC;AAEjE,OAAO,EACL,gBAAgB,EAChB,sBAAsB,GACvB,MAAM,6BAA6B,CAAC;AAErC,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC;AAQnE,OAAO,EACL,qBAAqB,EACrB,wBAAwB,GACzB,MAAM,4BAA4B,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lifecycleComponent/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,YAAY,EACV,kBAAkB,EAClB,4BAA4B,EAC5B,qBAAqB,EACrB,qBAAqB,GACtB,MAAM,8BAA8B,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/lifecycleComponent/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"lifecycleComponent.type.d.ts","sourceRoot":"","sources":["../../src/lifecycleComponent/lifecycleComponent.type.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,8CAA8C,CAAC;AAErF;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IACjC,8CAA8C;IAC9C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB,iFAAiF;IACjF,UAAU,CAAC,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEtD,iEAAiE;IACjE,KAAK,CAAC,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEjD,uEAAuE;IACvE,KAAK,CAAC,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEjD,yEAAyE;IACzE,IAAI,CAAC,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEhD,gEAAgE;IAChE,OAAO,CAAC,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACpD;AAED,qDAAqD;AACrD,MAAM,WAAW,4BAA4B;IAC3C,gEAAgE;IAChE,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IAErB,yDAAyD;IACzD,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAEvC,gFAAgF;IAChF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAE3B,sFAAsF;IACtF,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAE5B,yDAAyD;IACzD,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAE1B,iDAAiD;IACjD,QAAQ,CAAC,KAAK,CAAC,EAAE,qBAAqB,CAAC;CACxC;AAED,8CAA8C;AAC9C,MAAM,WAAW,qBAAqB;IACpC,wCAAwC;IACxC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAE3B,mCAAmC;IACnC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAExB,2CAA2C;IAC3C,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAE3B,wBAAwB;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,aAAa,CAAC;CAC5C;AAED,kDAAkD;AAClD,MAAM,WAAW,qBAAqB;IACpC,iBAAiB;IACjB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IAEpB,8BAA8B;IAC9B,QAAQ,CAAC,SAAS,EAAE,kBAAkB,CAAC;IAEvC,2BAA2B;IAC3B,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IAEtC,6CAA6C;IAC7C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAE1B,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAE3B,+CAA+C;IAC/C,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAEzB,2BAA2B;IAC3B,QAAQ,CAAC,KAAK,EAAE,qBAAqB,CAAC;CACvC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"lifecycleComponent.type.js","sourceRoot":"","sources":["../../src/lifecycleComponent/lifecycleComponent.type.ts"],"names":[],"mappings":"AAAA;;;;GAIG"}