@zudojs/lifecycle 1.1.1 → 1.2.1

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 CHANGED
@@ -124,6 +124,13 @@ createLifecycleManager({
124
124
  Per-component: `id`, `dependsOn`, `priority`, `critical`, `timeout`,
125
125
  `retry: { attempts, delay, maxDelay, backoff }`.
126
126
 
127
+ `priority` orders components that share a dependency level, and it is a
128
+ barrier rather than a hint: every component at one priority completes the
129
+ phase before the next priority starts, so `priority: 10` really does start
130
+ before `priority: 0`. Components sharing a priority still run together, up
131
+ to `concurrency`. Shutdown mirrors it — within a level the lowest priority
132
+ stops first and the highest stops last.
133
+
127
134
  `timeout` and `shutdownTimeout` accept `Infinity` for "no bound"; NaN and
128
135
  negative values throw a `RangeError` when registered or constructed, and
129
136
  finite values above 2^31-1 ms are clamped to the largest timer delay.
@@ -28,7 +28,14 @@ export interface LifecycleRegistrationOptions {
28
28
  readonly id?: string;
29
29
  /** IDs of components that must start before this one. */
30
30
  readonly dependsOn?: readonly string[];
31
- /** Priority for ordering within the same dependency level. Higher = earlier. */
31
+ /**
32
+ * Priority for ordering within the same dependency level. Higher = earlier.
33
+ *
34
+ * Priority is a barrier, not a hint: every component at one priority
35
+ * finishes the phase before the next priority begins, and shutdown
36
+ * runs the mirror image (lowest priority stops first). Components
37
+ * sharing a priority still run concurrently.
38
+ */
32
39
  readonly priority?: number;
33
40
  /** If true, application startup fails when this component fails. Defaults to true. */
34
41
  readonly critical?: boolean;
@@ -37,7 +37,15 @@ export declare class LifecycleExecutor {
37
37
  */
38
38
  execute(registration: LifecycleRegistration, phase: LifecyclePhase, context: LifecycleContext): Promise<ExecutionResult>;
39
39
  /**
40
- * Executes a stage of components in parallel with concurrency limit.
40
+ * Executes a stage of components, honouring priority as a barrier.
41
+ *
42
+ * The stage arrives already ordered by priority (descending for
43
+ * startup, ascending for shutdown). Components sharing a priority run
44
+ * together, limited by `concurrency`; the next priority group only
45
+ * begins once the previous one has settled. Launching the whole stage
46
+ * concurrently made `priority` observable only at `concurrency: 1`,
47
+ * so a `priority: 100` component documented as starting first lost
48
+ * the race to any sibling with a faster hook.
41
49
  */
42
50
  executeStage(registrations: readonly LifecycleRegistration[], phase: LifecyclePhase, context: LifecycleContext, concurrency: number): Promise<readonly ExecutionResult[]>;
43
51
  }
@@ -96,17 +96,49 @@ export class LifecycleExecutor {
96
96
  };
97
97
  }
98
98
  /**
99
- * Executes a stage of components in parallel with concurrency limit.
99
+ * Executes a stage of components, honouring priority as a barrier.
100
+ *
101
+ * The stage arrives already ordered by priority (descending for
102
+ * startup, ascending for shutdown). Components sharing a priority run
103
+ * together, limited by `concurrency`; the next priority group only
104
+ * begins once the previous one has settled. Launching the whole stage
105
+ * concurrently made `priority` observable only at `concurrency: 1`,
106
+ * so a `priority: 100` component documented as starting first lost
107
+ * the race to any sibling with a faster hook.
100
108
  */
101
109
  async executeStage(registrations, phase, context, concurrency) {
102
110
  const results = [];
103
- await withConcurrency(registrations, concurrency, async (reg) => {
104
- const result = await this.execute(reg, phase, context);
105
- results.push(result);
106
- });
111
+ for (const batch of groupByPriority(registrations)) {
112
+ await withConcurrency(batch, concurrency, async (reg) => {
113
+ const result = await this.execute(reg, phase, context);
114
+ results.push(result);
115
+ });
116
+ }
107
117
  return results;
108
118
  }
109
119
  }
120
+ /**
121
+ * Splits an already-ordered stage into runs of equal priority.
122
+ *
123
+ * Consecutive grouping preserves whatever order the execution plan
124
+ * produced, so a caller that does not care about priority (every
125
+ * component at the default 0) still gets a single fully concurrent
126
+ * batch.
127
+ */
128
+ function groupByPriority(registrations) {
129
+ const batches = [];
130
+ let current;
131
+ let currentPriority;
132
+ for (const reg of registrations) {
133
+ if (current === undefined || reg.priority !== currentPriority) {
134
+ current = [];
135
+ currentPriority = reg.priority;
136
+ batches.push(current);
137
+ }
138
+ current.push(reg);
139
+ }
140
+ return batches;
141
+ }
110
142
  /** Calculates retry delay with backoff. */
111
143
  function calculateDelay(config, attempt) {
112
144
  const base = config.delay ?? 500;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Async utilities for timeout, abort, and concurrency control.
5
5
  */
6
- import { LifecycleTimeoutError } from "@zudojs/errors";
6
+ import { ErrorCode, LifecycleError, LifecycleTimeoutError, } from "@zudojs/errors";
7
7
  import { assertTimeoutBudget, isBounded, toTimerDelay, } from "./timeoutBudget.core.js";
8
8
  /**
9
9
  * Executes an async operation with a timeout.
@@ -49,11 +49,11 @@ export async function withTimeout(fn, timeoutMs, componentId, phase) {
49
49
  */
50
50
  export async function withAbort(fn, signal) {
51
51
  if (signal.aborted) {
52
- throw new Error("Operation aborted");
52
+ throw abortError();
53
53
  }
54
54
  return new Promise((resolve, reject) => {
55
55
  const onAbort = () => {
56
- reject(new Error("Operation aborted"));
56
+ reject(abortError());
57
57
  };
58
58
  signal.addEventListener("abort", onAbort, { once: true });
59
59
  // A synchronously throwing `fn` rejected via the Promise executor
@@ -79,6 +79,12 @@ export async function withAbort(fn, signal) {
79
79
  });
80
80
  });
81
81
  }
82
+ /** The typed error surfaced when a `withAbort` operation is cancelled. */
83
+ function abortError() {
84
+ return new LifecycleError("Operation aborted", {
85
+ code: ErrorCode.LIFECYCLE_COMPONENT,
86
+ });
87
+ }
82
88
  /**
83
89
  * Executes async operations with a concurrency limit.
84
90
  */
@@ -14,6 +14,13 @@ export type TopologicalStage = readonly string[];
14
14
  export declare function topologicalSort(graph: DependencyGraph, priorities?: ReadonlyMap<string, number>): readonly TopologicalStage[];
15
15
  /**
16
16
  * Performs reverse topological sort for shutdown ordering.
17
+ *
18
+ * Both the stage list AND each stage's contents are reversed, so a
19
+ * shutdown is the exact mirror of the startup order: within a stage the
20
+ * lowest-priority component is torn down first and the highest-priority
21
+ * one last. Only the stage list used to be reversed, which left every
22
+ * stage in descending-priority order — harmless while stages ran fully
23
+ * concurrently, but wrong now that priority is a real sub-stage barrier.
17
24
  */
18
25
  export declare function reverseTopologicalSort(graph: DependencyGraph, priorities?: ReadonlyMap<string, number>): readonly TopologicalStage[];
19
26
  //# sourceMappingURL=topologicalSort.core.d.ts.map
@@ -42,9 +42,16 @@ export function topologicalSort(graph, priorities) {
42
42
  }
43
43
  /**
44
44
  * Performs reverse topological sort for shutdown ordering.
45
+ *
46
+ * Both the stage list AND each stage's contents are reversed, so a
47
+ * shutdown is the exact mirror of the startup order: within a stage the
48
+ * lowest-priority component is torn down first and the highest-priority
49
+ * one last. Only the stage list used to be reversed, which left every
50
+ * stage in descending-priority order — harmless while stages ran fully
51
+ * concurrently, but wrong now that priority is a real sub-stage barrier.
45
52
  */
46
53
  export function reverseTopologicalSort(graph, priorities) {
47
54
  const stages = topologicalSort(graph, priorities);
48
- return Object.freeze([...stages].reverse().map((stage) => Object.freeze([...stage])));
55
+ return Object.freeze([...stages].reverse().map((stage) => Object.freeze([...stage].reverse())));
49
56
  }
50
57
  //# sourceMappingURL=topologicalSort.core.js.map
@@ -66,6 +66,14 @@ async function runShutdown(ctx) {
66
66
  catch {
67
67
  // Shutdown must continue even if individual components fail.
68
68
  }
69
+ // A stop()/dispose() hook that blew its own component timeout joins
70
+ // the abandoned set DURING this phase, so the pre-phase settle above
71
+ // cannot have covered it. Without this wait, DISPOSE ran on top of a
72
+ // stop() that was still draining and shutdown() resolved (reporting
73
+ // DISPOSED) while the hook kept running — the exact overlap the
74
+ // pre-phase settle was added to prevent. Still bounded by the global
75
+ // shutdown deadline.
76
+ await raceDeadline(ctx, ctx.executor.settleAbandoned(), Math.max(deadline - Date.now(), 1));
69
77
  }
70
78
  ctx.state.forceState(LifecycleState.DISPOSED);
71
79
  ctx.events.emit("application:stopped", {
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { LIFECYCLE_DEFAULT_TIMEOUT } from "@zudojs/constants";
7
7
  import { DependencyGraph, assertTimeoutBudget, } from "../lifecycleInternal/index.js";
8
+ import { ErrorCode, LifecycleError } from "@zudojs/errors";
8
9
  /**
9
10
  * Registry for lifecycle components.
10
11
  * Validates registration, builds dependency graph, and freezes on demand.
@@ -16,14 +17,17 @@ export class LifecycleRegistry {
16
17
  /** Registers a component with optional configuration. */
17
18
  register(component, options = {}) {
18
19
  if (this._frozen) {
19
- throw new Error("Cannot register components after registry is frozen");
20
+ throw new LifecycleError("Cannot register components after registry is frozen", { code: ErrorCode.LIFECYCLE_COMPONENT });
20
21
  }
21
22
  const id = options.id ?? component.name;
22
23
  if (options.timeout !== undefined) {
23
24
  assertTimeoutBudget(`Component "${id}" timeout`, options.timeout);
24
25
  }
25
26
  if (this._registrations.has(id)) {
26
- throw new Error(`Component "${id}" is already registered`);
27
+ throw new LifecycleError(`Component "${id}" is already registered`, {
28
+ code: ErrorCode.LIFECYCLE_COMPONENT,
29
+ componentId: id,
30
+ });
27
31
  }
28
32
  const registration = {
29
33
  id,
@@ -51,7 +55,11 @@ export class LifecycleRegistry {
51
55
  for (const [id, reg] of this._registrations) {
52
56
  for (const dep of reg.dependsOn) {
53
57
  if (!this._registrations.has(dep)) {
54
- throw new Error(`Component "${id}" depends on "${dep}" which is not registered`);
58
+ throw new LifecycleError(`Component "${id}" depends on "${dep}" which is not registered`, {
59
+ code: ErrorCode.LIFECYCLE_DEPENDENCY,
60
+ componentId: id,
61
+ metadata: { dependency: dep },
62
+ });
55
63
  }
56
64
  }
57
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/lifecycle",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Application and component lifecycle orchestration with state machine, dependency ordering, graceful shutdown, rollback, and signals.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -24,13 +24,13 @@
24
24
  "!dist/.tsbuildinfo"
25
25
  ],
26
26
  "dependencies": {
27
- "@zudojs/errors": "1.1.0",
28
- "@zudojs/constants": "1.1.0"
27
+ "@zudojs/errors": "1.3.0",
28
+ "@zudojs/constants": "1.1.2"
29
29
  },
30
30
  "devDependencies": {
31
- "@types/node": "^26.4.1",
31
+ "@types/node": "^26.6.2",
32
32
  "typescript": "7.0.2",
33
- "vitest": "^4.1.11"
33
+ "vitest": "^5.0.1"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=24.0.0"
@@ -45,7 +45,7 @@
45
45
  "startup",
46
46
  "shutdown"
47
47
  ],
48
- "homepage": "https://github.com/oyinlola-tech/zudo#readme",
48
+ "homepage": "https://zudojs.oyinlola.site/docs/packages-lifecycle",
49
49
  "bugs": {
50
50
  "url": "https://github.com/oyinlola-tech/zudo/issues"
51
51
  },