bunsane 0.2.10 → 0.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.
@@ -9,6 +9,16 @@ export enum ApplicationPhase {
9
9
  APPLICATION_READY = "application_ready"
10
10
  }
11
11
 
12
+ const PHASE_ORDER: Record<ApplicationPhase, number> = {
13
+ [ApplicationPhase.DATABASE_INITIALIZING]: 0,
14
+ [ApplicationPhase.DATABASE_READY]: 1,
15
+ [ApplicationPhase.COMPONENTS_REGISTERING]: 2,
16
+ [ApplicationPhase.COMPONENTS_READY]: 3,
17
+ [ApplicationPhase.SYSTEM_REGISTERING]: 4,
18
+ [ApplicationPhase.SYSTEM_READY]: 5,
19
+ [ApplicationPhase.APPLICATION_READY]: 6,
20
+ };
21
+
12
22
  export interface PhaseChangeEvent extends CustomEvent {
13
23
  detail: ApplicationPhase;
14
24
  }
@@ -30,10 +40,29 @@ class ApplicationLifecycle {
30
40
  private currentPhase = ApplicationPhase.DATABASE_INITIALIZING;
31
41
 
32
42
 
33
- async waitForPhase(phase: ApplicationPhase): Promise<void> {
34
- while (this.currentPhase !== phase) {
35
- await new Promise(resolve => setTimeout(resolve, 100));
36
- }
43
+ /**
44
+ * Wait for the lifecycle to reach the given phase. Resolves immediately if
45
+ * already at that phase; otherwise attaches a one-shot listener that
46
+ * resolves when the phase is reached. Bounded by `timeoutMs` so callers
47
+ * cannot hang forever when a phase transition fails silently.
48
+ */
49
+ async waitForPhase(phase: ApplicationPhase, timeoutMs = 30_000): Promise<void> {
50
+ if (this.currentPhase === phase) return;
51
+ return new Promise<void>((resolve, reject) => {
52
+ const timer = setTimeout(() => {
53
+ this.eventEmitter.removeEventListener("phaseChanged", onPhase as EventListener);
54
+ reject(new Error(`waitForPhase(${phase}) timed out after ${timeoutMs}ms; current=${this.currentPhase}`));
55
+ }, timeoutMs);
56
+ timer.unref?.();
57
+ const onPhase = (event: PhaseChangeEvent) => {
58
+ if (event.detail === phase) {
59
+ clearTimeout(timer);
60
+ this.eventEmitter.removeEventListener("phaseChanged", onPhase as EventListener);
61
+ resolve();
62
+ }
63
+ };
64
+ this.eventEmitter.addEventListener("phaseChanged", onPhase as EventListener);
65
+ });
37
66
  }
38
67
 
39
68
  addPhaseListener(listener: (event: PhaseChangeEvent) => void) {
@@ -45,7 +74,42 @@ class ApplicationLifecycle {
45
74
  this.eventEmitter.removeEventListener("phaseChanged", listener as EventListener);
46
75
  }
47
76
 
77
+ /**
78
+ * Test / shutdown helper: remove all phase listeners. Call before
79
+ * recreating singletons in tests to prevent listener stacking.
80
+ */
81
+ removeAllListeners() {
82
+ this.eventEmitter = new EventTarget();
83
+ }
84
+
85
+ /**
86
+ * Test helper: reset the current phase to the initial value. Paired with
87
+ * `removeAllListeners()` when re-initializing the lifecycle in tests.
88
+ * Monotonic phase enforcement would otherwise reject re-entering early
89
+ * phases on a second App.init().
90
+ */
91
+ resetPhase() {
92
+ this.currentPhase = ApplicationPhase.DATABASE_INITIALIZING;
93
+ }
94
+
48
95
  setPhase(phase: ApplicationPhase) {
96
+ // Phases are linear: refuse non-monotonic transitions to prevent
97
+ // concurrent callers from clobbering each other's progress (H-LIFE-2).
98
+ // Idempotent re-emits at the same phase are silently ignored.
99
+ const currentRank = PHASE_ORDER[this.currentPhase];
100
+ const nextRank = PHASE_ORDER[phase];
101
+ if (nextRank === undefined) {
102
+ throw new Error(`Unknown application phase: ${phase}`);
103
+ }
104
+ if (nextRank < currentRank) {
105
+ throw new Error(
106
+ `Non-monotonic phase transition rejected: ${this.currentPhase} → ${phase}`,
107
+ );
108
+ }
109
+ if (nextRank === currentRank) {
110
+ // Same phase — no-op, no re-dispatch
111
+ return;
112
+ }
49
113
  this.currentPhase = phase;
50
114
  this.eventEmitter.dispatchEvent(new CustomEvent("phaseChanged", { detail: phase }));
51
115
  }