@effect-app/vue 4.0.0-beta.307 → 4.0.0-beta.309

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,138 @@
1
+ import { DataDependencies } from "effect-app/client"
2
+ import * as Hash from "effect/Hash"
3
+
4
+ export interface LiveQueryOptions {
5
+ /** Fixed maximum delay from the first matching write. Zero reacts immediately. */
6
+ readonly maxDelayMs?: number
7
+ }
8
+
9
+ export interface LiveQueryInvalidationSource {
10
+ /** Resolve only after events received after this point can no longer be missed. */
11
+ readonly ready: () => Promise<void>
12
+ readonly subscribe: (
13
+ onWrites: (writes: DataDependencies.DataDependencies) => void,
14
+ onReset: () => void
15
+ ) => () => void
16
+ readonly invalidate: (keys: ReadonlyArray<ReadonlyArray<unknown>>) => void
17
+ }
18
+
19
+ interface LiveQueryEntry {
20
+ readonly key: ReadonlyArray<unknown>
21
+ readonly reads: () => DataDependencies.DataDependencies
22
+ observers: number
23
+ readonly delays: Map<number, number>
24
+ timer: ReturnType<typeof setTimeout> | undefined
25
+ }
26
+
27
+ const entries = new Map<number, LiveQueryEntry>()
28
+ let source: LiveQueryInvalidationSource | undefined
29
+ let unsubscribe: (() => void) | undefined
30
+ let discovering = 0
31
+ let bufferedWrites = DataDependencies.empty()
32
+
33
+ const invalidateEntry = (entry: LiveQueryEntry) => {
34
+ entry.timer = undefined
35
+ source?.invalidate([entry.key])
36
+ }
37
+
38
+ 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)
43
+ }
44
+
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
+ }
50
+ }
51
+
52
+ const receive = (writes: DataDependencies.DataDependencies) => {
53
+ if (discovering > 0) {
54
+ bufferedWrites = DataDependencies.merge(bufferedWrites, writes)
55
+ return
56
+ }
57
+ dispatch(writes)
58
+ }
59
+
60
+ const reset = () => {
61
+ bufferedWrites = DataDependencies.empty()
62
+ for (const entry of entries.values()) {
63
+ if (DataDependencies.isNonEmpty(entry.reads())) schedule(entry)
64
+ }
65
+ }
66
+
67
+ const ensureSubscribed = () => {
68
+ if (unsubscribe === undefined && source !== undefined && entries.size > 0) {
69
+ unsubscribe = source.subscribe(receive, reset)
70
+ }
71
+ }
72
+
73
+ export const configureLiveQueryInvalidation = (next: LiveQueryInvalidationSource) => {
74
+ unsubscribe?.()
75
+ unsubscribe = undefined
76
+ source = next
77
+ ensureSubscribed()
78
+ }
79
+
80
+ export const registerLiveQuery = (
81
+ key: ReadonlyArray<unknown>,
82
+ reads: () => DataDependencies.DataDependencies,
83
+ options: LiveQueryOptions
84
+ ) => {
85
+ const hash = Hash.hash(key)
86
+ 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)
91
+ } else {
92
+ entries.set(hash, {
93
+ key,
94
+ reads,
95
+ observers: 1,
96
+ delays: new Map([[delay, 1]]),
97
+ timer: undefined
98
+ })
99
+ }
100
+ ensureSubscribed()
101
+
102
+ 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()
115
+ }
116
+ }
117
+ }
118
+
119
+ export const beginLiveQueryFetch = async (key: ReadonlyArray<unknown>) => {
120
+ if (!entries.has(Hash.hash(key)) || source === undefined) return false
121
+ discovering++
122
+ try {
123
+ await source.ready()
124
+ return true
125
+ } catch (error) {
126
+ discovering--
127
+ throw error
128
+ }
129
+ }
130
+
131
+ 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)
138
+ }
package/src/makeClient.ts CHANGED
@@ -1031,7 +1031,7 @@ export const makeClient = <RT_, RTHooks>(
1031
1031
  fromRequestConfig(
1032
1032
  defaultKey,
1033
1033
  queryResources as never,
1034
- input as never,
1034
+ input,
1035
1035
  output as never
1036
1036
  ) as InvalidationEntry[])
1037
1037
  : undefined
@@ -1111,7 +1111,7 @@ export const makeClient = <RT_, RTHooks>(
1111
1111
  fromRequestConfig(
1112
1112
  defaultKey,
1113
1113
  queryResources as never,
1114
- input as never,
1114
+ input,
1115
1115
  output as never
1116
1116
  ) as InvalidationEntry[])
1117
1117
  : undefined
@@ -1142,7 +1142,7 @@ export const makeClient = <RT_, RTHooks>(
1142
1142
  fromRequestConfig(
1143
1143
  defaultKey,
1144
1144
  queryResources as never,
1145
- input as never,
1145
+ input,
1146
1146
  output as never
1147
1147
  ) as InvalidationEntry[])
1148
1148
  : undefined
package/src/mutate.ts CHANGED
@@ -212,7 +212,7 @@ export const asResult = <Args extends readonly any[], A, E, R>(
212
212
  ))
213
213
  )
214
214
 
215
- return tuple(computed(() => state.value), act) as any
215
+ return tuple(computed(() => state.value), act)
216
216
  }
217
217
 
218
218
  /**
@@ -259,7 +259,7 @@ export const asStreamResult = <Args extends readonly any[], A, E, R>(
259
259
 
260
260
  const act = (...args: Args) => runStream(handler(...args))
261
261
 
262
- return tuple(computed(() => state.value), act) as any
262
+ return tuple(computed(() => state.value), act)
263
263
  }
264
264
 
265
265
  const buildInvalidateCache = <RInvalidator>(
package/src/query.ts CHANGED
@@ -16,8 +16,9 @@ import * as Exit from "effect/Exit"
16
16
  import * as Stream from "effect/Stream"
17
17
  import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"
18
18
  import * as Atom from "effect/unstable/reactivity/Atom"
19
- import { computed, type ComputedRef, effectScope, type MaybeRefOrGetter, onBeforeUnmount, onMounted, onScopeDispose, type Ref, ref, toValue, type WatchSource } from "vue"
20
- import { type AtomClientRuntime, type AtomQueryOptions, awaitAtomResult, buildQueryFamily, buildStreamQueryFamily, disabledQueryAtom, isStaleResult, refreshAtomWithCurrentSpan, staleTimeMsOf, withQueryOptions } from "./atomQuery.ts"
19
+ import { computed, type ComputedRef, effectScope, type MaybeRefOrGetter, onBeforeUnmount, onMounted, onScopeDispose, ref, toValue, type WatchSource } from "vue"
20
+ import { type AtomClientRuntime, type AtomQueryOptions, awaitAtomResult, buildQueryFamily, buildStreamQueryFamily, disabledQueryAtom, isStaleResult, queryKeyForAtom, refreshAtomWithCurrentSpan, staleTimeMsOf, withQueryOptions } from "./atomQuery.ts"
21
+ import type { LiveQueryOptions } from "./liveQueryInvalidation.ts"
21
22
  import { latestDefined } from "./suspense.ts"
22
23
 
23
24
  // --- minimal local types (replacing the former @tanstack/vue-query type imports) ---
@@ -201,6 +202,7 @@ export interface CustomUseQueryOptions<
201
202
  readonly structuralSharing?: boolean
202
203
  /** poll: re-fetch every N ms (tanstack refetchInterval) */
203
204
  readonly refetchInterval?: number
205
+ readonly live?: boolean | LiveQueryOptions
204
206
  readonly select?: (data: TQueryFnData) => TData
205
207
  /** accepted for source compatibility; not used by the atom engine */
206
208
  readonly retry?: boolean | number
@@ -253,6 +255,7 @@ export interface AtomQueryNewOptions<TQueryFnData = unknown, TData = TQueryFnDat
253
255
  readonly structuralSharing?: boolean
254
256
  readonly refreshEvery?: number
255
257
  readonly refetchInterval?: number
258
+ readonly live?: boolean | LiveQueryOptions
256
259
  readonly select?: (data: TQueryFnData) => TData
257
260
  }
258
261
 
@@ -263,6 +266,7 @@ export interface AtomStreamQueryOptions {
263
266
  readonly revalidateOnFocus?: boolean
264
267
  readonly refetchOnWindowFocus?: boolean
265
268
  readonly refreshEvery?: number
269
+ readonly live?: boolean | LiveQueryOptions
266
270
  readonly refetchInterval?: number
267
271
  }
268
272
 
@@ -276,7 +280,8 @@ export interface AtomStreamQueryOptions {
276
280
  * option is set. Neither value is written to the atom cache.
277
281
  */
278
282
  export const withDataFallback = <A, E>(
279
- rawResult: Ref<AsyncResult.AsyncResult<A, E>>,
283
+ // Structural read so both Ref and Readonly<Ref> from useAtomValue are accepted.
284
+ rawResult: { readonly value: AsyncResult.AsyncResult<A, E> },
280
285
  options?: unknown
281
286
  ): ComputedRef<AsyncResult.AsyncResult<A, E>> => {
282
287
  const opts = options as
@@ -285,6 +290,8 @@ export const withDataFallback = <A, E>(
285
290
  const initialData = opts?.initialData
286
291
  const placeholderData = opts?.placeholderData
287
292
  if (initialData === undefined && placeholderData === undefined) {
293
+ // Identity when no fallback options. Callers that need a real ComputedRef
294
+ // (e.g. useAtomValue's Readonly<Ref>) wrap before calling this helper.
288
295
  return rawResult as ComputedRef<AsyncResult.AsyncResult<A, E>>
289
296
  }
290
297
 
@@ -319,6 +326,7 @@ const normalizeQueryOptions = (options?: {
319
326
  readonly structuralSharing?: boolean
320
327
  readonly refetchInterval?: number
321
328
  readonly refreshEvery?: number
329
+ readonly live?: boolean | LiveQueryOptions
322
330
  }): AtomQueryOptions => {
323
331
  const out: {
324
332
  staleTime?: number
@@ -326,6 +334,7 @@ const normalizeQueryOptions = (options?: {
326
334
  revalidateOnFocus?: boolean
327
335
  structuralSharing?: boolean
328
336
  refetchInterval?: number
337
+ live?: boolean | LiveQueryOptions
329
338
  } = {}
330
339
  if (options?.staleTime !== undefined) out.staleTime = options.staleTime
331
340
  const gcTime = options?.idleTTL ?? options?.gcTime
@@ -335,6 +344,7 @@ const normalizeQueryOptions = (options?: {
335
344
  if (options?.structuralSharing !== undefined) out.structuralSharing = options.structuralSharing
336
345
  const refetchInterval = options?.refreshEvery ?? options?.refetchInterval
337
346
  if (refetchInterval !== undefined) out.refetchInterval = refetchInterval
347
+ if (options?.live !== undefined) out.live = options.live
338
348
  return out
339
349
  }
340
350
 
@@ -510,8 +520,10 @@ const observedAtom = <A, E>(
510
520
  readonly structuralSharing?: boolean
511
521
  readonly refetchInterval?: number
512
522
  readonly refreshEvery?: number
523
+ readonly live?: boolean | LiveQueryOptions
513
524
  }
514
- ): Atom.Atom<AsyncResult.AsyncResult<A, E>> => withQueryOptions(atom, normalizeQueryOptions(options))
525
+ ): Atom.Atom<AsyncResult.AsyncResult<A, E>> =>
526
+ withQueryOptions(atom, normalizeQueryOptions(options), queryKeyForAtom(atom))
515
527
 
516
528
  const observedStreamAtom = <A, E>(
517
529
  atom: Atom.Writable<Atom.PullResult<A, E>, void>,
@@ -549,7 +561,9 @@ const makeQueryView = <I, A, E, TData>(
549
561
  const [req, enabledRef] = optionValue<I>(arg, options)
550
562
  const family = getQueryFamily(atomRt, q)
551
563
  const atomRef = computed(() => enabledRef.value ? observedAtom(family(req.value), options) : disabledQueryAtom)
552
- const rawResult = useAtomValue(() => atomRef.value) as ComputedRef<AsyncResult.AsyncResult<A, E>>
564
+ // useAtomValue returns Readonly<Ref>, not ComputedRef — re-wrap before withDataFallback / QueryView.
565
+ const rawAtomResult = useAtomValue(() => atomRef.value)
566
+ const rawResult = computed(() => rawAtomResult.value)
553
567
  // `initialData` / `placeholderData` display fallback (old `.query()` API). Applied only while the
554
568
  // result is still Initial; Failures/Success pass through unmasked. Neither is written to cache
555
569
  // (the base atom stays Initial, so the mount-staleness check still triggers the real fetch). The
@@ -0,0 +1 @@
1
+ {"version":3,"file":"liveQueryInvalidation.test.d.ts","sourceRoot":"","sources":["../liveQueryInvalidation.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,108 @@
1
+ import { DataDependencies } from "effect-app/client"
2
+ import { afterEach, describe, expect, it, vi } from "vitest"
3
+ import { beginLiveQueryFetch, configureLiveQueryInvalidation, endLiveQueryFetch, registerLiveQuery } from "../src/liveQueryInvalidation.js"
4
+
5
+ const repo = DataDependencies.repo("Inventory", ["item-1"])
6
+ const otherId = DataDependencies.repo("Inventory", ["item-2"])
7
+
8
+ describe("live query invalidation", () => {
9
+ const cleanups: Array<() => void> = []
10
+
11
+ afterEach(() => {
12
+ while (cleanups.length > 0) cleanups.pop()?.()
13
+ vi.useRealTimers()
14
+ })
15
+
16
+ it("invalidates only live queries whose recorded reads intersect", () => {
17
+ const invalidations: Array<ReadonlyArray<ReadonlyArray<unknown>>> = []
18
+ let receive = (_writes: DataDependencies.DataDependencies) => {}
19
+ configureLiveQueryInvalidation({
20
+ ready: () => Promise.resolve(),
21
+ subscribe: (onWrites) => {
22
+ receive = onWrites
23
+ return () => {}
24
+ },
25
+ invalidate: (keys) => invalidations.push(keys)
26
+ })
27
+
28
+ const key = ["$Inventory", "List", undefined]
29
+ cleanups.push(registerLiveQuery(key, () => new Set([repo]), {}))
30
+ receive(new Set([otherId]))
31
+ expect(invalidations).toEqual([])
32
+
33
+ receive(new Set([repo]))
34
+ expect(invalidations).toEqual([[key]])
35
+ })
36
+
37
+ it("replays writes received while the initial query discovers its dependencies", async () => {
38
+ const invalidations: Array<ReadonlyArray<ReadonlyArray<unknown>>> = []
39
+ let receive = (_writes: DataDependencies.DataDependencies) => {}
40
+ let open = () => {}
41
+ const ready = new Promise<void>((resolve) => open = resolve)
42
+ configureLiveQueryInvalidation({
43
+ ready: () => ready,
44
+ subscribe: (onWrites) => {
45
+ receive = onWrites
46
+ return () => {}
47
+ },
48
+ invalidate: (keys) => invalidations.push(keys)
49
+ })
50
+
51
+ const key = ["$Inventory", "Get", { id: "item-1" }]
52
+ let reads = DataDependencies.empty()
53
+ cleanups.push(registerLiveQuery(key, () => reads, {}))
54
+
55
+ const starting = beginLiveQueryFetch(key)
56
+ receive(new Set([repo]))
57
+ open()
58
+ const wasLive = await starting
59
+ reads = new Set([repo])
60
+ endLiveQueryFetch(wasLive)
61
+
62
+ expect(invalidations).toEqual([[key]])
63
+ })
64
+
65
+ it("coalesces matching writes using the query's maximum delay", () => {
66
+ vi.useFakeTimers()
67
+ const invalidations: Array<ReadonlyArray<ReadonlyArray<unknown>>> = []
68
+ let receive = (_writes: DataDependencies.DataDependencies) => {}
69
+ configureLiveQueryInvalidation({
70
+ ready: () => Promise.resolve(),
71
+ subscribe: (onWrites) => {
72
+ receive = onWrites
73
+ return () => {}
74
+ },
75
+ invalidate: (keys) => invalidations.push(keys)
76
+ })
77
+
78
+ const key = ["$Inventory", "List", undefined]
79
+ cleanups.push(registerLiveQuery(key, () => new Set([repo]), { maxDelayMs: 2_000 }))
80
+ receive(new Set([repo]))
81
+ receive(new Set([repo]))
82
+ vi.advanceTimersByTime(1_999)
83
+ expect(invalidations).toEqual([])
84
+ vi.advanceTimersByTime(1)
85
+ expect(invalidations).toEqual([[key]])
86
+ })
87
+
88
+ it("fails safe by invalidating every live query when the source resets", () => {
89
+ const invalidations: Array<ReadonlyArray<ReadonlyArray<unknown>>> = []
90
+ let reset = () => {}
91
+ configureLiveQueryInvalidation({
92
+ ready: () => Promise.resolve(),
93
+ subscribe: (_onWrites, onReset) => {
94
+ reset = onReset
95
+ return () => {}
96
+ },
97
+ invalidate: (keys) => invalidations.push(keys)
98
+ })
99
+
100
+ const first = ["$Inventory", "List", undefined]
101
+ const second = ["$Orders", "List", undefined]
102
+ cleanups.push(registerLiveQuery(first, () => new Set([repo]), {}))
103
+ cleanups.push(registerLiveQuery(second, () => new Set([otherId]), {}))
104
+ reset()
105
+
106
+ expect(invalidations).toEqual([[first], [second]])
107
+ })
108
+ })
package/tsconfig.json CHANGED
@@ -14,7 +14,12 @@
14
14
  "isolatedModules": true,
15
15
  "esModuleInterop": true,
16
16
  "skipLibCheck": true,
17
- "plugins": [],
17
+ "plugins": [
18
+ {
19
+ "name": "@effect/language-service",
20
+ "diagnostics": false
21
+ }
22
+ ],
18
23
  "module": "Node16",
19
24
  "lib": [
20
25
  "ES2023"