orpc-fn 0.3.0 → 0.5.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.
Files changed (95) hide show
  1. package/README.md +146 -5
  2. package/dist/client.d.ts +29 -0
  3. package/dist/client.d.ts.map +1 -1
  4. package/dist/client.js +49 -0
  5. package/dist/client.js.map +1 -1
  6. package/dist/create-fn.d.ts.map +1 -1
  7. package/dist/create-fn.js +2 -8
  8. package/dist/create-fn.js.map +1 -1
  9. package/dist/hono.d.ts +12 -1
  10. package/dist/hono.d.ts.map +1 -1
  11. package/dist/hono.js +15 -1
  12. package/dist/hono.js.map +1 -1
  13. package/dist/live/fn-live.d.ts +1 -6
  14. package/dist/live/fn-live.d.ts.map +1 -1
  15. package/dist/live/fn-live.js +2 -18
  16. package/dist/live/fn-live.js.map +1 -1
  17. package/dist/live/index.d.ts +3 -1
  18. package/dist/live/index.d.ts.map +1 -1
  19. package/dist/live/index.js +2 -0
  20. package/dist/live/index.js.map +1 -1
  21. package/dist/live/invalidation-stream.d.ts +52 -0
  22. package/dist/live/invalidation-stream.d.ts.map +1 -0
  23. package/dist/live/invalidation-stream.js +98 -0
  24. package/dist/live/invalidation-stream.js.map +1 -0
  25. package/dist/live/ioredis.d.ts +1 -0
  26. package/dist/live/ioredis.d.ts.map +1 -1
  27. package/dist/live/ioredis.js +1 -0
  28. package/dist/live/ioredis.js.map +1 -1
  29. package/dist/live/memory.d.ts +2 -1
  30. package/dist/live/memory.d.ts.map +1 -1
  31. package/dist/live/memory.js +6 -1
  32. package/dist/live/memory.js.map +1 -1
  33. package/dist/live/pub-sub.d.ts +5 -0
  34. package/dist/live/pub-sub.d.ts.map +1 -1
  35. package/dist/live/pub-sub.js +36 -15
  36. package/dist/live/pub-sub.js.map +1 -1
  37. package/dist/live/redis-bun.d.ts +3 -0
  38. package/dist/live/redis-bun.d.ts.map +1 -1
  39. package/dist/live/redis-bun.js +1 -0
  40. package/dist/live/redis-bun.js.map +1 -1
  41. package/dist/live/redis.d.ts +4 -1
  42. package/dist/live/redis.d.ts.map +1 -1
  43. package/dist/live/redis.js +14 -2
  44. package/dist/live/redis.js.map +1 -1
  45. package/dist/live/scoped-change-bus.d.ts +45 -0
  46. package/dist/live/scoped-change-bus.d.ts.map +1 -0
  47. package/dist/live/scoped-change-bus.js +38 -0
  48. package/dist/live/scoped-change-bus.js.map +1 -0
  49. package/dist/live/transport.d.ts +11 -0
  50. package/dist/live/transport.d.ts.map +1 -1
  51. package/dist/live/transport.js.map +1 -1
  52. package/dist/otel.d.ts +5 -0
  53. package/dist/otel.d.ts.map +1 -1
  54. package/dist/otel.js +7 -0
  55. package/dist/otel.js.map +1 -1
  56. package/dist/procedure-cli.d.ts +25 -0
  57. package/dist/procedure-cli.d.ts.map +1 -0
  58. package/dist/procedure-cli.js +67 -0
  59. package/dist/procedure-cli.js.map +1 -0
  60. package/dist/react.d.ts +116 -0
  61. package/dist/react.d.ts.map +1 -0
  62. package/dist/react.js +156 -0
  63. package/dist/react.js.map +1 -0
  64. package/dist/realtime.d.ts +114 -0
  65. package/dist/realtime.d.ts.map +1 -0
  66. package/dist/realtime.js +316 -0
  67. package/dist/realtime.js.map +1 -0
  68. package/dist/vue/testing.d.ts +67 -0
  69. package/dist/vue/testing.d.ts.map +1 -0
  70. package/dist/vue/testing.js +225 -0
  71. package/dist/vue/testing.js.map +1 -0
  72. package/dist/vue.d.ts +211 -0
  73. package/dist/vue.d.ts.map +1 -0
  74. package/dist/vue.js +312 -0
  75. package/dist/vue.js.map +1 -0
  76. package/package.json +51 -4
  77. package/src/client.ts +57 -0
  78. package/src/create-fn.ts +2 -7
  79. package/src/hono.ts +22 -2
  80. package/src/live/fn-live.ts +3 -22
  81. package/src/live/index.ts +12 -1
  82. package/src/live/invalidation-stream.ts +142 -0
  83. package/src/live/ioredis.ts +2 -0
  84. package/src/live/memory.ts +11 -2
  85. package/src/live/pub-sub.ts +48 -17
  86. package/src/live/redis-bun.ts +3 -0
  87. package/src/live/redis.ts +33 -6
  88. package/src/live/scoped-change-bus.ts +98 -0
  89. package/src/live/transport.ts +12 -0
  90. package/src/otel.ts +8 -0
  91. package/src/procedure-cli.ts +99 -0
  92. package/src/react.ts +355 -0
  93. package/src/realtime.ts +416 -0
  94. package/src/vue/testing.ts +296 -0
  95. package/src/vue.ts +783 -0
package/src/vue.ts ADDED
@@ -0,0 +1,783 @@
1
+ import {
2
+ type DefaultError,
3
+ type FetchQueryOptions,
4
+ focusManager,
5
+ hashKey,
6
+ type InferDataFromTag,
7
+ type InfiniteData,
8
+ type InvalidateQueryFilters,
9
+ type MutationObserverOptions,
10
+ type UseInfiniteQueryOptions,
11
+ type UseInfiniteQueryReturnType,
12
+ type UseMutationReturnType,
13
+ type UseQueryReturnType,
14
+ type Updater,
15
+ keepPreviousData,
16
+ type QueryKey,
17
+ type UseQueryOptions,
18
+ useInfiniteQuery as useVueInfiniteQuery,
19
+ useMutation as useVueMutation,
20
+ useQuery as useVueQuery,
21
+ useQueryClient
22
+ } from '@tanstack/vue-query'
23
+ import {
24
+ type App,
25
+ computed,
26
+ getCurrentScope,
27
+ hasInjectionContext,
28
+ type InjectionKey,
29
+ inject,
30
+ type MaybeRefOrGetter,
31
+ onScopeDispose,
32
+ type Ref,
33
+ ref,
34
+ shallowRef,
35
+ toValue,
36
+ watch
37
+ } from 'vue'
38
+ import { userFacingErrorMessage } from './client.js'
39
+ import {
40
+ createInvalidationCoordinator,
41
+ createStreamScope,
42
+ createSubscriptionRunner,
43
+ type InvalidationStreamOptions,
44
+ type LiveConnectionStatus,
45
+ seedQueryFn
46
+ } from './realtime.js'
47
+
48
+ export type { InvalidationDomain, LiveConnectionStatus } from './realtime.js'
49
+
50
+ /**
51
+ * Register your client types once so every composable infers them:
52
+ *
53
+ * ```ts
54
+ * declare module 'orpc-fn/vue' {
55
+ * interface Register { utils: typeof orpc; client: typeof client }
56
+ * }
57
+ * ```
58
+ */
59
+ // biome-ignore lint/suspicious/noEmptyInterface: filled in by app module augmentation
60
+ export interface Register {}
61
+
62
+ export type RegisteredOrpcUtils = Register extends { utils: infer T }
63
+ ? T
64
+ : {
65
+ 'orpc-fn/vue: register your utils with `declare module "orpc-fn/vue" { interface Register { utils: typeof orpc } }`, or pass `orpc`': never
66
+ }
67
+ export type RegisteredOrpcClient = Register extends { client: infer T }
68
+ ? T
69
+ : unknown
70
+
71
+ /** An explicit `<TData>`, else the data type oRPC tagged on the key. */
72
+ type CachedData<TData, TKey extends QueryKey> = [TData] extends [never]
73
+ ? InferDataFromTag<unknown, TKey>
74
+ : TData
75
+
76
+ type ProvidedOrpc = { client: unknown; utils: unknown }
77
+ const orpcKey: InjectionKey<ProvidedOrpc> = Symbol('orpc-fn')
78
+
79
+ /**
80
+ * Make an oRPC client and its TanStack Query utils the app's default. A
81
+ * surface that talks to another API (an embed with its own auth) passes
82
+ * `orpc` to each composable instead and never needs this.
83
+ */
84
+ export function provideOrpc<TClient, TUtils>(
85
+ app: App,
86
+ orpc: { client: TClient; utils: TUtils }
87
+ ): void {
88
+ app.provide(orpcKey, orpc)
89
+ }
90
+
91
+ const missingOrpc = () =>
92
+ new Error(
93
+ 'orpc-fn/vue: no oRPC client provided. Call provideOrpc(app, { client, utils }) or pass `orpc` to the composable.'
94
+ )
95
+
96
+ /** The client and utils from {@link provideOrpc}. */
97
+ export function useOrpc(): {
98
+ client: RegisteredOrpcClient
99
+ utils: RegisteredOrpcUtils
100
+ } {
101
+ const provided = hasInjectionContext() ? inject(orpcKey, null) : null
102
+ if (!provided) throw missingOrpc()
103
+ return provided as {
104
+ client: RegisteredOrpcClient
105
+ utils: RegisteredOrpcUtils
106
+ }
107
+ }
108
+
109
+ /** Resolve the provided utils now; fail only when they are needed and missing. */
110
+ function useUtilsGetter<TOrpc>(
111
+ explicit: MaybeRefOrGetter<TOrpc> | undefined
112
+ ): () => TOrpc {
113
+ if (explicit) return () => toValue(explicit)
114
+ const provided = hasInjectionContext() ? inject(orpcKey, null) : null
115
+ return () => {
116
+ if (!provided) throw missingOrpc()
117
+ return provided.utils as TOrpc
118
+ }
119
+ }
120
+
121
+ let documentVisible: Ref<boolean> | null = null
122
+ /**
123
+ * Whether the page is visible, from TanStack's `focusManager` (which follows
124
+ * `visibilitychange` in browsers). Live streams pause while it is false.
125
+ */
126
+ function useDocumentVisible(): Ref<boolean> {
127
+ if (!documentVisible) {
128
+ const visible = ref(focusManager.isFocused())
129
+ focusManager.subscribe((focused) => {
130
+ visible.value = focused
131
+ })
132
+ documentVisible = visible
133
+ }
134
+ return documentVisible
135
+ }
136
+
137
+ function resolveQueryEnabled(enabled: unknown): boolean {
138
+ if (enabled === undefined) return true
139
+ if (typeof enabled === 'function') return Boolean(enabled())
140
+ return Boolean(toValue(enabled))
141
+ }
142
+
143
+ /**
144
+ * Picks the stream to open from the utils. A procedure's `call` fits as is:
145
+ * `(o) => o.order.watch.call`. The composable passes the input and an abort signal.
146
+ */
147
+ export type OrpcSubscribeFn<TInput, TEvent, TOrpc = RegisteredOrpcUtils> = (
148
+ orpc: TOrpc
149
+ ) => (
150
+ input: TInput,
151
+ options: { signal: AbortSignal }
152
+ ) => Promise<AsyncIterable<TEvent>>
153
+
154
+ export interface UseSubscriptionOptions<TEvent, TOrpc = RegisteredOrpcUtils> {
155
+ /** Start the stream on its own (default: true). With false, call `connect()`. */
156
+ immediate?: MaybeRefOrGetter<boolean> | undefined
157
+ onEvent?: (event: TEvent, meta?: { connectionId: number }) => void
158
+ onError?: ((error: Error) => void) | undefined
159
+ /** Every successful connect; `connectionId > 1` is a reconnect. */
160
+ onConnect?: (connectionId: number) => void
161
+ onClose?: () => void
162
+ /** Events kept in `events` (default: 100). 1 keeps only `latestEvent`. */
163
+ maxEvents?: number
164
+ /** Reconnect when the input changes (default: true). */
165
+ autoReconnectOnInputChange?: boolean
166
+ /**
167
+ * After a connect, reconnect with backoff when the stream ends or errors
168
+ * unexpectedly (proxy idle timeout, network blip). Deliberate teardown
169
+ * does not retry. Default: true.
170
+ */
171
+ autoReconnectOnClose?: boolean
172
+ orpc?: MaybeRefOrGetter<TOrpc> | undefined
173
+ /**
174
+ * Close the stream while the page is hidden (default: true), so a
175
+ * background tab holds no server connection. It reconnects, and a live
176
+ * route re-sends its snapshot, when the page is visible again.
177
+ */
178
+ respectVisibility?: boolean | undefined
179
+ }
180
+
181
+ export interface UseSubscriptionReturn<TEvent> {
182
+ events: Ref<TEvent[]>
183
+ latestEvent: Ref<TEvent | undefined>
184
+ status: Ref<LiveConnectionStatus>
185
+ error: Ref<Error | null>
186
+ isConnected: Ref<boolean>
187
+ connect: () => Promise<void>
188
+ disconnect: () => Promise<void>
189
+ clearEvents: () => void
190
+ }
191
+
192
+ /**
193
+ * An oRPC event iterator as reactive state, with backoff reconnects and a
194
+ * pause while the page is hidden. Closed when the owning effect scope stops.
195
+ */
196
+ export function useOrpcSubscription<
197
+ TInput,
198
+ TEvent,
199
+ TOrpc = RegisteredOrpcUtils
200
+ >(
201
+ input: MaybeRefOrGetter<TInput>,
202
+ subscribeFn: OrpcSubscribeFn<TInput, TEvent, TOrpc>,
203
+ options: UseSubscriptionOptions<TEvent, TOrpc> = {}
204
+ ): UseSubscriptionReturn<TEvent> {
205
+ const {
206
+ immediate = true,
207
+ maxEvents = 100,
208
+ autoReconnectOnInputChange = true,
209
+ autoReconnectOnClose = true,
210
+ respectVisibility = true
211
+ } = options
212
+ const getUtils = useUtilsGetter(options.orpc)
213
+ const visible = useDocumentVisible()
214
+
215
+ // May a stream run at all? This gates a manual connect() too.
216
+ const isStreamAllowed = computed(() => !respectVisibility || visible.value)
217
+ // Should it start on its own? That is what `immediate` means.
218
+ const shouldAutoConnect = computed(
219
+ () => toValue(immediate) && isStreamAllowed.value
220
+ )
221
+
222
+ // Shallow: event payloads are replaced, never mutated, so deep proxies are wasted work.
223
+ const events = shallowRef<TEvent[]>([])
224
+ const latestEvent = shallowRef<TEvent | undefined>(undefined)
225
+ const status = ref<LiveConnectionStatus>('idle')
226
+ const error = ref<Error | null>(null)
227
+
228
+ const runner = createSubscriptionRunner<TEvent>({
229
+ open: (signal) => subscribeFn(getUtils())(toValue(input), { signal }),
230
+ canConnect: () => isStreamAllowed.value,
231
+ shouldReconnect: () => autoReconnectOnClose && shouldAutoConnect.value,
232
+ onStatus: (next) => {
233
+ status.value = next
234
+ if (next === 'connecting') error.value = null
235
+ },
236
+ onEvent: (event, connectionId) => {
237
+ latestEvent.value = event
238
+ if (maxEvents > 1) {
239
+ events.value = [...events.value.slice(-(maxEvents - 1)), event]
240
+ }
241
+ options.onEvent?.(event, { connectionId })
242
+ },
243
+ onError: (err) => {
244
+ error.value = err
245
+ options.onError?.(err)
246
+ },
247
+ onConnect: options.onConnect,
248
+ onClose: options.onClose
249
+ })
250
+
251
+ function clearEvents() {
252
+ events.value = []
253
+ latestEvent.value = undefined
254
+ }
255
+
256
+ if (autoReconnectOnInputChange) {
257
+ watch(
258
+ () => toValue(input),
259
+ async () => {
260
+ const wasLive = runner.isLive()
261
+ if (runner.getStatus() !== 'idle') {
262
+ await runner.disconnect()
263
+ clearEvents()
264
+ }
265
+ // Reconnect in auto mode, or in manual mode when it was running.
266
+ if (shouldAutoConnect.value || (wasLive && isStreamAllowed.value)) {
267
+ await runner.start()
268
+ }
269
+ },
270
+ { deep: true }
271
+ )
272
+ }
273
+
274
+ // Visibility closes the stream in both modes; only the re-open waits for `immediate`.
275
+ watch(
276
+ isStreamAllowed,
277
+ (allowed) => {
278
+ if (!allowed) void runner.disconnect()
279
+ else if (toValue(immediate)) {
280
+ error.value = null
281
+ void runner.start()
282
+ }
283
+ },
284
+ { immediate: true }
285
+ )
286
+
287
+ watch(
288
+ () => toValue(immediate),
289
+ (auto) => {
290
+ if (!auto) void runner.disconnect()
291
+ else if (isStreamAllowed.value) {
292
+ error.value = null
293
+ void runner.start()
294
+ }
295
+ }
296
+ )
297
+
298
+ // A detached effect scope may own the stream, not just a component.
299
+ if (getCurrentScope()) onScopeDispose(() => void runner.dispose())
300
+
301
+ return {
302
+ events,
303
+ latestEvent,
304
+ status,
305
+ error,
306
+ isConnected: computed(() => status.value === 'connected'),
307
+ connect: runner.connect,
308
+ disconnect: runner.disconnect,
309
+ clearEvents
310
+ }
311
+ }
312
+
313
+ /**
314
+ * TanStack Query composables that receive the oRPC utils, so call sites read
315
+ * `useQuery((o) => o.order.get.queryOptions({ input }))`. Pass `orpc` to bind
316
+ * another client than the provided one.
317
+ */
318
+ export function useApiClient<TOrpc = RegisteredOrpcUtils>(clientOptions?: {
319
+ orpc?: MaybeRefOrGetter<TOrpc> | undefined
320
+ }) {
321
+ const queryClient = useQueryClient()
322
+ const getO = useUtilsGetter(clientOptions?.orpc)
323
+
324
+ // Each wrapper forwards TanStack's own generics, so `select`, typed errors,
325
+ // page params and oRPC's tagged keys infer exactly as with vue-query itself.
326
+ function useQuery<
327
+ TQueryFnData,
328
+ TError = DefaultError,
329
+ TData = TQueryFnData,
330
+ TQueryKey extends QueryKey = QueryKey
331
+ >(
332
+ optionsFn: (
333
+ orpc: TOrpc
334
+ ) => UseQueryOptions<TQueryFnData, TError, TData, TQueryFnData, TQueryKey>
335
+ ): UseQueryReturnType<TData, TError> {
336
+ // A computed is a ref at runtime; TypeScript cannot relate a computed of
337
+ // the generic options to vue-query's MaybeRef parameter, so it is asserted.
338
+ const options = computed(() => optionsFn(getO()))
339
+ return useVueQuery<TQueryFnData, TError, TData, TQueryKey>(
340
+ options as unknown as UseQueryOptions<
341
+ TQueryFnData,
342
+ TError,
343
+ TData,
344
+ TQueryFnData,
345
+ TQueryKey
346
+ >
347
+ )
348
+ }
349
+
350
+ /** `useInfiniteQuery((o) => o.order.list.infiniteOptions({ input, initialPageParam, getNextPageParam }))` */
351
+ function useInfiniteQuery<
352
+ TQueryFnData,
353
+ TError = DefaultError,
354
+ TData = InfiniteData<TQueryFnData>,
355
+ TQueryKey extends QueryKey = QueryKey,
356
+ TPageParam = unknown
357
+ >(
358
+ optionsFn: (
359
+ orpc: TOrpc
360
+ ) => UseInfiniteQueryOptions<
361
+ TQueryFnData,
362
+ TError,
363
+ TData,
364
+ TQueryKey,
365
+ TPageParam
366
+ >
367
+ ): UseInfiniteQueryReturnType<TData, TError> {
368
+ const options = computed(() => optionsFn(getO()))
369
+ return useVueInfiniteQuery<
370
+ TQueryFnData,
371
+ TError,
372
+ TData,
373
+ TQueryKey,
374
+ TPageParam
375
+ >(
376
+ options as unknown as UseInfiniteQueryOptions<
377
+ TQueryFnData,
378
+ TError,
379
+ TData,
380
+ TQueryKey,
381
+ TPageParam
382
+ >
383
+ )
384
+ }
385
+
386
+ /**
387
+ * With `initialInput`, also returns `inputData`, a ref that `mutate()` and
388
+ * `mutateAsync()` use when called without variables (form state).
389
+ */
390
+ function useMutation<
391
+ TData = unknown,
392
+ TError = DefaultError,
393
+ TVariables = void,
394
+ TContext = unknown
395
+ >(
396
+ optionsFn: (
397
+ orpc: TOrpc
398
+ ) => MutationObserverOptions<TData, TError, TVariables, TContext>,
399
+ config: { initialInput: TVariables }
400
+ ): UseMutationReturnType<TData, TError, TVariables, TContext> & {
401
+ inputData: Ref<TVariables>
402
+ mutate: (variables?: TVariables) => void
403
+ mutateAsync: (variables?: TVariables) => Promise<TData>
404
+ }
405
+ function useMutation<
406
+ TData = unknown,
407
+ TError = DefaultError,
408
+ TVariables = void,
409
+ TContext = unknown
410
+ >(
411
+ optionsFn: (
412
+ orpc: TOrpc
413
+ ) => MutationObserverOptions<TData, TError, TVariables, TContext>
414
+ ): UseMutationReturnType<TData, TError, TVariables, TContext>
415
+ function useMutation<TData, TError, TVariables, TContext>(
416
+ optionsFn: (
417
+ orpc: TOrpc
418
+ ) => MutationObserverOptions<TData, TError, TVariables, TContext>,
419
+ config?: { initialInput: TVariables }
420
+ ) {
421
+ const mutation = useVueMutation(computed(() => optionsFn(getO())))
422
+ if (config?.initialInput === undefined) return mutation
423
+ const inputData = ref(config.initialInput) as Ref<TVariables>
424
+ return {
425
+ ...mutation,
426
+ inputData,
427
+ mutate: (variables?: TVariables) =>
428
+ mutation.mutate(variables ?? inputData.value),
429
+ mutateAsync: (variables?: TVariables) =>
430
+ mutation.mutateAsync(variables ?? inputData.value)
431
+ }
432
+ }
433
+
434
+ function useSubscription<TInput, TEvent>(
435
+ input: MaybeRefOrGetter<TInput>,
436
+ subscribeFn: OrpcSubscribeFn<TInput, TEvent, TOrpc>,
437
+ options: UseSubscriptionOptions<TEvent, TOrpc> = {}
438
+ ) {
439
+ return useOrpcSubscription<TInput, TEvent, TOrpc>(input, subscribeFn, {
440
+ ...options,
441
+ orpc: getO
442
+ })
443
+ }
444
+
445
+ const keyOf = <TKey extends QueryKey>(keyFn: (orpc: TOrpc) => TKey) =>
446
+ keyFn(getO())
447
+
448
+ return {
449
+ useQuery,
450
+ useInfiniteQuery,
451
+ useMutation,
452
+ useSubscription,
453
+ invalidate: (
454
+ keyFn: (orpc: TOrpc) => QueryKey,
455
+ filters?: Omit<InvalidateQueryFilters, 'queryKey'>
456
+ ) => queryClient.invalidateQueries({ queryKey: keyOf(keyFn), ...filters }),
457
+ /** Route preloading and hover-to-prefetch. */
458
+ prefetch: <
459
+ TQueryFnData,
460
+ TError = DefaultError,
461
+ TData = TQueryFnData,
462
+ TQueryKey extends QueryKey = QueryKey
463
+ >(
464
+ optionsFn: (
465
+ orpc: TOrpc
466
+ ) => FetchQueryOptions<TQueryFnData, TError, TData, TQueryKey>
467
+ ) => queryClient.prefetchQuery(optionsFn(getO())),
468
+ /** Data is typed from oRPC's tagged key; pass `<TData>` for an untagged one. */
469
+ getQueryData: <TData = never, TKey extends QueryKey = QueryKey>(
470
+ keyFn: (orpc: TOrpc) => TKey
471
+ ): CachedData<TData, TKey> | undefined =>
472
+ queryClient.getQueryData<CachedData<TData, TKey>>(keyOf(keyFn)),
473
+ /** Optimistic updates, typed like `getQueryData`. */
474
+ setQueryData: <TData = never, TKey extends QueryKey = QueryKey>(
475
+ keyFn: (orpc: TOrpc) => TKey,
476
+ updater: Updater<
477
+ CachedData<TData, TKey> | undefined,
478
+ CachedData<TData, TKey> | undefined
479
+ >
480
+ ) =>
481
+ queryClient.setQueryData<CachedData<TData, TKey>>(keyOf(keyFn), updater),
482
+ cancelQueries: (keyFn: (orpc: TOrpc) => QueryKey) =>
483
+ queryClient.cancelQueries({ queryKey: keyOf(keyFn) }),
484
+ removeQueries: (keyFn: (orpc: TOrpc) => QueryKey) =>
485
+ queryClient.removeQueries({ queryKey: keyOf(keyFn) }),
486
+ isFetching: (keyFn?: (orpc: TOrpc) => QueryKey) =>
487
+ queryClient.isFetching(keyFn ? { queryKey: keyOf(keyFn) } : {}),
488
+ queryClient
489
+ }
490
+ }
491
+
492
+ type ApplyEvent<TStreamEvent, TData> = (
493
+ event: TStreamEvent,
494
+ previous: TData | undefined
495
+ ) => TData | undefined
496
+
497
+ type LiveQueryOptions<
498
+ TInput,
499
+ TRouteData,
500
+ TStreamEvent,
501
+ TData,
502
+ TOrpc,
503
+ TSubscriptionInput
504
+ > = {
505
+ immediate?: MaybeRefOrGetter<boolean>
506
+ orpc?: MaybeRefOrGetter<TOrpc>
507
+ /** Input for the stream when it differs from the query's. */
508
+ subscriptionInput?: MaybeRefOrGetter<TSubscriptionInput>
509
+ /**
510
+ * Convert the route output once at fetch time into a differently typed
511
+ * cache; `applyEvent` then reduces stream events into that cache.
512
+ */
513
+ select?: (data: TRouteData) => TData
514
+ /**
515
+ * The raw route output of a seed before `select` drops fields. `input` is
516
+ * what the seed was fetched for, so a seed that resolves after the input
517
+ * moved on can be told apart.
518
+ */
519
+ onSeed?: (data: TRouteData, input: TInput) => void
520
+ /**
521
+ * Fold a stream event into the cached data. Required when the stream sends
522
+ * something other than the cached type (an `fnLive` patch route, or a
523
+ * `select`ed cache). Return `undefined` to skip the write.
524
+ */
525
+ applyEvent?: ApplyEvent<TStreamEvent, NoInfer<TData>>
526
+ onEvent?: (event: TStreamEvent, meta?: { connectionId: number }) => void
527
+ onError?: (error: Error) => void
528
+ /**
529
+ * Reconcile a fetched seed with what the stream already wrote. The HTTP
530
+ * request can resolve last, so a plain overwrite would drop events that
531
+ * arrived while it was in flight. Default: take the fetched value.
532
+ */
533
+ mergeSeed?: (
534
+ fetched: NoInfer<TData>,
535
+ cached: NoInfer<TData> | undefined
536
+ ) => NoInfer<TData>
537
+ /**
538
+ * Show the previous key's data while a new key loads (default: true). Pass
539
+ * false when the key says which entity is shown, not just how it is
540
+ * filtered: another conversation's timeline must not stand in for this one.
541
+ */
542
+ keepPreviousData?: boolean
543
+ /** Pause the stream while the page is hidden (default: true). */
544
+ respectVisibility?: boolean
545
+ }
546
+
547
+ type LiveQueryReturn<TData, TError> = ReturnType<
548
+ typeof useVueQuery<TData, TError>
549
+ > & { liveStatus: Ref<LiveConnectionStatus> }
550
+
551
+ /**
552
+ * The client half of `fnLive`: the HTTP query seeds the cache, and the live
553
+ * route's stream writes each snapshot into the same cache entry. Marked
554
+ * `meta.live`, so invalidation streams leave it alone.
555
+ *
556
+ * One signature, not overloads: TypeScript fixes an untyped callback's
557
+ * parameters at the first overload it tries, which made valid calls fail.
558
+ */
559
+ export function useLiveQuery<
560
+ TInput,
561
+ TRouteData,
562
+ TStreamEvent,
563
+ TData = TRouteData,
564
+ TError = Error,
565
+ TOrpc = RegisteredOrpcUtils,
566
+ TSubscriptionInput = TInput
567
+ >(
568
+ input: MaybeRefOrGetter<TInput>,
569
+ queryOptionsFn: (
570
+ input: TInput,
571
+ orpc: TOrpc
572
+ ) => UseQueryOptions<TRouteData, TError> & { queryKey: QueryKey },
573
+ subscribeFn: OrpcSubscribeFn<
574
+ NoInfer<TSubscriptionInput>,
575
+ TStreamEvent,
576
+ TOrpc
577
+ >,
578
+ ...options: [TStreamEvent] extends [TData]
579
+ ? [
580
+ options?: LiveQueryOptions<
581
+ TInput,
582
+ TRouteData,
583
+ TStreamEvent,
584
+ TData,
585
+ TOrpc,
586
+ TSubscriptionInput
587
+ >
588
+ ]
589
+ : [
590
+ options: LiveQueryOptions<
591
+ TInput,
592
+ TRouteData,
593
+ TStreamEvent,
594
+ TData,
595
+ TOrpc,
596
+ TSubscriptionInput
597
+ > & { applyEvent: ApplyEvent<TStreamEvent, NoInfer<TData>> }
598
+ ]
599
+ ): LiveQueryReturn<TData, TError>
600
+ export function useLiveQuery<
601
+ TInput,
602
+ TRouteData,
603
+ TStreamEvent,
604
+ TData,
605
+ TError,
606
+ TOrpc,
607
+ TSubscriptionInput
608
+ >(
609
+ input: MaybeRefOrGetter<TInput>,
610
+ queryOptionsFn: (
611
+ input: TInput,
612
+ orpc: TOrpc
613
+ ) => UseQueryOptions<TRouteData, TError> & { queryKey: QueryKey },
614
+ subscribeFn: OrpcSubscribeFn<TSubscriptionInput, TStreamEvent, TOrpc>,
615
+ options: LiveQueryOptions<
616
+ TInput,
617
+ TRouteData,
618
+ TStreamEvent,
619
+ TData,
620
+ TOrpc,
621
+ TSubscriptionInput
622
+ > = {}
623
+ ): LiveQueryReturn<TData, TError> {
624
+ const queryClient = useQueryClient()
625
+ const getO = useUtilsGetter(options.orpc)
626
+ const visible = useDocumentVisible()
627
+ // Without `applyEvent` the stream event is the cache type (the first overload).
628
+ const applyEvent =
629
+ options.applyEvent ?? ((event: TStreamEvent) => event as unknown as TData)
630
+ // Recomputed when the input changes, not once per stream event.
631
+ const queryKey = computed(
632
+ () => queryOptionsFn(toValue(input), getO()).queryKey
633
+ )
634
+ const queryHash = computed(() => hashKey(queryKey.value))
635
+ const scope = createStreamScope()
636
+
637
+ const subscription = useOrpcSubscription(
638
+ computed(
639
+ () =>
640
+ (options.subscriptionInput === undefined
641
+ ? toValue(input)
642
+ : toValue(options.subscriptionInput)) as TSubscriptionInput
643
+ ),
644
+ subscribeFn,
645
+ {
646
+ immediate: options.immediate,
647
+ respectVisibility: options.respectVisibility,
648
+ onError: options.onError,
649
+ maxEvents: 1,
650
+ orpc: getO,
651
+ onConnect: (connectionId) => scope.open(connectionId, queryHash.value),
652
+ onEvent: (event, meta) => {
653
+ // The consumer callback must not see an event for a scope no longer shown.
654
+ if (!meta || !scope.owns(meta.connectionId, queryHash.value)) return
655
+ const key = queryKey.value
656
+ const next = applyEvent(event, queryClient.getQueryData<TData>(key))
657
+ if (next !== undefined) queryClient.setQueryData(key, next)
658
+ options.onEvent?.(event, meta)
659
+ }
660
+ }
661
+ )
662
+
663
+ const query = useVueQuery<TData, TError>(
664
+ computed(() => {
665
+ const inputValue = toValue(input)
666
+ // oRPC query options are plain objects at runtime; vue-query's type
667
+ // wraps fields in MaybeRefDeep, so `queryFn` is read through one shape.
668
+ const raw = queryOptionsFn(inputValue, getO()) as unknown as {
669
+ queryKey: QueryKey
670
+ queryFn?: (context: unknown) => Promise<TRouteData>
671
+ enabled?: unknown
672
+ } & Record<string, unknown>
673
+ const wrapsSeed = options.select || options.mergeSeed
674
+ const queryFn =
675
+ raw.queryFn && wrapsSeed
676
+ ? seedQueryFn(raw.queryFn, {
677
+ input: inputValue,
678
+ readCache: () => queryClient.getQueryData<TData>(raw.queryKey),
679
+ select: options.select,
680
+ onSeed: options.onSeed,
681
+ mergeSeed: options.mergeSeed
682
+ })
683
+ : raw.queryFn
684
+ return {
685
+ ...raw,
686
+ ...(queryFn ? { queryFn } : {}),
687
+ enabled: visible.value && resolveQueryEnabled(raw.enabled),
688
+ ...(options.keepPreviousData === false
689
+ ? {}
690
+ : { placeholderData: keepPreviousData }),
691
+ staleTime: Number.POSITIVE_INFINITY,
692
+ meta: { live: true }
693
+ } as unknown as UseQueryOptions<TData, TError>
694
+ })
695
+ )
696
+
697
+ return Object.assign(query, { liveStatus: subscription.status })
698
+ }
699
+
700
+ /**
701
+ * Treat a stream as a "something changed" ping: invalidate `keyFn` on each
702
+ * event. Prefer {@link useLiveQuery} when the stream carries the data itself.
703
+ */
704
+ export function useSubscriptionInvalidates<
705
+ TInput,
706
+ TEvent,
707
+ TOrpc = RegisteredOrpcUtils
708
+ >(
709
+ input: MaybeRefOrGetter<TInput>,
710
+ subscribeFn: OrpcSubscribeFn<TInput, TEvent, TOrpc>,
711
+ keyFn: (orpc: TOrpc) => QueryKey,
712
+ options: Pick<
713
+ UseSubscriptionOptions<TEvent, TOrpc>,
714
+ 'immediate' | 'maxEvents' | 'respectVisibility' | 'orpc'
715
+ > & {
716
+ onEvent?: (event: TEvent) => void
717
+ /** Return false to skip invalidation for this event. */
718
+ shouldInvalidate?: (event: TEvent) => boolean
719
+ } = {}
720
+ ): UseSubscriptionReturn<TEvent> {
721
+ const { onEvent, shouldInvalidate, ...subscriptionOptions } = options
722
+ const { useSubscription, invalidate } = useApiClient<TOrpc>({
723
+ orpc: options.orpc
724
+ })
725
+ return useSubscription(input, subscribeFn, {
726
+ ...subscriptionOptions,
727
+ onEvent: (event) => {
728
+ onEvent?.(event)
729
+ if (shouldInvalidate?.(event) === false) return
730
+ void invalidate(keyFn)
731
+ }
732
+ })
733
+ }
734
+
735
+ /**
736
+ * One app-wide stream of `{ domain }` change events that keeps list and count
737
+ * queries fresh: each event invalidates its domain's query keys (debounced),
738
+ * and a reconnect that proves healthy invalidates every domain to cover
739
+ * events lost while it was down. Call once, in the signed-in shell.
740
+ *
741
+ * ```ts
742
+ * useInvalidationStream({}, (o) => o.realtime.subscribeChanges.call, {
743
+ * domains: { order: { debounceMs: 100, queryKeys: [orpc.order.key()] } }
744
+ * })
745
+ * ```
746
+ */
747
+ export function useInvalidationStream<
748
+ TInput,
749
+ TEvent extends { domain: string },
750
+ TOrpc = RegisteredOrpcUtils
751
+ >(
752
+ input: MaybeRefOrGetter<TInput>,
753
+ subscribe: OrpcSubscribeFn<TInput, TEvent, TOrpc>,
754
+ options: InvalidationStreamOptions<TEvent> &
755
+ Pick<
756
+ UseSubscriptionOptions<TEvent, TOrpc>,
757
+ 'immediate' | 'respectVisibility' | 'orpc'
758
+ >
759
+ ): UseSubscriptionReturn<TEvent> {
760
+ const coordinator = createInvalidationCoordinator(useQueryClient(), options)
761
+ if (getCurrentScope()) onScopeDispose(coordinator.dispose)
762
+ return useOrpcSubscription<TInput, TEvent, TOrpc>(input, subscribe, {
763
+ immediate: options.immediate,
764
+ respectVisibility: options.respectVisibility,
765
+ orpc: options.orpc,
766
+ maxEvents: 1,
767
+ onEvent: coordinator.event,
768
+ onConnect: coordinator.connect,
769
+ onError: coordinator.lost,
770
+ onClose: coordinator.lost
771
+ })
772
+ }
773
+
774
+ /** Localized text for a failed query: your fallback unless the server marked its message user-safe. */
775
+ export function useQueryErrorMessage(
776
+ error: MaybeRefOrGetter<unknown>,
777
+ fallback: MaybeRefOrGetter<string>
778
+ ) {
779
+ return computed(() => {
780
+ const err = toValue(error)
781
+ return err ? userFacingErrorMessage(err, toValue(fallback)) : null
782
+ })
783
+ }