@electric-sql/client 1.5.2 → 1.5.3

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.
@@ -0,0 +1,112 @@
1
+ /**
2
+ * A set-based counting lock for coordinating multiple pause reasons.
3
+ *
4
+ * Multiple independent subsystems (tab visibility, snapshot requests, etc.)
5
+ * may each need the stream paused. A simple boolean flag or counter can't
6
+ * track *why* the stream is paused, leading to bugs where one subsystem's
7
+ * resume overrides another's pause.
8
+ *
9
+ * PauseLock uses a Set of reason strings. The stream is paused when any
10
+ * reason is held, and only resumes when all reasons are released.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * const lock = new PauseLock({
15
+ * onAcquired: () => abortController.abort(),
16
+ * onReleased: () => startRequestLoop(),
17
+ * })
18
+ *
19
+ * // Tab hidden
20
+ * lock.acquire('visibility') // → onAcquired fires, stream pauses
21
+ *
22
+ * // Snapshot starts while tab hidden
23
+ * lock.acquire('snapshot-1') // → no-op, already paused
24
+ *
25
+ * // Snapshot finishes
26
+ * lock.release('snapshot-1') // → no-op, 'visibility' still held
27
+ *
28
+ * // Tab visible
29
+ * lock.release('visibility') // → onReleased fires, stream resumes
30
+ * ```
31
+ */
32
+ export class PauseLock {
33
+ #holders = new Set<string>()
34
+ #onAcquired: () => void
35
+ #onReleased: () => void
36
+
37
+ constructor(callbacks: { onAcquired: () => void; onReleased: () => void }) {
38
+ this.#onAcquired = callbacks.onAcquired
39
+ this.#onReleased = callbacks.onReleased
40
+ }
41
+
42
+ /**
43
+ * Acquire the lock for a given reason. Idempotent — acquiring the same
44
+ * reason twice is a no-op (but logs a warning since it likely indicates
45
+ * a caller bug).
46
+ *
47
+ * Fires `onAcquired` when the first reason is acquired (transition from
48
+ * unlocked to locked).
49
+ */
50
+ acquire(reason: string): void {
51
+ if (this.#holders.has(reason)) {
52
+ console.warn(
53
+ `[Electric] PauseLock: "${reason}" already held — ignoring duplicate acquire`
54
+ )
55
+ return
56
+ }
57
+ const wasUnlocked = this.#holders.size === 0
58
+ this.#holders.add(reason)
59
+ if (wasUnlocked) {
60
+ this.#onAcquired()
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Release the lock for a given reason. Releasing a reason that isn't
66
+ * held logs a warning (likely indicates an acquire/release mismatch).
67
+ *
68
+ * Fires `onReleased` when the last reason is released (transition from
69
+ * locked to unlocked).
70
+ */
71
+ release(reason: string): void {
72
+ if (!this.#holders.delete(reason)) {
73
+ console.warn(
74
+ `[Electric] PauseLock: "${reason}" not held — ignoring release (possible acquire/release mismatch)`
75
+ )
76
+ return
77
+ }
78
+ if (this.#holders.size === 0) {
79
+ this.#onReleased()
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Whether the lock is currently held by any reason.
85
+ */
86
+ get isPaused(): boolean {
87
+ return this.#holders.size > 0
88
+ }
89
+
90
+ /**
91
+ * Check if a specific reason is holding the lock.
92
+ */
93
+ isHeldBy(reason: string): boolean {
94
+ return this.#holders.has(reason)
95
+ }
96
+
97
+ /**
98
+ * Release all reasons matching a prefix. Does NOT fire `onReleased` —
99
+ * this is for cleanup/reset paths where the stream state is being
100
+ * managed separately.
101
+ *
102
+ * This preserves reasons with different prefixes (e.g., 'visibility'
103
+ * is preserved when clearing 'snapshot-*' reasons).
104
+ */
105
+ releaseAllMatching(prefix: string): void {
106
+ for (const reason of this.#holders) {
107
+ if (reason.startsWith(prefix)) {
108
+ this.#holders.delete(reason)
109
+ }
110
+ }
111
+ }
112
+ }