@zudojs/lifecycle 1.2.3 → 1.3.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 (70) hide show
  1. package/README.md +44 -15
  2. package/dist/index.d.ts +2 -3
  3. package/dist/index.js +7 -1
  4. package/dist/lifecycleComponent/index.d.ts +0 -1
  5. package/dist/lifecycleComponent/index.js +0 -1
  6. package/dist/lifecycleComponent/lifecycleComponent.type.d.ts +27 -8
  7. package/dist/lifecycleComponent/lifecycleComponent.type.js +0 -1
  8. package/dist/lifecycleContext/index.d.ts +0 -1
  9. package/dist/lifecycleContext/index.js +0 -1
  10. package/dist/lifecycleContext/lifecycleContext.type.d.ts +0 -1
  11. package/dist/lifecycleContext/lifecycleContext.type.js +0 -1
  12. package/dist/lifecycleEvents/index.d.ts +0 -1
  13. package/dist/lifecycleEvents/index.js +0 -1
  14. package/dist/lifecycleEvents/lifecycleEvents.core.d.ts +20 -5
  15. package/dist/lifecycleEvents/lifecycleEvents.core.js +0 -1
  16. package/dist/lifecycleExecutor/index.d.ts +3 -3
  17. package/dist/lifecycleExecutor/index.js +2 -2
  18. package/dist/lifecycleExecutor/lifecycleExecutor.abandoned.d.ts +31 -0
  19. package/dist/lifecycleExecutor/lifecycleExecutor.abandoned.js +75 -0
  20. package/dist/lifecycleExecutor/lifecycleExecutor.core.d.ts +18 -24
  21. package/dist/lifecycleExecutor/lifecycleExecutor.core.js +64 -105
  22. package/dist/lifecycleExecutor/lifecycleExecutor.retry.d.ts +30 -0
  23. package/dist/lifecycleExecutor/lifecycleExecutor.retry.js +66 -0
  24. package/dist/lifecycleExecutor/lifecycleExecutor.type.d.ts +50 -0
  25. package/dist/lifecycleExecutor/lifecycleExecutor.type.js +6 -0
  26. package/dist/lifecycleInternal/asyncUtils.core.d.ts +0 -1
  27. package/dist/lifecycleInternal/asyncUtils.core.js +0 -1
  28. package/dist/lifecycleInternal/dependencyGraph.core.d.ts +34 -7
  29. package/dist/lifecycleInternal/dependencyGraph.core.js +55 -38
  30. package/dist/lifecycleInternal/dependencyGraph.cycle.d.ts +20 -0
  31. package/dist/lifecycleInternal/dependencyGraph.cycle.js +57 -0
  32. package/dist/lifecycleInternal/index.d.ts +1 -1
  33. package/dist/lifecycleInternal/index.js +0 -1
  34. package/dist/lifecycleInternal/timeoutBudget.core.d.ts +0 -1
  35. package/dist/lifecycleInternal/timeoutBudget.core.js +0 -1
  36. package/dist/lifecycleInternal/topologicalSort.core.d.ts +12 -2
  37. package/dist/lifecycleInternal/topologicalSort.core.js +41 -22
  38. package/dist/lifecycleManager/index.d.ts +0 -1
  39. package/dist/lifecycleManager/index.js +0 -1
  40. package/dist/lifecycleManager/lifecycleManager.context.d.ts +15 -7
  41. package/dist/lifecycleManager/lifecycleManager.context.js +0 -1
  42. package/dist/lifecycleManager/lifecycleManager.core.d.ts +12 -2
  43. package/dist/lifecycleManager/lifecycleManager.core.js +30 -3
  44. package/dist/lifecycleManager/lifecycleManager.deadline.d.ts +29 -0
  45. package/dist/lifecycleManager/lifecycleManager.deadline.js +79 -0
  46. package/dist/lifecycleManager/lifecycleManager.shutdown.d.ts +0 -1
  47. package/dist/lifecycleManager/lifecycleManager.shutdown.js +60 -69
  48. package/dist/lifecycleManager/lifecycleManager.startup.d.ts +0 -1
  49. package/dist/lifecycleManager/lifecycleManager.startup.js +12 -4
  50. package/dist/lifecyclePhase/index.d.ts +0 -1
  51. package/dist/lifecyclePhase/index.js +0 -1
  52. package/dist/lifecyclePhase/lifecyclePhase.core.d.ts +0 -1
  53. package/dist/lifecyclePhase/lifecyclePhase.core.js +0 -1
  54. package/dist/lifecyclePlan/index.d.ts +0 -1
  55. package/dist/lifecyclePlan/index.js +0 -1
  56. package/dist/lifecyclePlan/lifecyclePlan.core.d.ts +0 -1
  57. package/dist/lifecyclePlan/lifecyclePlan.core.js +0 -2
  58. package/dist/lifecycleRegistry/index.d.ts +0 -1
  59. package/dist/lifecycleRegistry/index.js +0 -1
  60. package/dist/lifecycleRegistry/lifecycleRegistry.core.d.ts +0 -1
  61. package/dist/lifecycleRegistry/lifecycleRegistry.core.js +3 -2
  62. package/dist/lifecycleSignal/index.d.ts +0 -1
  63. package/dist/lifecycleSignal/index.js +0 -1
  64. package/dist/lifecycleSignal/lifecycleSignal.handler.d.ts +0 -1
  65. package/dist/lifecycleSignal/lifecycleSignal.handler.js +0 -1
  66. package/dist/lifecycleState/index.d.ts +0 -1
  67. package/dist/lifecycleState/index.js +0 -1
  68. package/dist/lifecycleState/lifecycleState.machine.d.ts +0 -1
  69. package/dist/lifecycleState/lifecycleState.machine.js +0 -1
  70. package/package.json +3 -3
@@ -6,93 +6,90 @@
6
6
  import { withTimeout, withConcurrency } from "../lifecycleInternal/index.js";
7
7
  import { getComponentMethod } from "../lifecyclePhase/index.js";
8
8
  import { LifecycleComponentError, LifecycleTimeoutError, } from "@zudojs/errors";
9
+ import { AbandonedHooks, isShutdownPhase, isStartupPhase, } from "./lifecycleExecutor.abandoned.js";
10
+ import { calculateDelay, groupByPriority, sleep } from "./lifecycleExecutor.retry.js";
9
11
  /**
10
12
  * Executes lifecycle component hooks with timeout, retry, and concurrency support.
13
+ *
14
+ * Every invocation gets its own AbortSignal, derived from the run
15
+ * signal and aborted when the component's `timeout` elapses, so a hook
16
+ * that honours `context.signal` unwinds promptly. One that ignores it
17
+ * keeps running and is tracked per component: before that component's
18
+ * `stop()`/`dispose()` the executor waits for it — a drain that overran
19
+ * its stop timeout is waited for until the global deadline, a startup
20
+ * hook that ignored its timeout only for one more `timeout` — so hooks
21
+ * of one component never overlap.
11
22
  */
12
23
  export class LifecycleExecutor {
13
- /** Hook invocations still running after their timeout fired. */
14
- abandoned = new Set();
24
+ abandoned = new AbandonedHooks();
25
+ onRetry;
26
+ constructor(options = {}) {
27
+ this.onRetry = options.onRetry;
28
+ }
15
29
  /**
16
- * Resolves once every hook abandoned by a timeout has settled.
17
- *
18
- * Shutdown waits on this before stopping components, so `stop()`
19
- * never overlaps a `start()` that is still running.
30
+ * Resolves once every hook abandoned by a timeout has settled — those
31
+ * of one component when `id` is given, otherwise all of them.
20
32
  */
21
- async settleAbandoned() {
22
- while (this.abandoned.size > 0) {
23
- await Promise.allSettled([...this.abandoned]);
24
- }
33
+ settleAbandoned(id) {
34
+ return this.abandoned.settle(id);
25
35
  }
26
36
  /**
27
37
  * Executes a single component hook.
28
38
  */
29
39
  async execute(registration, phase, context) {
30
- const methodName = getComponentMethod(phase);
31
- const hook = registration.component[methodName];
40
+ const { id } = registration;
41
+ const hook = registration.component[getComponentMethod(phase)];
32
42
  if (typeof hook !== "function") {
33
- return {
34
- id: registration.id,
35
- phase,
36
- duration: 0,
37
- success: true,
38
- };
43
+ return { id, phase, duration: 0, success: true };
44
+ }
45
+ if (isShutdownPhase(phase)) {
46
+ await this.abandoned.settle(id, isShutdownPhase, context.signal);
39
47
  }
40
48
  const startTime = Date.now();
49
+ const maxAttempts = 1 + (registration.retry.attempts ?? 0);
41
50
  let lastError;
42
- const retryConfig = registration.retry;
43
- const maxAttempts = 1 + (retryConfig.attempts ?? 0);
51
+ let timedOut = false;
44
52
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
45
- // A hook must not be retried (or even started) once the run has
46
- // been cancelled — the context signal is aborted by the shutdown
47
- // deadline and by startup rollback.
48
53
  if (context.signal.aborted) {
49
- lastError ??= new LifecycleComponentError(registration.id, phase, context.signal.reason);
54
+ lastError ??= new LifecycleComponentError(id, phase, context.signal.reason);
50
55
  break;
51
56
  }
57
+ const controller = new AbortController();
52
58
  let invocation;
53
59
  try {
54
60
  await withTimeout(() => {
55
- invocation = (async () => {
56
- await hook.call(registration.component, context);
57
- })();
61
+ invocation = this.invoke(registration, phase, hook, {
62
+ ...context,
63
+ signal: AbortSignal.any([context.signal, controller.signal]),
64
+ });
58
65
  return invocation;
59
- }, registration.timeout, registration.id, phase);
60
- return {
61
- id: registration.id,
62
- phase,
63
- duration: Date.now() - startTime,
64
- success: true,
65
- };
66
+ }, registration.timeout, id, phase);
67
+ return { id, phase, duration: Date.now() - startTime, success: true };
66
68
  }
67
69
  catch (error) {
68
70
  lastError = error;
69
- // A timed-out hook is still running; withTimeout cannot cancel
70
- // it. Retrying would run the same start() concurrently (three
71
- // listen() calls on one port), so a timeout is final and the
72
- // abandoned invocation is tracked for shutdown to wait on.
73
71
  if (error instanceof LifecycleTimeoutError && invocation) {
74
- const abandoned = invocation.catch(() => undefined);
75
- this.abandoned.add(abandoned);
76
- void abandoned.finally(() => this.abandoned.delete(abandoned));
72
+ timedOut = true;
73
+ controller.abort(error);
74
+ this.abandoned.track(id, phase, invocation);
77
75
  break;
78
76
  }
79
77
  if (attempt < maxAttempts - 1) {
80
- const delay = calculateDelay(retryConfig, attempt);
78
+ const delay = calculateDelay(registration.retry, attempt);
79
+ this.notifyRetry({ id, phase, attempt: attempt + 1, delay, error });
81
80
  await sleep(delay, context.signal);
82
81
  }
83
82
  }
84
83
  }
85
84
  return {
86
- id: registration.id,
85
+ id,
87
86
  phase,
88
87
  duration: Date.now() - startTime,
89
- // LifecycleComponentError was imported but never constructed, so
90
- // callers received a bare hook error with no indication of which
91
- // component or phase produced it.
92
88
  error: lastError instanceof LifecycleComponentError
93
89
  ? lastError
94
- : new LifecycleComponentError(registration.id, phase, lastError),
90
+ : new LifecycleComponentError(id, phase, lastError),
95
91
  success: false,
92
+ ...(timedOut && { timedOut: true }),
96
93
  };
97
94
  }
98
95
  /**
@@ -101,75 +98,37 @@ export class LifecycleExecutor {
101
98
  * The stage arrives already ordered by priority (descending for
102
99
  * startup, ascending for shutdown). Components sharing a priority run
103
100
  * 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.
101
+ * begins once the previous one has settled.
108
102
  */
109
103
  async executeStage(registrations, phase, context, concurrency) {
110
104
  const results = [];
111
105
  for (const batch of groupByPriority(registrations)) {
112
106
  await withConcurrency(batch, concurrency, async (reg) => {
113
- const result = await this.execute(reg, phase, context);
114
- results.push(result);
107
+ results.push(await this.execute(reg, phase, context));
115
108
  });
116
109
  }
117
110
  return results;
118
111
  }
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);
112
+ async invoke(registration, phase, hook, context) {
113
+ if (isShutdownPhase(phase)) {
114
+ await this.abandoned.settle(registration.id, isStartupPhase, context.signal);
115
+ // The wait was cut short by the component timeout (or the run
116
+ // deadline): the earlier hook is still running, so this one must
117
+ // not start on top of it.
118
+ if (context.signal.aborted) {
119
+ throw context.signal.reason instanceof Error
120
+ ? context.signal.reason
121
+ : new LifecycleComponentError(registration.id, phase, context.signal.reason);
122
+ }
137
123
  }
138
- current.push(reg);
124
+ await hook.call(registration.component, context);
139
125
  }
140
- return batches;
141
- }
142
- /** Calculates retry delay with backoff. */
143
- function calculateDelay(config, attempt) {
144
- const base = config.delay ?? 500;
145
- const max = config.maxDelay ?? 10_000;
146
- const backoff = config.backoff ?? "exponential";
147
- if (backoff === "exponential") {
148
- return Math.min(base * 2 ** attempt, max);
149
- }
150
- return base;
151
- }
152
- /**
153
- * Sleeps for the given duration, waking early when the run is aborted.
154
- *
155
- * An unconditional timer would keep the process alive for a full retry
156
- * backoff after shutdown had already been requested.
157
- */
158
- function sleep(ms, signal) {
159
- return new Promise((resolve) => {
160
- if (signal.aborted) {
161
- resolve();
162
- return;
126
+ notifyRetry(notice) {
127
+ try {
128
+ this.onRetry?.(notice);
163
129
  }
164
- const timer = setTimeout(() => {
165
- signal.removeEventListener("abort", onAbort);
166
- resolve();
167
- }, ms);
168
- function onAbort() {
169
- clearTimeout(timer);
170
- resolve();
130
+ catch {
131
+ // A listener must not break the retry loop.
171
132
  }
172
- signal.addEventListener("abort", onAbort, { once: true });
173
- });
133
+ }
174
134
  }
175
- //# sourceMappingURL=lifecycleExecutor.core.js.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @zudojs/lifecycle/executor/retry
3
+ *
4
+ * Retry delay, cancellable sleep and priority batching for the executor.
5
+ */
6
+ import type { LifecycleRegistration, LifecycleRetryOptions } from "../lifecycleComponent/lifecycleComponent.type.js";
7
+ /**
8
+ * Calculates the delay before retry number `attempt + 1`.
9
+ *
10
+ * `attempt` is 0 for the first retry. Exponential backoff doubles the
11
+ * base delay per retry and is capped at `maxDelay`; fixed backoff
12
+ * always waits `delay`.
13
+ */
14
+ export declare function calculateDelay(config: LifecycleRetryOptions, attempt: number): number;
15
+ /**
16
+ * Sleeps for the given duration, waking early when the run is aborted.
17
+ *
18
+ * An unconditional timer would keep the process alive for a full retry
19
+ * backoff after shutdown had already been requested.
20
+ */
21
+ export declare function sleep(ms: number, signal: AbortSignal): Promise<void>;
22
+ /**
23
+ * Splits an already-ordered stage into runs of equal priority.
24
+ *
25
+ * Consecutive grouping preserves whatever order the execution plan
26
+ * produced, so a caller that does not care about priority (every
27
+ * component at the default 0) still gets a single fully concurrent
28
+ * batch.
29
+ */
30
+ export declare function groupByPriority(registrations: readonly LifecycleRegistration[]): readonly (readonly LifecycleRegistration[])[];
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @zudojs/lifecycle/executor/retry
3
+ *
4
+ * Retry delay, cancellable sleep and priority batching for the executor.
5
+ */
6
+ /**
7
+ * Calculates the delay before retry number `attempt + 1`.
8
+ *
9
+ * `attempt` is 0 for the first retry. Exponential backoff doubles the
10
+ * base delay per retry and is capped at `maxDelay`; fixed backoff
11
+ * always waits `delay`.
12
+ */
13
+ export function calculateDelay(config, attempt) {
14
+ const base = config.delay ?? 500;
15
+ const max = config.maxDelay ?? 10_000;
16
+ const backoff = config.backoff ?? "exponential";
17
+ if (backoff === "exponential") {
18
+ return Math.min(base * 2 ** attempt, max);
19
+ }
20
+ return base;
21
+ }
22
+ /**
23
+ * Sleeps for the given duration, waking early when the run is aborted.
24
+ *
25
+ * An unconditional timer would keep the process alive for a full retry
26
+ * backoff after shutdown had already been requested.
27
+ */
28
+ export function sleep(ms, signal) {
29
+ return new Promise((resolve) => {
30
+ if (signal.aborted) {
31
+ resolve();
32
+ return;
33
+ }
34
+ const timer = setTimeout(() => {
35
+ signal.removeEventListener("abort", onAbort);
36
+ resolve();
37
+ }, ms);
38
+ function onAbort() {
39
+ clearTimeout(timer);
40
+ resolve();
41
+ }
42
+ signal.addEventListener("abort", onAbort, { once: true });
43
+ });
44
+ }
45
+ /**
46
+ * Splits an already-ordered stage into runs of equal priority.
47
+ *
48
+ * Consecutive grouping preserves whatever order the execution plan
49
+ * produced, so a caller that does not care about priority (every
50
+ * component at the default 0) still gets a single fully concurrent
51
+ * batch.
52
+ */
53
+ export function groupByPriority(registrations) {
54
+ const batches = [];
55
+ let current;
56
+ let currentPriority;
57
+ for (const reg of registrations) {
58
+ if (current === undefined || reg.priority !== currentPriority) {
59
+ current = [];
60
+ currentPriority = reg.priority;
61
+ batches.push(current);
62
+ }
63
+ current.push(reg);
64
+ }
65
+ return batches;
66
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @zudojs/lifecycle/executor/types
3
+ *
4
+ * Result and option types for the lifecycle executor.
5
+ */
6
+ import type { LifecyclePhase } from "@zudojs/constants";
7
+ /** Result of executing a component hook. */
8
+ export interface ExecutionResult {
9
+ /** Component ID. */
10
+ readonly id: string;
11
+ /** The phase that was executed. */
12
+ readonly phase: LifecyclePhase;
13
+ /** Duration in ms. */
14
+ readonly duration: number;
15
+ /** Error if the hook failed. */
16
+ readonly error?: unknown;
17
+ /** Whether the operation succeeded. */
18
+ readonly success: boolean;
19
+ /**
20
+ * True when the operation exceeded the component's `timeout`.
21
+ *
22
+ * For a startup phase that is the hook itself; for `stop`/`dispose`
23
+ * it also covers the wait for the component's own earlier hook that
24
+ * is still running after its timeout. A timed-out hook keeps running
25
+ * (it cannot be cancelled), is never retried, and is tracked so the
26
+ * component's next hook does not overlap it.
27
+ */
28
+ readonly timedOut?: boolean;
29
+ }
30
+ /** Details of a retry the executor is about to perform. */
31
+ export interface LifecycleRetryNotice {
32
+ /** Component ID. */
33
+ readonly id: string;
34
+ /** The phase being retried. */
35
+ readonly phase: LifecyclePhase;
36
+ /** 1-based number of the retry about to run (1 = first retry). */
37
+ readonly attempt: number;
38
+ /** Milliseconds the executor waits before that retry. */
39
+ readonly delay: number;
40
+ /** The error that triggered the retry. */
41
+ readonly error: unknown;
42
+ }
43
+ /** Options for creating a lifecycle executor. */
44
+ export interface LifecycleExecutorOptions {
45
+ /**
46
+ * Called before each retry delay. The lifecycle manager forwards it
47
+ * as the `component:retrying` event. Exceptions are swallowed.
48
+ */
49
+ readonly onRetry?: (notice: LifecycleRetryNotice) => void;
50
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @zudojs/lifecycle/executor/types
3
+ *
4
+ * Result and option types for the lifecycle executor.
5
+ */
6
+ export {};
@@ -20,4 +20,3 @@ export declare function withAbort<T>(fn: (signal: AbortSignal) => Promise<T>, si
20
20
  * Executes async operations with a concurrency limit.
21
21
  */
22
22
  export declare function withConcurrency<T>(items: readonly T[], concurrency: number, fn: (item: T, index: number) => Promise<void>): Promise<void>;
23
- //# sourceMappingURL=asyncUtils.core.d.ts.map
@@ -125,4 +125,3 @@ export async function withConcurrency(items, concurrency, fn) {
125
125
  throw firstError;
126
126
  }
127
127
  }
128
- //# sourceMappingURL=asyncUtils.core.js.map
@@ -3,27 +3,54 @@
3
3
  *
4
4
  * Directed acyclic graph for component dependency tracking.
5
5
  */
6
+ /** Options for {@link DependencyGraph.validate}. */
7
+ export interface DependencyGraphValidationOptions {
8
+ /**
9
+ * Also reject nodes that only appear as an edge endpoint and were
10
+ * never passed to `addNode`. Off by default, because `addEdge` has
11
+ * always created its endpoints; turn it on when the node set is
12
+ * known up front (the registry does its own equivalent check on
13
+ * `dependsOn` and names the missing registration).
14
+ */
15
+ readonly requireDeclared?: boolean;
16
+ }
6
17
  /**
7
18
  * A directed acyclic graph of component dependencies.
19
+ *
20
+ * `addEdge` creates any endpoint it has not seen, so an undeclared
21
+ * node (a typo in a dependency id, say) sorts as a leaf in the first
22
+ * stage. The graph remembers which nodes were declared with `addNode`:
23
+ * inspect them with `getUndeclaredNodes()` or reject them with
24
+ * `validate({ requireDeclared: true })`.
8
25
  */
9
26
  export declare class DependencyGraph {
10
27
  private readonly _edges;
11
28
  private readonly _reverseEdges;
12
- /** Adds a node to the graph. */
29
+ private readonly _declared;
30
+ /** Declares a node in the graph. */
13
31
  addNode(id: string): void;
14
32
  /** Adds a directed edge: from depends on to. */
15
33
  addEdge(from: string, to: string): void;
16
- /** Returns all nodes. */
34
+ /** Returns all nodes, declared or not, in insertion order. */
17
35
  getNodes(): readonly string[];
18
36
  /** Returns the nodes that the given node depends on. */
19
37
  getDependencies(id: string): readonly string[];
20
38
  /** Returns the nodes that depend on the given node. */
21
39
  getDependents(id: string): readonly string[];
40
+ /** Returns the nodes referenced by an edge but never passed to `addNode`. */
41
+ getUndeclaredNodes(): readonly string[];
42
+ /**
43
+ * Validates that the graph has no circular dependencies, throwing
44
+ * LifecycleDependencyError with the cycle path if one is found. With
45
+ * `requireDeclared` it first throws LifecycleError
46
+ * (LIFECYCLE_DEPENDENCY) for an edge to a node that was never added.
47
+ */
48
+ validate(options?: DependencyGraphValidationOptions): void;
22
49
  /**
23
- * Validates that the graph has no circular dependencies.
24
- * Throws LifecycleDependencyError with the cycle path if found.
50
+ * Returns one dependency cycle as a closed path (first id repeated
51
+ * last), or undefined when the graph is acyclic.
25
52
  */
26
- validate(): void;
27
- private _detectCycle;
53
+ findCycle(): readonly string[] | undefined;
54
+ private ensureNode;
55
+ private assertDeclared;
28
56
  }
29
- //# sourceMappingURL=dependencyGraph.core.d.ts.map
@@ -3,30 +3,34 @@
3
3
  *
4
4
  * Directed acyclic graph for component dependency tracking.
5
5
  */
6
- import { LifecycleDependencyError } from "@zudojs/errors";
6
+ import { ErrorCode, LifecycleDependencyError, LifecycleError, } from "@zudojs/errors";
7
+ import { findDependencyCycle } from "./dependencyGraph.cycle.js";
7
8
  /**
8
9
  * A directed acyclic graph of component dependencies.
10
+ *
11
+ * `addEdge` creates any endpoint it has not seen, so an undeclared
12
+ * node (a typo in a dependency id, say) sorts as a leaf in the first
13
+ * stage. The graph remembers which nodes were declared with `addNode`:
14
+ * inspect them with `getUndeclaredNodes()` or reject them with
15
+ * `validate({ requireDeclared: true })`.
9
16
  */
10
17
  export class DependencyGraph {
11
18
  _edges = new Map();
12
19
  _reverseEdges = new Map();
13
- /** Adds a node to the graph. */
20
+ _declared = new Set();
21
+ /** Declares a node in the graph. */
14
22
  addNode(id) {
15
- if (!this._edges.has(id)) {
16
- this._edges.set(id, new Set());
17
- }
18
- if (!this._reverseEdges.has(id)) {
19
- this._reverseEdges.set(id, new Set());
20
- }
23
+ this._declared.add(id);
24
+ this.ensureNode(id);
21
25
  }
22
26
  /** Adds a directed edge: from depends on to. */
23
27
  addEdge(from, to) {
24
- this.addNode(from);
25
- this.addNode(to);
28
+ this.ensureNode(from);
29
+ this.ensureNode(to);
26
30
  this._edges.get(from).add(to);
27
31
  this._reverseEdges.get(to).add(from);
28
32
  }
29
- /** Returns all nodes. */
33
+ /** Returns all nodes, declared or not, in insertion order. */
30
34
  getNodes() {
31
35
  return [...this._edges.keys()];
32
36
  }
@@ -38,37 +42,50 @@ export class DependencyGraph {
38
42
  getDependents(id) {
39
43
  return [...(this._reverseEdges.get(id) ?? [])];
40
44
  }
45
+ /** Returns the nodes referenced by an edge but never passed to `addNode`. */
46
+ getUndeclaredNodes() {
47
+ return this.getNodes().filter((id) => !this._declared.has(id));
48
+ }
41
49
  /**
42
- * Validates that the graph has no circular dependencies.
43
- * Throws LifecycleDependencyError with the cycle path if found.
50
+ * Validates that the graph has no circular dependencies, throwing
51
+ * LifecycleDependencyError with the cycle path if one is found. With
52
+ * `requireDeclared` it first throws LifecycleError
53
+ * (LIFECYCLE_DEPENDENCY) for an edge to a node that was never added.
44
54
  */
45
- validate() {
46
- const visited = new Set();
47
- const inStack = new Set();
48
- const path = [];
49
- for (const node of this._edges.keys()) {
50
- if (!visited.has(node)) {
51
- this._detectCycle(node, visited, inStack, path);
52
- }
55
+ validate(options = {}) {
56
+ if (options.requireDeclared === true) {
57
+ this.assertDeclared();
58
+ }
59
+ const cycle = this.findCycle();
60
+ if (cycle !== undefined) {
61
+ throw new LifecycleDependencyError(cycle);
53
62
  }
54
63
  }
55
- _detectCycle(node, visited, inStack, path) {
56
- visited.add(node);
57
- inStack.add(node);
58
- path.push(node);
59
- const deps = this._edges.get(node) ?? new Set();
60
- for (const dep of deps) {
61
- if (inStack.has(dep)) {
62
- const cycleStart = path.indexOf(dep);
63
- const cycle = [...path.slice(cycleStart), dep];
64
- throw new LifecycleDependencyError(cycle);
65
- }
66
- if (!visited.has(dep)) {
67
- this._detectCycle(dep, visited, inStack, path);
68
- }
64
+ /**
65
+ * Returns one dependency cycle as a closed path (first id repeated
66
+ * last), or undefined when the graph is acyclic.
67
+ */
68
+ findCycle() {
69
+ return findDependencyCycle(this);
70
+ }
71
+ ensureNode(id) {
72
+ if (!this._edges.has(id)) {
73
+ this._edges.set(id, new Set());
69
74
  }
70
- path.pop();
71
- inStack.delete(node);
75
+ if (!this._reverseEdges.has(id)) {
76
+ this._reverseEdges.set(id, new Set());
77
+ }
78
+ }
79
+ assertDeclared() {
80
+ const undeclared = this.getUndeclaredNodes();
81
+ const missing = undeclared[0];
82
+ if (missing === undefined)
83
+ return;
84
+ const dependent = this.getDependents(missing)[0];
85
+ throw new LifecycleError(`Component "${dependent ?? missing}" depends on "${missing}" which was never added to the graph.`, {
86
+ code: ErrorCode.LIFECYCLE_DEPENDENCY,
87
+ componentId: dependent,
88
+ metadata: { dependency: missing, undeclared },
89
+ });
72
90
  }
73
91
  }
74
- //# sourceMappingURL=dependencyGraph.core.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @zudojs/lifecycle/internal/dependency-graph/cycle
3
+ *
4
+ * Iterative cycle search over a dependency graph.
5
+ */
6
+ /** The part of a graph the search needs. */
7
+ export interface CycleSearchGraph {
8
+ getNodes(): readonly string[];
9
+ getDependencies(id: string): readonly string[];
10
+ }
11
+ /**
12
+ * Returns one dependency cycle as a closed path (first id repeated
13
+ * last), or undefined when the graph is acyclic.
14
+ *
15
+ * The search is iterative so a very deep graph cannot overflow the
16
+ * call stack, and it reports the actual loop: the topological sort
17
+ * used to list every node that was still blocked, which for one
18
+ * three-node loop meant naming the whole application.
19
+ */
20
+ export declare function findDependencyCycle(graph: CycleSearchGraph): readonly string[] | undefined;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @zudojs/lifecycle/internal/dependency-graph/cycle
3
+ *
4
+ * Iterative cycle search over a dependency graph.
5
+ */
6
+ /**
7
+ * Returns one dependency cycle as a closed path (first id repeated
8
+ * last), or undefined when the graph is acyclic.
9
+ *
10
+ * The search is iterative so a very deep graph cannot overflow the
11
+ * call stack, and it reports the actual loop: the topological sort
12
+ * used to list every node that was still blocked, which for one
13
+ * three-node loop meant naming the whole application.
14
+ */
15
+ export function findDependencyCycle(graph) {
16
+ const visited = new Set();
17
+ for (const start of graph.getNodes()) {
18
+ if (visited.has(start))
19
+ continue;
20
+ const cycle = findCycleFrom(graph, start, visited);
21
+ if (cycle !== undefined)
22
+ return cycle;
23
+ }
24
+ return undefined;
25
+ }
26
+ function findCycleFrom(graph, start, visited) {
27
+ const stack = [
28
+ { id: start, dependencies: graph.getDependencies(start), next: 0 },
29
+ ];
30
+ const inPath = new Set([start]);
31
+ const path = [start];
32
+ while (stack.length > 0) {
33
+ const frame = stack[stack.length - 1];
34
+ if (frame.next >= frame.dependencies.length) {
35
+ stack.pop();
36
+ path.pop();
37
+ inPath.delete(frame.id);
38
+ visited.add(frame.id);
39
+ continue;
40
+ }
41
+ const dependency = frame.dependencies[frame.next];
42
+ frame.next += 1;
43
+ if (inPath.has(dependency)) {
44
+ return Object.freeze([...path.slice(path.indexOf(dependency)), dependency]);
45
+ }
46
+ if (visited.has(dependency))
47
+ continue;
48
+ stack.push({
49
+ id: dependency,
50
+ dependencies: graph.getDependencies(dependency),
51
+ next: 0,
52
+ });
53
+ inPath.add(dependency);
54
+ path.push(dependency);
55
+ }
56
+ return undefined;
57
+ }