orpc-fn 0.4.0 → 0.5.1
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/README.md +146 -4
- package/dist/cli.js +37 -48
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +29 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +49 -0
- package/dist/client.js.map +1 -1
- package/dist/create-fn.d.ts.map +1 -1
- package/dist/create-fn.js +0 -1
- package/dist/create-fn.js.map +1 -1
- package/dist/hono.d.ts +12 -1
- package/dist/hono.d.ts.map +1 -1
- package/dist/hono.js +15 -1
- package/dist/hono.js.map +1 -1
- package/dist/live/fn-live.d.ts +1 -6
- package/dist/live/fn-live.d.ts.map +1 -1
- package/dist/live/fn-live.js +2 -18
- package/dist/live/fn-live.js.map +1 -1
- package/dist/live/index.d.ts +3 -1
- package/dist/live/index.d.ts.map +1 -1
- package/dist/live/index.js +2 -0
- package/dist/live/index.js.map +1 -1
- package/dist/live/invalidation-stream.d.ts +52 -0
- package/dist/live/invalidation-stream.d.ts.map +1 -0
- package/dist/live/invalidation-stream.js +98 -0
- package/dist/live/invalidation-stream.js.map +1 -0
- package/dist/live/ioredis.d.ts +1 -0
- package/dist/live/ioredis.d.ts.map +1 -1
- package/dist/live/ioredis.js +1 -0
- package/dist/live/ioredis.js.map +1 -1
- package/dist/live/memory.d.ts +2 -1
- package/dist/live/memory.d.ts.map +1 -1
- package/dist/live/memory.js +6 -1
- package/dist/live/memory.js.map +1 -1
- package/dist/live/pub-sub.d.ts +5 -0
- package/dist/live/pub-sub.d.ts.map +1 -1
- package/dist/live/pub-sub.js +16 -3
- package/dist/live/pub-sub.js.map +1 -1
- package/dist/live/redis-bun.d.ts +3 -0
- package/dist/live/redis-bun.d.ts.map +1 -1
- package/dist/live/redis-bun.js +1 -0
- package/dist/live/redis-bun.js.map +1 -1
- package/dist/live/redis.d.ts +4 -1
- package/dist/live/redis.d.ts.map +1 -1
- package/dist/live/redis.js +14 -2
- package/dist/live/redis.js.map +1 -1
- package/dist/live/scoped-change-bus.d.ts +45 -0
- package/dist/live/scoped-change-bus.d.ts.map +1 -0
- package/dist/live/scoped-change-bus.js +38 -0
- package/dist/live/scoped-change-bus.js.map +1 -0
- package/dist/live/transport.d.ts +11 -0
- package/dist/live/transport.d.ts.map +1 -1
- package/dist/live/transport.js.map +1 -1
- package/dist/procedure-cli.d.ts +25 -0
- package/dist/procedure-cli.d.ts.map +1 -0
- package/dist/procedure-cli.js +67 -0
- package/dist/procedure-cli.js.map +1 -0
- package/dist/react.d.ts +116 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +156 -0
- package/dist/react.js.map +1 -0
- package/dist/realtime.d.ts +114 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +316 -0
- package/dist/realtime.js.map +1 -0
- package/dist/vue/testing.d.ts +67 -0
- package/dist/vue/testing.d.ts.map +1 -0
- package/dist/vue/testing.js +225 -0
- package/dist/vue/testing.js.map +1 -0
- package/dist/vue.d.ts +211 -0
- package/dist/vue.d.ts.map +1 -0
- package/dist/vue.js +312 -0
- package/dist/vue.js.map +1 -0
- package/package.json +49 -3
- package/src/cli.ts +47 -57
- package/src/client.ts +57 -0
- package/src/create-fn.ts +0 -1
- package/src/hono.ts +22 -2
- package/src/live/fn-live.ts +3 -22
- package/src/live/index.ts +12 -1
- package/src/live/invalidation-stream.ts +142 -0
- package/src/live/ioredis.ts +2 -0
- package/src/live/memory.ts +11 -2
- package/src/live/pub-sub.ts +23 -2
- package/src/live/redis-bun.ts +3 -0
- package/src/live/redis.ts +33 -6
- package/src/live/scoped-change-bus.ts +98 -0
- package/src/live/transport.ts +12 -0
- package/src/procedure-cli.ts +99 -0
- package/src/react.ts +355 -0
- package/src/realtime.ts +416 -0
- package/src/vue/testing.ts +296 -0
- package/src/vue.ts +783 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type AnyProcedure, call, isProcedure, ORPCError } from '@orpc/server'
|
|
2
|
+
import {
|
|
3
|
+
CliError,
|
|
4
|
+
type Command,
|
|
5
|
+
type CommandGroup,
|
|
6
|
+
defineCommand
|
|
7
|
+
} from 'hanzio/cli'
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
import { type FnMeta, readFnMeta } from './meta.js'
|
|
10
|
+
|
|
11
|
+
export type ProcedureCommandsOptions<C> = {
|
|
12
|
+
/**
|
|
13
|
+
* Which procedures become commands. Default: those declared with
|
|
14
|
+
* `meta: { cli: true }` (add `cli?: boolean` to the app's meta type).
|
|
15
|
+
*/
|
|
16
|
+
filter?: (meta: FnMeta) => boolean
|
|
17
|
+
/**
|
|
18
|
+
* The oRPC context a command calls its procedure with, from the CLI's
|
|
19
|
+
* context (`defineCli({ context })`). Default: that context as-is. It gets
|
|
20
|
+
* the procedure's meta, so it can refuse early with a `CliError`, e.g. a
|
|
21
|
+
* protected procedure run without a user.
|
|
22
|
+
*/
|
|
23
|
+
context?: (cliContext: C, meta: FnMeta) => unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const optedIn = (meta: FnMeta) => (meta.meta as { cli?: unknown }).cli === true
|
|
27
|
+
|
|
28
|
+
const kebab = (key: string) =>
|
|
29
|
+
key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The opted-in `fn()` procedures of a router as hanzio/cli commands, nested
|
|
33
|
+
* like the router: `supportLearning.syncRun` is `support-learning sync-run`.
|
|
34
|
+
* Flags come from the procedure's input schema, help from its summary. A
|
|
35
|
+
* command calls the procedure through oRPC, so middleware, guards, tracing
|
|
36
|
+
* and errors run as they do over HTTP.
|
|
37
|
+
*/
|
|
38
|
+
export function procedureCommands<C = unknown>(
|
|
39
|
+
router: object,
|
|
40
|
+
options: ProcedureCommandsOptions<C> = {}
|
|
41
|
+
): CommandGroup<C> {
|
|
42
|
+
const filter = options.filter ?? optedIn
|
|
43
|
+
const commands: Record<string, Command<C> | CommandGroup<C>> = {}
|
|
44
|
+
|
|
45
|
+
for (const [key, value] of Object.entries(router)) {
|
|
46
|
+
if (isProcedure(value)) {
|
|
47
|
+
const meta = readFnMeta(value)
|
|
48
|
+
if (filter(meta)) commands[kebab(key)] = toCommand(value, meta, options)
|
|
49
|
+
} else if (value && typeof value === 'object') {
|
|
50
|
+
const group = procedureCommands(value, options)
|
|
51
|
+
if (Object.keys(group).length > 0) commands[kebab(key)] = group
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return commands
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toCommand<C>(
|
|
59
|
+
procedure: AnyProcedure,
|
|
60
|
+
meta: FnMeta,
|
|
61
|
+
options: ProcedureCommandsOptions<C>
|
|
62
|
+
): Command<C> {
|
|
63
|
+
const name = meta.name ?? 'procedure'
|
|
64
|
+
if (meta.stream) {
|
|
65
|
+
throw new TypeError(`orpc-fn: ${name} streams and cannot be a CLI command`)
|
|
66
|
+
}
|
|
67
|
+
const input = meta.inputSchema
|
|
68
|
+
if (input !== undefined && !(input instanceof z.ZodObject)) {
|
|
69
|
+
throw new TypeError(
|
|
70
|
+
`orpc-fn: ${name} must take a z.object() input to be a CLI command`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return defineCommand<z.ZodObject, unknown, C>({
|
|
75
|
+
description: meta.description,
|
|
76
|
+
input: input ?? z.object({}),
|
|
77
|
+
run: async (parsed, ctx) => {
|
|
78
|
+
const context = options.context
|
|
79
|
+
? await options.context(ctx.context, meta)
|
|
80
|
+
: ctx.context
|
|
81
|
+
try {
|
|
82
|
+
// The flags were validated against this schema; the procedure parses
|
|
83
|
+
// them again, like any nested call with a parsed value.
|
|
84
|
+
return await call(procedure, input ? parsed : undefined, {
|
|
85
|
+
context: context as Record<never, never>
|
|
86
|
+
})
|
|
87
|
+
} catch (error) {
|
|
88
|
+
// A refusal the caller drives (auth, permission, not found) reads as
|
|
89
|
+
// a message, not a crash with a stack trace.
|
|
90
|
+
if (error instanceof ORPCError && error.status < 500) {
|
|
91
|
+
throw new CliError(`${error.code}: ${error.message}`, {
|
|
92
|
+
cause: error
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
}
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import {
|
|
2
|
+
focusManager,
|
|
3
|
+
hashKey,
|
|
4
|
+
keepPreviousData,
|
|
5
|
+
type QueryKey,
|
|
6
|
+
type UseQueryOptions,
|
|
7
|
+
type UseQueryResult,
|
|
8
|
+
useQuery,
|
|
9
|
+
useQueryClient
|
|
10
|
+
} from '@tanstack/react-query'
|
|
11
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
|
12
|
+
import {
|
|
13
|
+
createInvalidationCoordinator,
|
|
14
|
+
createStreamScope,
|
|
15
|
+
createSubscriptionRunner,
|
|
16
|
+
type InvalidationStreamOptions,
|
|
17
|
+
type LiveConnectionStatus,
|
|
18
|
+
type SubscriptionRunner,
|
|
19
|
+
seedQueryFn
|
|
20
|
+
} from './realtime.js'
|
|
21
|
+
|
|
22
|
+
export type { InvalidationDomain, LiveConnectionStatus } from './realtime.js'
|
|
23
|
+
|
|
24
|
+
/** An oRPC client procedure fits: `client.order.watch`. */
|
|
25
|
+
export type SubscribeFn<TInput, TEvent> = (
|
|
26
|
+
input: TInput,
|
|
27
|
+
options: { signal: AbortSignal }
|
|
28
|
+
) => Promise<AsyncIterable<TEvent>>
|
|
29
|
+
|
|
30
|
+
export type UseOrpcSubscriptionOptions<TInput, TEvent> = {
|
|
31
|
+
/** Run the stream (default: true). */
|
|
32
|
+
enabled?: boolean | undefined
|
|
33
|
+
onEvent?: (
|
|
34
|
+
event: TEvent,
|
|
35
|
+
meta: { connectionId: number; input: TInput }
|
|
36
|
+
) => void
|
|
37
|
+
/** Every successful connect; `connectionId > 1` is a reconnect. */
|
|
38
|
+
onConnect?: (connectionId: number) => void
|
|
39
|
+
onError?: ((error: Error) => void) | undefined
|
|
40
|
+
onClose?: () => void
|
|
41
|
+
/**
|
|
42
|
+
* Close the stream while the app is not focused (default: true). Focus is
|
|
43
|
+
* TanStack's `focusManager`: on React Native, wire it to `AppState` once.
|
|
44
|
+
*/
|
|
45
|
+
pauseWhenUnfocused?: boolean | undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const subscribeFocus = (listener: () => void) =>
|
|
49
|
+
focusManager.subscribe(listener)
|
|
50
|
+
const getFocus = () => focusManager.isFocused()
|
|
51
|
+
|
|
52
|
+
/** Whether the app is focused, from TanStack's `focusManager`. */
|
|
53
|
+
function useIsFocused(): boolean {
|
|
54
|
+
return useSyncExternalStore(subscribeFocus, getFocus, getFocus)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* An oRPC event iterator with backoff reconnects that closes while the app is
|
|
59
|
+
* backgrounded. A changed input (compared as JSON) reconnects with a fresh
|
|
60
|
+
* retry budget.
|
|
61
|
+
*/
|
|
62
|
+
export function useOrpcSubscription<TInput, TEvent>(
|
|
63
|
+
input: TInput,
|
|
64
|
+
subscribe: SubscribeFn<TInput, TEvent>,
|
|
65
|
+
options: UseOrpcSubscriptionOptions<TInput, TEvent> = {}
|
|
66
|
+
): { status: LiveConnectionStatus } {
|
|
67
|
+
const { enabled = true, pauseWhenUnfocused = true } = options
|
|
68
|
+
const focused = useIsFocused()
|
|
69
|
+
const shouldRun = enabled && (!pauseWhenUnfocused || focused)
|
|
70
|
+
const [status, setStatus] = useState<LiveConnectionStatus>('idle')
|
|
71
|
+
|
|
72
|
+
const latest = useRef({ input, subscribe, options, shouldRun })
|
|
73
|
+
latest.current = { input, subscribe, options, shouldRun }
|
|
74
|
+
|
|
75
|
+
const runnerRef = useRef<SubscriptionRunner | null>(null)
|
|
76
|
+
if (!runnerRef.current) {
|
|
77
|
+
let openedInput = input
|
|
78
|
+
runnerRef.current = createSubscriptionRunner<TEvent>({
|
|
79
|
+
open: (signal) => {
|
|
80
|
+
openedInput = latest.current.input
|
|
81
|
+
return latest.current.subscribe(openedInput, { signal })
|
|
82
|
+
},
|
|
83
|
+
canConnect: () => latest.current.shouldRun,
|
|
84
|
+
shouldReconnect: () => latest.current.shouldRun,
|
|
85
|
+
onStatus: setStatus,
|
|
86
|
+
onEvent: (event, connectionId) =>
|
|
87
|
+
latest.current.options.onEvent?.(event, {
|
|
88
|
+
connectionId,
|
|
89
|
+
input: openedInput
|
|
90
|
+
}),
|
|
91
|
+
onConnect: (id) => latest.current.options.onConnect?.(id),
|
|
92
|
+
onError: (error) => latest.current.options.onError?.(error),
|
|
93
|
+
onClose: () => latest.current.options.onClose?.()
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
const runner = runnerRef.current
|
|
97
|
+
const serializedInput = JSON.stringify(input)
|
|
98
|
+
|
|
99
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: a changed input resubscribes
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!shouldRun) return
|
|
102
|
+
void runner.start()
|
|
103
|
+
return () => void runner.disconnect()
|
|
104
|
+
}, [runner, shouldRun, serializedInput])
|
|
105
|
+
|
|
106
|
+
return { status }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type ApplyEvent<TStreamEvent, TData> = (
|
|
110
|
+
event: TStreamEvent,
|
|
111
|
+
previous: TData | undefined
|
|
112
|
+
) => TData | undefined
|
|
113
|
+
|
|
114
|
+
type LiveQueryOptions<
|
|
115
|
+
TInput,
|
|
116
|
+
TRouteData,
|
|
117
|
+
TStreamEvent,
|
|
118
|
+
TData,
|
|
119
|
+
TSubscriptionInput
|
|
120
|
+
> = {
|
|
121
|
+
/** Run the query and its stream (default: true). */
|
|
122
|
+
enabled?: boolean | undefined
|
|
123
|
+
/** Input for the stream when it differs from the query's. */
|
|
124
|
+
subscriptionInput?: TSubscriptionInput | undefined
|
|
125
|
+
/**
|
|
126
|
+
* Convert the route output once at fetch time into a differently typed
|
|
127
|
+
* cache; `applyEvent` then reduces stream events into that cache.
|
|
128
|
+
*/
|
|
129
|
+
select?: (data: TRouteData) => TData
|
|
130
|
+
/** The raw route output of a seed, before `select` drops fields. */
|
|
131
|
+
onSeed?: (data: TRouteData, input: TInput) => void
|
|
132
|
+
/**
|
|
133
|
+
* Fold a stream event into the cached data. Required when the stream sends
|
|
134
|
+
* something other than the cached type (an `fnLive` patch route, or a
|
|
135
|
+
* `select`ed cache). Return `undefined` to skip the write.
|
|
136
|
+
*/
|
|
137
|
+
applyEvent?: ApplyEvent<TStreamEvent, NoInfer<TData>>
|
|
138
|
+
onEvent?: (event: TStreamEvent, meta: { connectionId: number }) => void
|
|
139
|
+
onError?: ((error: Error) => void) | undefined
|
|
140
|
+
/**
|
|
141
|
+
* Reconcile a fetched seed with what the stream already wrote. The HTTP
|
|
142
|
+
* request can resolve last, so a plain overwrite would drop events that
|
|
143
|
+
* arrived while it was in flight. Default: take the fetched value.
|
|
144
|
+
*/
|
|
145
|
+
mergeSeed?: (
|
|
146
|
+
fetched: NoInfer<TData>,
|
|
147
|
+
cached: NoInfer<TData> | undefined
|
|
148
|
+
) => NoInfer<TData>
|
|
149
|
+
/** Show the previous key's data while a new key loads (default: true). */
|
|
150
|
+
keepPreviousData?: boolean
|
|
151
|
+
/** Close the stream while the app is not focused (default: true). */
|
|
152
|
+
pauseWhenUnfocused?: boolean | undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type LiveQueryOptionsFn<TInput, TData, TError> = (
|
|
156
|
+
input: TInput
|
|
157
|
+
) => UseQueryOptions<TData, TError, TData, QueryKey>
|
|
158
|
+
|
|
159
|
+
type LiveQueryResult<TData, TError> = UseQueryResult<TData, TError> & {
|
|
160
|
+
liveStatus: LiveConnectionStatus
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The client half of `fnLive`: the HTTP query seeds the cache, and the live
|
|
165
|
+
* route's stream writes each snapshot into the same cache entry. Marked
|
|
166
|
+
* `meta.live`, so invalidation streams leave it alone.
|
|
167
|
+
*
|
|
168
|
+
* ```tsx
|
|
169
|
+
* const order = useLiveQuery({ id }, (input) => orpc.order.get.queryOptions({ input }), client.order.watch)
|
|
170
|
+
* ```
|
|
171
|
+
*
|
|
172
|
+
* One signature, not overloads: TypeScript fixes an untyped callback's
|
|
173
|
+
* parameters at the first overload it tries, which made valid calls fail.
|
|
174
|
+
*/
|
|
175
|
+
export function useLiveQuery<
|
|
176
|
+
TInput,
|
|
177
|
+
TRouteData,
|
|
178
|
+
TStreamEvent,
|
|
179
|
+
TData = TRouteData,
|
|
180
|
+
TError = Error,
|
|
181
|
+
TSubscriptionInput = TInput
|
|
182
|
+
>(
|
|
183
|
+
input: TInput,
|
|
184
|
+
queryOptionsFn: LiveQueryOptionsFn<TInput, TRouteData, TError>,
|
|
185
|
+
subscribe: SubscribeFn<NoInfer<TSubscriptionInput>, TStreamEvent>,
|
|
186
|
+
...options: [TStreamEvent] extends [TData]
|
|
187
|
+
? [
|
|
188
|
+
options?: LiveQueryOptions<
|
|
189
|
+
TInput,
|
|
190
|
+
TRouteData,
|
|
191
|
+
TStreamEvent,
|
|
192
|
+
TData,
|
|
193
|
+
TSubscriptionInput
|
|
194
|
+
>
|
|
195
|
+
]
|
|
196
|
+
: [
|
|
197
|
+
options: LiveQueryOptions<
|
|
198
|
+
TInput,
|
|
199
|
+
TRouteData,
|
|
200
|
+
TStreamEvent,
|
|
201
|
+
TData,
|
|
202
|
+
TSubscriptionInput
|
|
203
|
+
> & { applyEvent: ApplyEvent<TStreamEvent, NoInfer<TData>> }
|
|
204
|
+
]
|
|
205
|
+
): LiveQueryResult<TData, TError>
|
|
206
|
+
export function useLiveQuery<
|
|
207
|
+
TInput,
|
|
208
|
+
TRouteData,
|
|
209
|
+
TStreamEvent,
|
|
210
|
+
TData,
|
|
211
|
+
TError,
|
|
212
|
+
TSubscriptionInput
|
|
213
|
+
>(
|
|
214
|
+
input: TInput,
|
|
215
|
+
queryOptionsFn: LiveQueryOptionsFn<TInput, TRouteData, TError>,
|
|
216
|
+
subscribe: SubscribeFn<TSubscriptionInput, TStreamEvent>,
|
|
217
|
+
options: LiveQueryOptions<
|
|
218
|
+
TInput,
|
|
219
|
+
TRouteData,
|
|
220
|
+
TStreamEvent,
|
|
221
|
+
TData,
|
|
222
|
+
TSubscriptionInput
|
|
223
|
+
> = {}
|
|
224
|
+
): LiveQueryResult<TData, TError> {
|
|
225
|
+
const queryClient = useQueryClient()
|
|
226
|
+
const focused = useIsFocused()
|
|
227
|
+
const [scope] = useState(createStreamScope)
|
|
228
|
+
|
|
229
|
+
// oRPC query options are plain objects; `queryFn` is read through one shape.
|
|
230
|
+
const raw = queryOptionsFn(input) as unknown as {
|
|
231
|
+
queryKey: QueryKey
|
|
232
|
+
queryFn?: (context: unknown) => Promise<TRouteData>
|
|
233
|
+
enabled?: UseQueryOptions['enabled']
|
|
234
|
+
meta?: Record<string, unknown>
|
|
235
|
+
} & Record<string, unknown>
|
|
236
|
+
const keyHash = hashKey(raw.queryKey)
|
|
237
|
+
const latest = useRef({ raw, keyHash, options })
|
|
238
|
+
latest.current = { raw, keyHash, options }
|
|
239
|
+
|
|
240
|
+
const { status } = useOrpcSubscription(
|
|
241
|
+
(options.subscriptionInput === undefined
|
|
242
|
+
? input
|
|
243
|
+
: options.subscriptionInput) as TSubscriptionInput,
|
|
244
|
+
subscribe,
|
|
245
|
+
{
|
|
246
|
+
enabled: options.enabled,
|
|
247
|
+
pauseWhenUnfocused: options.pauseWhenUnfocused,
|
|
248
|
+
onError: options.onError,
|
|
249
|
+
onConnect: (connectionId) =>
|
|
250
|
+
scope.open(connectionId, latest.current.keyHash),
|
|
251
|
+
onEvent: (event, { connectionId }) => {
|
|
252
|
+
const current = latest.current
|
|
253
|
+
// The consumer callback must not see an event for a scope no longer shown.
|
|
254
|
+
if (!scope.owns(connectionId, current.keyHash)) return
|
|
255
|
+
// Without `applyEvent` the stream event is the cache type (the first overload).
|
|
256
|
+
const applyEvent =
|
|
257
|
+
current.options.applyEvent ??
|
|
258
|
+
((streamEvent: TStreamEvent) => streamEvent as unknown as TData)
|
|
259
|
+
const key = current.raw.queryKey
|
|
260
|
+
const next = applyEvent(event, queryClient.getQueryData<TData>(key))
|
|
261
|
+
if (next !== undefined) queryClient.setQueryData(key, next)
|
|
262
|
+
current.options.onEvent?.(event, { connectionId })
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
const wrapsSeed = options.select || options.mergeSeed
|
|
268
|
+
const queryFn =
|
|
269
|
+
raw.queryFn && wrapsSeed
|
|
270
|
+
? seedQueryFn(raw.queryFn, {
|
|
271
|
+
input,
|
|
272
|
+
readCache: () => queryClient.getQueryData<TData>(raw.queryKey),
|
|
273
|
+
select: options.select,
|
|
274
|
+
onSeed: options.onSeed,
|
|
275
|
+
mergeSeed: options.mergeSeed
|
|
276
|
+
})
|
|
277
|
+
: raw.queryFn
|
|
278
|
+
const runs = options.enabled !== false && focused
|
|
279
|
+
const query = useQuery({
|
|
280
|
+
...raw,
|
|
281
|
+
...(queryFn ? { queryFn } : {}),
|
|
282
|
+
enabled: runs ? (raw.enabled ?? true) : false,
|
|
283
|
+
...(options.keepPreviousData === false
|
|
284
|
+
? {}
|
|
285
|
+
: { placeholderData: keepPreviousData }),
|
|
286
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
287
|
+
meta: { ...raw.meta, live: true }
|
|
288
|
+
} as UseQueryOptions<TData, TError, TData, QueryKey>)
|
|
289
|
+
|
|
290
|
+
return Object.assign(query, { liveStatus: status })
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* One app-wide stream of `{ domain }` change events that keeps list and count
|
|
295
|
+
* queries fresh: each event invalidates its domain's query keys (debounced),
|
|
296
|
+
* and a reconnect that proves healthy invalidates every domain to cover
|
|
297
|
+
* events lost while it was down. Mount once, in the signed-in shell.
|
|
298
|
+
*
|
|
299
|
+
* ```ts
|
|
300
|
+
* useInvalidationStream({}, client.realtime.subscribeChanges, {
|
|
301
|
+
* enabled: signedIn,
|
|
302
|
+
* domains: { order: { debounceMs: 100, queryKeys: [orpc.order.key()] } }
|
|
303
|
+
* })
|
|
304
|
+
* ```
|
|
305
|
+
*/
|
|
306
|
+
export function useInvalidationStream<
|
|
307
|
+
TInput,
|
|
308
|
+
TEvent extends { domain: string }
|
|
309
|
+
>(
|
|
310
|
+
input: TInput,
|
|
311
|
+
subscribe: SubscribeFn<TInput, TEvent>,
|
|
312
|
+
options: InvalidationStreamOptions<TEvent> &
|
|
313
|
+
Pick<
|
|
314
|
+
UseOrpcSubscriptionOptions<TInput, TEvent>,
|
|
315
|
+
'enabled' | 'pauseWhenUnfocused'
|
|
316
|
+
>
|
|
317
|
+
): { status: LiveConnectionStatus } {
|
|
318
|
+
const queryClient = useQueryClient()
|
|
319
|
+
const latestOptions = useRef(options)
|
|
320
|
+
latestOptions.current = options
|
|
321
|
+
// `domains`, `refetchType` and `logger` are read on mount; `onEvent` stays current.
|
|
322
|
+
const [coordinator] = useState(() =>
|
|
323
|
+
createInvalidationCoordinator<TEvent>(queryClient, {
|
|
324
|
+
...options,
|
|
325
|
+
onEvent: (event) => latestOptions.current.onEvent?.(event)
|
|
326
|
+
})
|
|
327
|
+
)
|
|
328
|
+
useEffect(() => coordinator.dispose, [coordinator])
|
|
329
|
+
|
|
330
|
+
return useOrpcSubscription<TInput, TEvent>(input, subscribe, {
|
|
331
|
+
enabled: options.enabled,
|
|
332
|
+
pauseWhenUnfocused: options.pauseWhenUnfocused,
|
|
333
|
+
onEvent: coordinator.event,
|
|
334
|
+
onConnect: coordinator.connect,
|
|
335
|
+
onError: coordinator.lost,
|
|
336
|
+
onClose: coordinator.lost
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Clear the query cache when the signed-in owner of the cached data changes
|
|
342
|
+
* (another user, another organization), so the next one never sees it.
|
|
343
|
+
* `null` means signed out: signing in starts from the cache sign-out emptied.
|
|
344
|
+
* Pass the same owner as the persister's `buster` so a saved cache from
|
|
345
|
+
* someone else is discarded on the next launch too.
|
|
346
|
+
*/
|
|
347
|
+
export function useQueryCacheOwner(owner: string | null): void {
|
|
348
|
+
const queryClient = useQueryClient()
|
|
349
|
+
const previous = useRef(owner)
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
const before = previous.current
|
|
352
|
+
previous.current = owner
|
|
353
|
+
if (owner !== before && before !== null) queryClient.clear()
|
|
354
|
+
}, [owner, queryClient])
|
|
355
|
+
}
|