@effect-app/vue 4.0.4 → 4.0.6

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.
@@ -1,5 +1,6 @@
1
1
  import { DataDependencies } from "effect-app/client"
2
- import * as Hash from "effect/Hash"
2
+ import * as MutableHashMap from "effect/MutableHashMap"
3
+ import * as Option from "effect/Option"
3
4
 
4
5
  export interface LiveQueryOptions {
5
6
  /** Fixed maximum delay from the first matching write. Zero reacts immediately. */
@@ -22,59 +23,227 @@ interface LiveQueryEntry {
22
23
  observers: number
23
24
  readonly delays: Map<number, number>
24
25
  timer: ReturnType<typeof setTimeout> | undefined
26
+ firstWriteAt: number
27
+ deadline: number
28
+ /** live fetches between acquire and release; their writes are buffered until reads are known */
29
+ discovering: number
30
+ /** live fetches still waiting for source readiness */
31
+ awaitingReady: number
32
+ buffered: DataDependencies.DataDependencies
33
+ /** invalidate when discovery ends (reset, or a source epoch no fetch covered) */
34
+ dirty: boolean
35
+ /** latest source epoch a fetch of this entry was ready for */
36
+ coveredEpoch: number
37
+ /** pending check for writes missed before this subscription's epoch was covered */
38
+ catchUp: AbortController | undefined
25
39
  }
26
40
 
27
- const entries = new Map<number, LiveQueryEntry>()
41
+ /** One live fetch: acquire before running, release on every exit. */
42
+ export interface LiveQueryFetch {
43
+ readonly key: ReadonlyArray<unknown>
44
+ }
45
+
46
+ interface FetchState {
47
+ readonly entry: LiveQueryEntry
48
+ awaiting: boolean
49
+ released: boolean
50
+ }
51
+
52
+ const entries = MutableHashMap.empty<ReadonlyArray<unknown>, LiveQueryEntry>()
53
+ const fetches = new WeakMap<LiveQueryFetch, FetchState>()
54
+ type ReadyOutcome = { readonly _tag: "ready" } | { readonly _tag: "switched" } | {
55
+ readonly _tag: "failed"
56
+ readonly error: unknown
57
+ }
58
+ /** One shared `ready()` call per epoch; waiters detach on abort so none accumulate on the promise. */
59
+ let observation: { readonly epoch: number; readonly waiters: Set<(outcome: ReadyOutcome) => void> } | undefined
28
60
  let source: LiveQueryInvalidationSource | undefined
29
61
  let unsubscribe: (() => void) | undefined
30
- let discovering = 0
31
- let bufferedWrites = DataDependencies.empty()
62
+ let subscribing = false
63
+ let epoch = 0
64
+
65
+ // a synchronous refresh can remove or replace registrations while dispatching
66
+ const isCurrent = (entry: LiveQueryEntry) => Option.getOrUndefined(MutableHashMap.get(entries, entry.key)) === entry
67
+
68
+ const forEachCurrent = (f: (entry: LiveQueryEntry) => void) => {
69
+ for (const entry of Array.from(MutableHashMap.values(entries))) {
70
+ if (isCurrent(entry)) f(entry)
71
+ }
72
+ }
32
73
 
33
74
  const invalidateEntry = (entry: LiveQueryEntry) => {
34
75
  entry.timer = undefined
35
- source?.invalidate([entry.key])
76
+ if (isCurrent(entry)) source?.invalidate([entry.key])
77
+ }
78
+
79
+ // (re)arm for the strictest observer; a stricter observer or write can only bring the deadline forward
80
+ const arm = (entry: LiveQueryEntry) => {
81
+ const deadline = entry.firstWriteAt + Math.min(...entry.delays.keys())
82
+ if (entry.timer !== undefined) {
83
+ if (entry.deadline <= deadline) return
84
+ clearTimeout(entry.timer)
85
+ entry.timer = undefined
86
+ }
87
+ entry.deadline = deadline
88
+ const wait = deadline - Date.now()
89
+ if (wait <= 0) return invalidateEntry(entry)
90
+ entry.timer = setTimeout(() => invalidateEntry(entry), wait)
36
91
  }
37
92
 
38
93
  const schedule = (entry: LiveQueryEntry) => {
39
- if (entry.timer !== undefined) return
40
- const maxDelayMs = Math.min(...entry.delays.keys())
41
- if (maxDelayMs <= 0) return invalidateEntry(entry)
42
- entry.timer = setTimeout(() => invalidateEntry(entry), maxDelayMs)
94
+ if (entry.timer === undefined) entry.firstWriteAt = Date.now()
95
+ arm(entry)
43
96
  }
44
97
 
45
- const dispatch = (writes: DataDependencies.DataDependencies) => {
46
- if (!DataDependencies.isNonEmpty(writes)) return
47
- for (const entry of entries.values()) {
48
- if (DataDependencies.intersects(entry.reads(), writes)) schedule(entry)
49
- }
98
+ // catch-up replaces a refetch, so it bypasses write coalescing; a running refetch makes SWR skip its own
99
+ const invalidateNow = (entry: LiveQueryEntry) => {
100
+ if (entry.discovering > 0) return void (entry.dirty = true)
101
+ if (entry.timer !== undefined) clearTimeout(entry.timer)
102
+ invalidateEntry(entry)
50
103
  }
51
104
 
52
105
  const receive = (writes: DataDependencies.DataDependencies) => {
53
- if (discovering > 0) {
54
- bufferedWrites = DataDependencies.merge(bufferedWrites, writes)
55
- return
56
- }
57
- dispatch(writes)
106
+ if (!DataDependencies.isNonEmpty(writes)) return
107
+ forEachCurrent((entry) => {
108
+ if (entry.discovering > 0) entry.buffered = DataDependencies.merge(entry.buffered, writes)
109
+ else if (DataDependencies.intersects(entry.reads(), writes)) schedule(entry)
110
+ })
58
111
  }
59
112
 
60
113
  const reset = () => {
61
- bufferedWrites = DataDependencies.empty()
62
- for (const entry of entries.values()) {
63
- if (DataDependencies.isNonEmpty(entry.reads())) schedule(entry)
114
+ forEachCurrent((entry) => {
115
+ if (entry.discovering > 0) entry.dirty = true
116
+ else if (DataDependencies.isNonEmpty(entry.reads())) schedule(entry)
117
+ })
118
+ }
119
+
120
+ const settle = (current: NonNullable<typeof observation>, outcome: ReadyOutcome) => {
121
+ if (observation === current) observation = undefined
122
+ if (current.epoch !== epoch) return
123
+ const waiters = Array.from(current.waiters)
124
+ current.waiters.clear()
125
+ for (const f of waiters) f(outcome)
126
+ }
127
+
128
+ const observeReady = (waiter: (outcome: ReadyOutcome) => void) => {
129
+ let current = observation
130
+ if (current === undefined || current.epoch !== epoch) {
131
+ const started = { epoch, waiters: new Set([waiter]) }
132
+ current = observation = started
133
+ // the waiter is attached first: `ready()` may reconfigure synchronously
134
+ let ready: Promise<void>
135
+ try {
136
+ ready = source?.ready() ?? Promise.resolve()
137
+ } catch (error) {
138
+ ready = Promise.reject(error)
139
+ }
140
+ ready.then(() => settle(started, { _tag: "ready" }), (error) => settle(started, { _tag: "failed", error }))
141
+ } else {
142
+ current.waiters.add(waiter)
143
+ }
144
+ const observed = current
145
+ return () => observed.waiters.delete(waiter)
146
+ }
147
+
148
+ /** Whether the current source became ready before being replaced; rejects with its error or on abort. */
149
+ const readyOnce = (signal?: AbortSignal) =>
150
+ new Promise<boolean>((resolve, reject) => {
151
+ if (signal?.aborted === true) return reject(signal.reason)
152
+ if (source === undefined) return resolve(true)
153
+ let detach: (() => void) | undefined
154
+ let aborted = false
155
+ const onAbort = () => {
156
+ aborted = true
157
+ detach?.()
158
+ reject(signal?.reason)
159
+ }
160
+ signal?.addEventListener("abort", onAbort, { once: true })
161
+ // `ready()` may abort synchronously, before `detach` exists
162
+ detach = observeReady((outcome) => {
163
+ signal?.removeEventListener("abort", onAbort)
164
+ if (outcome._tag === "failed") reject(outcome.error)
165
+ else resolve(outcome._tag === "ready")
166
+ })
167
+ if (aborted) detach()
168
+ })
169
+
170
+ /** Resolves with the epoch whose readiness was observed; a reconfigured source supersedes the wait. */
171
+ const waitReady = async (signal?: AbortSignal): Promise<number> => {
172
+ const target = epoch
173
+ let ready: boolean
174
+ try {
175
+ ready = await readyOnce(signal)
176
+ } catch (error) {
177
+ // a reconfiguration can run between settlement and this continuation
178
+ if (epoch === target || signal?.aborted === true) throw error
179
+ return waitReady(signal)
64
180
  }
181
+ return ready && epoch === target ? target : waitReady(signal)
182
+ }
183
+
184
+ const cancelCatchUp = (entry: LiveQueryEntry) => {
185
+ entry.catchUp?.abort()
186
+ entry.catchUp = undefined
187
+ }
188
+
189
+ // a reconfigured source restarts it; a live fetch or unregistering cancels it
190
+ const catchUp = (entry: LiveQueryEntry) => {
191
+ cancelCatchUp(entry)
192
+ if (source === undefined) return
193
+ const target = epoch
194
+ const controller = new AbortController()
195
+ entry.catchUp = controller
196
+ readyOnce(controller.signal).then((ready) => {
197
+ if (entry.catchUp === controller) entry.catchUp = undefined
198
+ if (!ready || epoch !== target || !isCurrent(entry)) return
199
+ if (entry.awaitingReady === 0 && entry.coveredEpoch !== target) invalidateNow(entry)
200
+ }, () => {})
201
+ }
202
+
203
+ const unsubscribeNow = () => {
204
+ const current = unsubscribe
205
+ unsubscribe = undefined
206
+ current?.()
65
207
  }
66
208
 
67
209
  const ensureSubscribed = () => {
68
- if (unsubscribe === undefined && source !== undefined && entries.size > 0) {
69
- unsubscribe = source.subscribe(receive, reset)
210
+ if (unsubscribe !== undefined || subscribing || source === undefined || MutableHashMap.size(entries) === 0) return
211
+ const target = epoch
212
+ const active = () => epoch === target
213
+ subscribing = true
214
+ let handle: () => void
215
+ try {
216
+ handle = source.subscribe(
217
+ (writes) => {
218
+ if (active()) receive(writes)
219
+ },
220
+ () => {
221
+ if (active()) reset()
222
+ }
223
+ )
224
+ } finally {
225
+ subscribing = false
226
+ }
227
+ if (!active() || MutableHashMap.size(entries) === 0) {
228
+ handle()
229
+ return ensureSubscribed()
70
230
  }
231
+ unsubscribe = handle
71
232
  }
72
233
 
73
234
  export const configureLiveQueryInvalidation = (next: LiveQueryInvalidationSource) => {
74
- unsubscribe?.()
75
- unsubscribe = undefined
235
+ unsubscribeNow()
76
236
  source = next
237
+ epoch++
238
+ const previous = observation
239
+ observation = undefined
240
+ if (previous !== undefined) {
241
+ const waiters = Array.from(previous.waiters)
242
+ previous.waiters.clear()
243
+ for (const f of waiters) f({ _tag: "switched" })
244
+ }
77
245
  ensureSubscribed()
246
+ forEachCurrent(catchUp)
78
247
  }
79
248
 
80
249
  export const registerLiveQuery = (
@@ -82,57 +251,167 @@ export const registerLiveQuery = (
82
251
  reads: () => DataDependencies.DataDependencies,
83
252
  options: LiveQueryOptions
84
253
  ) => {
85
- const hash = Hash.hash(key)
86
254
  const delay = options.maxDelayMs ?? 0
87
- const current = entries.get(hash)
88
- if (current !== undefined) {
89
- current.observers++
90
- current.delays.set(delay, (current.delays.get(delay) ?? 0) + 1)
255
+ let entry = Option.getOrUndefined(MutableHashMap.get(entries, key))
256
+ if (entry !== undefined) {
257
+ entry.observers++
258
+ entry.delays.set(delay, (entry.delays.get(delay) ?? 0) + 1)
259
+ if (entry.timer !== undefined) arm(entry)
91
260
  } else {
92
- entries.set(hash, {
261
+ entry = {
93
262
  key,
94
263
  reads,
95
264
  observers: 1,
96
265
  delays: new Map([[delay, 1]]),
97
- timer: undefined
98
- })
266
+ timer: undefined,
267
+ firstWriteAt: 0,
268
+ deadline: 0,
269
+ discovering: 0,
270
+ awaitingReady: 0,
271
+ buffered: DataDependencies.empty(),
272
+ dirty: false,
273
+ coveredEpoch: -1,
274
+ catchUp: undefined
275
+ }
276
+ MutableHashMap.set(entries, key, entry)
277
+ ensureSubscribed()
278
+ // writes missed while unsubscribed (cached result, or a fetch that started before this registration)
279
+ catchUp(entry)
99
280
  }
100
- ensureSubscribed()
101
281
 
282
+ const registered = entry
283
+ let active = true
102
284
  return () => {
103
- const entry = entries.get(hash)
104
- if (entry === undefined) return
105
- const delayObservers = entry.delays.get(delay) ?? 0
106
- if (delayObservers <= 1) entry.delays.delete(delay)
107
- else entry.delays.set(delay, delayObservers - 1)
108
- if (--entry.observers > 0) return
109
- if (entry.timer !== undefined) clearTimeout(entry.timer)
110
- entries.delete(hash)
111
- if (entries.size === 0) {
112
- unsubscribe?.()
113
- unsubscribe = undefined
114
- bufferedWrites = DataDependencies.empty()
285
+ if (!active) return
286
+ active = false
287
+ const delayObservers = registered.delays.get(delay) ?? 0
288
+ if (delayObservers <= 1) registered.delays.delete(delay)
289
+ else registered.delays.set(delay, delayObservers - 1)
290
+ if (--registered.observers > 0) return
291
+ if (registered.timer !== undefined) clearTimeout(registered.timer)
292
+ registered.timer = undefined
293
+ cancelCatchUp(registered)
294
+ if (isCurrent(registered)) MutableHashMap.remove(entries, key)
295
+ if (MutableHashMap.size(entries) === 0) unsubscribeNow()
296
+ }
297
+ }
298
+
299
+ /** Synchronously starts a live fetch for a registered key; release it with `releaseLiveQueryFetch` on every exit. */
300
+ export const acquireLiveQueryFetch = (key: ReadonlyArray<unknown>): LiveQueryFetch | undefined => {
301
+ const entry = Option.getOrUndefined(MutableHashMap.get(entries, key))
302
+ if (entry === undefined || source === undefined) return undefined
303
+ entry.discovering++
304
+ entry.awaitingReady++
305
+ // this fetch awaits readiness itself, covering the epoch the catch-up was waiting for
306
+ cancelCatchUp(entry)
307
+ const fetch: LiveQueryFetch = { key }
308
+ fetches.set(fetch, { entry, awaiting: true, released: false })
309
+ return fetch
310
+ }
311
+
312
+ const stopAwaiting = (state: FetchState) => {
313
+ if (!state.awaiting) return
314
+ state.awaiting = false
315
+ state.entry.awaitingReady--
316
+ }
317
+
318
+ /** Waits until writes after this point reach the fetch; abort to stop waiting. */
319
+ export const awaitLiveQueryFetch = async (fetch: LiveQueryFetch, signal?: AbortSignal) => {
320
+ const state = fetches.get(fetch)
321
+ if (state === undefined || state.released) return
322
+ try {
323
+ // every `await` is a gap in which the source can be switched: only settle for a still-current epoch
324
+ for (;;) {
325
+ const target = epoch
326
+ let ready: number
327
+ try {
328
+ ready = await waitReady(signal)
329
+ } catch (error) {
330
+ if (signal?.aborted === true || epoch === target) throw error
331
+ continue
332
+ }
333
+ if (ready !== epoch) continue
334
+ state.entry.coveredEpoch = Math.max(state.entry.coveredEpoch, ready)
335
+ break
336
+ }
337
+ } finally {
338
+ stopAwaiting(state)
339
+ }
340
+ }
341
+
342
+ export const releaseLiveQueryFetch = (fetch: LiveQueryFetch) => {
343
+ const state = fetches.get(fetch)
344
+ if (state === undefined || state.released) return
345
+ state.released = true
346
+ stopAwaiting(state)
347
+ state.entry.discovering--
348
+ finishDiscovery(state.entry)
349
+ }
350
+
351
+ const finishDiscovery = (entry: LiveQueryEntry) => {
352
+ if (entry.discovering > 0 || !isCurrent(entry)) return
353
+ const { buffered, dirty } = entry
354
+ entry.buffered = DataDependencies.empty()
355
+ entry.dirty = false
356
+ if (dirty || DataDependencies.intersects(entry.reads(), buffered)) schedule(entry)
357
+ }
358
+
359
+ /** Waits for the current source; a reconfigured source supersedes a pending wait on the previous one. */
360
+ export const liveQueryReady = async (): Promise<void> => {
361
+ for (;;) {
362
+ const target = epoch
363
+ try {
364
+ if (await waitReady() === epoch) return
365
+ } catch (error) {
366
+ if (epoch === target) throw error
115
367
  }
116
368
  }
117
369
  }
118
370
 
371
+ // Boolean callers cannot say which fetch ended: their entries keep buffering until every legacy fetch has
372
+ // ended. Other queries are unaffected; bookkeeping is per entry, not per call.
373
+ const legacyEntries = new Map<LiveQueryEntry, number>()
374
+ let legacyDiscovering = 0
375
+
376
+ /** @deprecated Waits for all concurrent legacy fetches; use `acquireLiveQueryFetch` + `awaitLiveQueryFetch`. */
119
377
  export const beginLiveQueryFetch = async (key: ReadonlyArray<unknown>) => {
120
- if (!entries.has(Hash.hash(key)) || source === undefined) return false
121
- discovering++
378
+ const fetch = acquireLiveQueryFetch(key)
379
+ const state = fetch && fetches.get(fetch)
380
+ if (fetch === undefined || state === undefined) return false
381
+ // hand the discovery count over to the legacy barrier
382
+ state.released = true
383
+ legacyDiscovering++
384
+ legacyEntries.set(state.entry, (legacyEntries.get(state.entry) ?? 0) + 1)
122
385
  try {
123
- await source.ready()
124
- return true
386
+ for (;;) {
387
+ const target = epoch
388
+ let ready: number
389
+ try {
390
+ ready = await waitReady()
391
+ } catch (error) {
392
+ if (epoch === target) throw error
393
+ continue
394
+ }
395
+ if (ready !== epoch) continue
396
+ state.entry.coveredEpoch = Math.max(state.entry.coveredEpoch, ready)
397
+ break
398
+ }
125
399
  } catch (error) {
126
- discovering--
400
+ endLiveQueryFetch(true)
127
401
  throw error
402
+ } finally {
403
+ stopAwaiting(state)
128
404
  }
405
+ return true
129
406
  }
130
407
 
408
+ /** @deprecated Pairs with `beginLiveQueryFetch`; use `releaseLiveQueryFetch`. */
131
409
  export const endLiveQueryFetch = (wasLive: boolean) => {
132
- if (!wasLive) return
133
- discovering = Math.max(0, discovering - 1)
134
- if (discovering > 0 || !DataDependencies.isNonEmpty(bufferedWrites)) return
135
- const writes = bufferedWrites
136
- bufferedWrites = DataDependencies.empty()
137
- dispatch(writes)
410
+ if (!wasLive || legacyDiscovering === 0 || --legacyDiscovering > 0) return
411
+ const ended = Array.from(legacyEntries)
412
+ legacyEntries.clear()
413
+ for (const [entry, count] of ended) {
414
+ entry.discovering -= count
415
+ finishDiscovery(entry)
416
+ }
138
417
  }
@@ -0,0 +1 @@
1
+ {"version":3,"file":"liveQueryLifecycle.test.d.ts","sourceRoot":"","sources":["../liveQueryLifecycle.test.ts"],"names":[],"mappings":""}
@@ -1,10 +1,22 @@
1
- import { DataDependencies } from "effect-app/client"
1
+ import { defaultRegistry } from "@effect/atom-vue"
2
+ import { DataDependencies, makeQueryKey } from "effect-app/client"
3
+ import * as Effect from "effect-app/Effect"
4
+ import * as Layer from "effect-app/Layer"
5
+ import * as ManagedRuntime from "effect/ManagedRuntime"
2
6
  import { afterEach, describe, expect, it, vi } from "vitest"
3
- import { beginLiveQueryFetch, configureLiveQueryInvalidation, endLiveQueryFetch, registerLiveQuery } from "../src/liveQueryInvalidation.js"
7
+ import { awaitAtomResult, buildQueryFamily, invalidateAndAwait, invalidateSoft, makeAtomClientRuntime, withQueryOptions } from "../src/atomQuery.js"
8
+ import { getQueryInvalidationMode } from "../src/dependencyMetadata.js"
9
+ import { acquireLiveQueryFetch, awaitLiveQueryFetch, configureLiveQueryInvalidation, registerLiveQuery, releaseLiveQueryFetch } from "../src/liveQueryInvalidation.js"
4
10
 
5
11
  const repo = DataDependencies.repo("Inventory", ["item-1"])
6
12
  const otherId = DataDependencies.repo("Inventory", ["item-2"])
7
13
 
14
+ const acquire = (key: ReadonlyArray<unknown>) => {
15
+ const fetch = acquireLiveQueryFetch(key)
16
+ if (fetch === undefined) throw new Error("expected a live fetch")
17
+ return fetch
18
+ }
19
+
8
20
  describe("live query invalidation", () => {
9
21
  const cleanups: Array<() => void> = []
10
22
 
@@ -13,6 +25,114 @@ describe("live query invalidation", () => {
13
25
  vi.useRealTimers()
14
26
  })
15
27
 
28
+ it.each(["await", "soft"] as const)(
29
+ "keeps an observed %s query subscribed after successive repository-write refreshes",
30
+ async (invalidation) => {
31
+ const dependency = DataDependencies.repo("LiveOrders")
32
+ const mrt = ManagedRuntime.make(Layer.empty)
33
+ const rt = makeAtomClientRuntime(() => Layer.empty, mrt.memoMap)
34
+ let calls = 0
35
+ let receive = (_writes: DataDependencies.DataDependencies) => {}
36
+ let reset = () => {}
37
+ let settled = Promise.resolve()
38
+ let refreshes = 0
39
+ configureLiveQueryInvalidation({
40
+ ready: () => Promise.resolve(),
41
+ subscribe: (onWrites, onReset) => {
42
+ receive = onWrites
43
+ reset = onReset
44
+ return () => {
45
+ receive = () => {}
46
+ reset = () => {}
47
+ }
48
+ },
49
+ invalidate: (keys) => {
50
+ refreshes++
51
+ settled = Effect.runPromise(
52
+ keys.every((key) => getQueryInvalidationMode(key) === "soft")
53
+ ? invalidateSoft(keys).pipe(Effect.andThen(awaitAtomResult(defaultRegistry, atom)))
54
+ : invalidateAndAwait(keys)
55
+ )
56
+ }
57
+ })
58
+ const query = {
59
+ id: "LiveOrders.List",
60
+ handler: () => DataDependencies.read(dependency).pipe(Effect.andThen(Effect.sync(() => ++calls)))
61
+ }
62
+ const base = buildQueryFamily(rt, query)(undefined)
63
+ const atom = withQueryOptions(base, { live: true, invalidation, revalidateOnFocus: false }, [
64
+ ...makeQueryKey(query),
65
+ undefined
66
+ ])
67
+ const unmount = defaultRegistry.mount(atom)
68
+ try {
69
+ await Effect.runPromise(awaitAtomResult(defaultRegistry, atom))
70
+ expect(calls).toBe(1)
71
+ expect(getQueryInvalidationMode([...makeQueryKey(query), undefined])).toBe(invalidation)
72
+ receive(new Set([dependency]))
73
+ await settled
74
+ expect(calls).toBe(2)
75
+ expect(refreshes).toBe(1)
76
+ receive(new Set([dependency]))
77
+ await settled
78
+ expect(calls).toBe(3)
79
+ expect(refreshes).toBe(2)
80
+ reset()
81
+ await settled
82
+ expect(calls).toBe(4)
83
+ expect(refreshes).toBe(3)
84
+ receive(new Set([dependency]))
85
+ await settled
86
+ expect(calls).toBe(5)
87
+ expect(refreshes).toBe(4)
88
+ } finally {
89
+ unmount()
90
+ defaultRegistry.reset()
91
+ await mrt.dispose()
92
+ }
93
+ }
94
+ )
95
+
96
+ it.each(["write", "reset"] as const)(
97
+ "skips registrations removed or replaced by a synchronous refresh during %s dispatch",
98
+ (event) => {
99
+ vi.useFakeTimers()
100
+ const invalidations: Array<ReadonlyArray<ReadonlyArray<unknown>>> = []
101
+ let receive = (_writes: DataDependencies.DataDependencies) => {}
102
+ let reset = () => {}
103
+ const first = ["$Inventory", "List", undefined]
104
+ const removed = ["$Inventory", "Get", { id: "item-1" }]
105
+ const replaced = ["$Inventory", "Get", { id: "item-2" }]
106
+ let unregisterRemoved = () => {}
107
+ let unregisterReplaced = () => {}
108
+ configureLiveQueryInvalidation({
109
+ ready: () => Promise.resolve(),
110
+ subscribe: (onWrites, onReset) => {
111
+ receive = onWrites
112
+ reset = onReset
113
+ return () => {}
114
+ },
115
+ invalidate: (keys) => {
116
+ invalidations.push(keys)
117
+ if (keys[0] !== first) return
118
+ unregisterRemoved()
119
+ unregisterReplaced()
120
+ unregisterReplaced = registerLiveQuery(replaced, () => new Set([repo]), { maxDelayMs: 1_000 })
121
+ }
122
+ })
123
+
124
+ cleanups.push(registerLiveQuery(first, () => new Set([repo]), {}))
125
+ unregisterRemoved = registerLiveQuery(removed, () => new Set([repo]), {})
126
+ unregisterReplaced = registerLiveQuery(replaced, () => new Set([repo]), {})
127
+ cleanups.push(() => unregisterReplaced())
128
+
129
+ if (event === "write") receive(new Set([repo]))
130
+ else reset()
131
+ vi.runAllTimers()
132
+ expect(invalidations).toEqual([[first]])
133
+ }
134
+ )
135
+
16
136
  it("invalidates only live queries whose recorded reads intersect", () => {
17
137
  const invalidations: Array<ReadonlyArray<ReadonlyArray<unknown>>> = []
18
138
  let receive = (_writes: DataDependencies.DataDependencies) => {}
@@ -52,12 +172,85 @@ describe("live query invalidation", () => {
52
172
  let reads = DataDependencies.empty()
53
173
  cleanups.push(registerLiveQuery(key, () => reads, {}))
54
174
 
55
- const starting = beginLiveQueryFetch(key)
175
+ const fetch = acquire(key)
176
+ const starting = awaitLiveQueryFetch(fetch)
177
+ receive(new Set([repo]))
178
+ open()
179
+ await starting
180
+ reads = new Set([repo])
181
+ releaseLiveQueryFetch(fetch)
182
+
183
+ expect(invalidations).toEqual([[key]])
184
+ })
185
+
186
+ it("dispatches writes after an observed live query is interrupted while awaiting readiness", async () => {
187
+ const dependency = DataDependencies.repo("LiveOrders")
188
+ const mrt = ManagedRuntime.make(Layer.empty)
189
+ const rt = makeAtomClientRuntime(() => Layer.empty, mrt.memoMap)
190
+ const invalidations: Array<ReadonlyArray<ReadonlyArray<unknown>>> = []
191
+ let receive = (_writes: DataDependencies.DataDependencies) => {}
192
+ let readyCalled = () => {}
193
+ const readyRequested = new Promise<void>((resolve) => readyCalled = resolve)
194
+ configureLiveQueryInvalidation({
195
+ ready: () => {
196
+ readyCalled()
197
+ return new Promise<void>(() => {})
198
+ },
199
+ subscribe: (onWrites) => {
200
+ receive = onWrites
201
+ return () => {}
202
+ },
203
+ invalidate: (keys) => invalidations.push(keys)
204
+ })
205
+ const query = { id: "LiveOrders.Pending", handler: () => DataDependencies.read(dependency) }
206
+ const base = buildQueryFamily(rt, query)(undefined)
207
+ const atom = withQueryOptions(base, { live: true, revalidateOnFocus: false }, [...makeQueryKey(query), undefined])
208
+ try {
209
+ const unmount = defaultRegistry.mount(atom)
210
+ await readyRequested
211
+ unmount()
212
+ defaultRegistry.reset()
213
+ await new Promise((resolve) => setTimeout(resolve, 0))
214
+
215
+ const key = ["$LiveOrders", "Other", undefined]
216
+ cleanups.push(registerLiveQuery(key, () => new Set([dependency]), {}))
217
+ receive(new Set([dependency]))
218
+ expect(invalidations).toEqual([[key]])
219
+ } finally {
220
+ defaultRegistry.reset()
221
+ await mrt.dispose()
222
+ }
223
+ })
224
+
225
+ it("moves a pending readiness wait to a newly configured source", async () => {
226
+ const invalidations: Array<ReadonlyArray<ReadonlyArray<unknown>>> = []
227
+ let receive = (_writes: DataDependencies.DataDependencies) => {}
228
+ configureLiveQueryInvalidation({
229
+ ready: () => new Promise<void>(() => {}),
230
+ subscribe: () => () => {},
231
+ invalidate: () => {}
232
+ })
233
+ const key = ["$Inventory", "Get", { id: "item-1" }]
234
+ let reads = DataDependencies.empty()
235
+ cleanups.push(registerLiveQuery(key, () => reads, {}))
236
+ const fetch = acquire(key)
237
+ const starting = awaitLiveQueryFetch(fetch)
238
+
239
+ let open = () => {}
240
+ const ready = new Promise<void>((resolve) => open = resolve)
241
+ configureLiveQueryInvalidation({
242
+ ready: () => ready,
243
+ subscribe: (onWrites) => {
244
+ receive = onWrites
245
+ return () => {}
246
+ },
247
+ invalidate: (keys) => invalidations.push(keys)
248
+ })
56
249
  receive(new Set([repo]))
57
250
  open()
58
- const wasLive = await starting
251
+ await starting
59
252
  reads = new Set([repo])
60
- endLiveQueryFetch(wasLive)
253
+ releaseLiveQueryFetch(fetch)
61
254
 
62
255
  expect(invalidations).toEqual([[key]])
63
256
  })