@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.
- package/CHANGELOG.md +26 -0
- package/dist/atomQuery.d.ts.map +1 -1
- package/dist/atomQuery.js +48 -41
- package/dist/dependencyMetadata.d.ts.map +1 -1
- package/dist/dependencyMetadata.js +18 -16
- package/dist/liveQueryInvalidation.d.ts +13 -0
- package/dist/liveQueryInvalidation.d.ts.map +1 -1
- package/dist/liveQueryInvalidation.js +340 -63
- package/package.json +2 -2
- package/src/atomQuery.ts +61 -43
- package/src/dependencyMetadata.ts +19 -14
- package/src/liveQueryInvalidation.ts +337 -58
- package/test/dist/liveQueryLifecycle.test.d.ts.map +1 -0
- package/test/liveQueryInvalidation.test.ts +198 -5
- package/test/liveQueryLifecycle.test.ts +507 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { DataDependencies } from "effect-app/client"
|
|
2
|
-
import * as
|
|
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
|
-
|
|
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
|
|
31
|
-
let
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 (
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
const delayObservers =
|
|
106
|
-
if (delayObservers <= 1)
|
|
107
|
-
else
|
|
108
|
-
if (--
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
251
|
+
await starting
|
|
59
252
|
reads = new Set([repo])
|
|
60
|
-
|
|
253
|
+
releaseLiveQueryFetch(fetch)
|
|
61
254
|
|
|
62
255
|
expect(invalidations).toEqual([[key]])
|
|
63
256
|
})
|