orpc-fn 0.4.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.
- package/README.md +146 -4
- 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 +51 -3
- 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
package/dist/react.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { focusManager, hashKey, keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
3
|
+
import { createInvalidationCoordinator, createStreamScope, createSubscriptionRunner, seedQueryFn } from './realtime.js';
|
|
4
|
+
const subscribeFocus = (listener) => focusManager.subscribe(listener);
|
|
5
|
+
const getFocus = () => focusManager.isFocused();
|
|
6
|
+
/** Whether the app is focused, from TanStack's `focusManager`. */
|
|
7
|
+
function useIsFocused() {
|
|
8
|
+
return useSyncExternalStore(subscribeFocus, getFocus, getFocus);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* An oRPC event iterator with backoff reconnects that closes while the app is
|
|
12
|
+
* backgrounded. A changed input (compared as JSON) reconnects with a fresh
|
|
13
|
+
* retry budget.
|
|
14
|
+
*/
|
|
15
|
+
export function useOrpcSubscription(input, subscribe, options = {}) {
|
|
16
|
+
const { enabled = true, pauseWhenUnfocused = true } = options;
|
|
17
|
+
const focused = useIsFocused();
|
|
18
|
+
const shouldRun = enabled && (!pauseWhenUnfocused || focused);
|
|
19
|
+
const [status, setStatus] = useState('idle');
|
|
20
|
+
const latest = useRef({ input, subscribe, options, shouldRun });
|
|
21
|
+
latest.current = { input, subscribe, options, shouldRun };
|
|
22
|
+
const runnerRef = useRef(null);
|
|
23
|
+
if (!runnerRef.current) {
|
|
24
|
+
let openedInput = input;
|
|
25
|
+
runnerRef.current = createSubscriptionRunner({
|
|
26
|
+
open: (signal) => {
|
|
27
|
+
openedInput = latest.current.input;
|
|
28
|
+
return latest.current.subscribe(openedInput, { signal });
|
|
29
|
+
},
|
|
30
|
+
canConnect: () => latest.current.shouldRun,
|
|
31
|
+
shouldReconnect: () => latest.current.shouldRun,
|
|
32
|
+
onStatus: setStatus,
|
|
33
|
+
onEvent: (event, connectionId) => latest.current.options.onEvent?.(event, {
|
|
34
|
+
connectionId,
|
|
35
|
+
input: openedInput
|
|
36
|
+
}),
|
|
37
|
+
onConnect: (id) => latest.current.options.onConnect?.(id),
|
|
38
|
+
onError: (error) => latest.current.options.onError?.(error),
|
|
39
|
+
onClose: () => latest.current.options.onClose?.()
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const runner = runnerRef.current;
|
|
43
|
+
const serializedInput = JSON.stringify(input);
|
|
44
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: a changed input resubscribes
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!shouldRun)
|
|
47
|
+
return;
|
|
48
|
+
void runner.start();
|
|
49
|
+
return () => void runner.disconnect();
|
|
50
|
+
}, [runner, shouldRun, serializedInput]);
|
|
51
|
+
return { status };
|
|
52
|
+
}
|
|
53
|
+
export function useLiveQuery(input, queryOptionsFn, subscribe, options = {}) {
|
|
54
|
+
const queryClient = useQueryClient();
|
|
55
|
+
const focused = useIsFocused();
|
|
56
|
+
const [scope] = useState(createStreamScope);
|
|
57
|
+
// oRPC query options are plain objects; `queryFn` is read through one shape.
|
|
58
|
+
const raw = queryOptionsFn(input);
|
|
59
|
+
const keyHash = hashKey(raw.queryKey);
|
|
60
|
+
const latest = useRef({ raw, keyHash, options });
|
|
61
|
+
latest.current = { raw, keyHash, options };
|
|
62
|
+
const { status } = useOrpcSubscription((options.subscriptionInput === undefined
|
|
63
|
+
? input
|
|
64
|
+
: options.subscriptionInput), subscribe, {
|
|
65
|
+
enabled: options.enabled,
|
|
66
|
+
pauseWhenUnfocused: options.pauseWhenUnfocused,
|
|
67
|
+
onError: options.onError,
|
|
68
|
+
onConnect: (connectionId) => scope.open(connectionId, latest.current.keyHash),
|
|
69
|
+
onEvent: (event, { connectionId }) => {
|
|
70
|
+
const current = latest.current;
|
|
71
|
+
// The consumer callback must not see an event for a scope no longer shown.
|
|
72
|
+
if (!scope.owns(connectionId, current.keyHash))
|
|
73
|
+
return;
|
|
74
|
+
// Without `applyEvent` the stream event is the cache type (the first overload).
|
|
75
|
+
const applyEvent = current.options.applyEvent ??
|
|
76
|
+
((streamEvent) => streamEvent);
|
|
77
|
+
const key = current.raw.queryKey;
|
|
78
|
+
const next = applyEvent(event, queryClient.getQueryData(key));
|
|
79
|
+
if (next !== undefined)
|
|
80
|
+
queryClient.setQueryData(key, next);
|
|
81
|
+
current.options.onEvent?.(event, { connectionId });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
const wrapsSeed = options.select || options.mergeSeed;
|
|
85
|
+
const queryFn = raw.queryFn && wrapsSeed
|
|
86
|
+
? seedQueryFn(raw.queryFn, {
|
|
87
|
+
input,
|
|
88
|
+
readCache: () => queryClient.getQueryData(raw.queryKey),
|
|
89
|
+
select: options.select,
|
|
90
|
+
onSeed: options.onSeed,
|
|
91
|
+
mergeSeed: options.mergeSeed
|
|
92
|
+
})
|
|
93
|
+
: raw.queryFn;
|
|
94
|
+
const runs = options.enabled !== false && focused;
|
|
95
|
+
const query = useQuery({
|
|
96
|
+
...raw,
|
|
97
|
+
...(queryFn ? { queryFn } : {}),
|
|
98
|
+
enabled: runs ? (raw.enabled ?? true) : false,
|
|
99
|
+
...(options.keepPreviousData === false
|
|
100
|
+
? {}
|
|
101
|
+
: { placeholderData: keepPreviousData }),
|
|
102
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
103
|
+
meta: { ...raw.meta, live: true }
|
|
104
|
+
});
|
|
105
|
+
return Object.assign(query, { liveStatus: status });
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* One app-wide stream of `{ domain }` change events that keeps list and count
|
|
109
|
+
* queries fresh: each event invalidates its domain's query keys (debounced),
|
|
110
|
+
* and a reconnect that proves healthy invalidates every domain to cover
|
|
111
|
+
* events lost while it was down. Mount once, in the signed-in shell.
|
|
112
|
+
*
|
|
113
|
+
* ```ts
|
|
114
|
+
* useInvalidationStream({}, client.realtime.subscribeChanges, {
|
|
115
|
+
* enabled: signedIn,
|
|
116
|
+
* domains: { order: { debounceMs: 100, queryKeys: [orpc.order.key()] } }
|
|
117
|
+
* })
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
export function useInvalidationStream(input, subscribe, options) {
|
|
121
|
+
const queryClient = useQueryClient();
|
|
122
|
+
const latestOptions = useRef(options);
|
|
123
|
+
latestOptions.current = options;
|
|
124
|
+
// `domains`, `refetchType` and `logger` are read on mount; `onEvent` stays current.
|
|
125
|
+
const [coordinator] = useState(() => createInvalidationCoordinator(queryClient, {
|
|
126
|
+
...options,
|
|
127
|
+
onEvent: (event) => latestOptions.current.onEvent?.(event)
|
|
128
|
+
}));
|
|
129
|
+
useEffect(() => coordinator.dispose, [coordinator]);
|
|
130
|
+
return useOrpcSubscription(input, subscribe, {
|
|
131
|
+
enabled: options.enabled,
|
|
132
|
+
pauseWhenUnfocused: options.pauseWhenUnfocused,
|
|
133
|
+
onEvent: coordinator.event,
|
|
134
|
+
onConnect: coordinator.connect,
|
|
135
|
+
onError: coordinator.lost,
|
|
136
|
+
onClose: coordinator.lost
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Clear the query cache when the signed-in owner of the cached data changes
|
|
141
|
+
* (another user, another organization), so the next one never sees it.
|
|
142
|
+
* `null` means signed out: signing in starts from the cache sign-out emptied.
|
|
143
|
+
* Pass the same owner as the persister's `buster` so a saved cache from
|
|
144
|
+
* someone else is discarded on the next launch too.
|
|
145
|
+
*/
|
|
146
|
+
export function useQueryCacheOwner(owner) {
|
|
147
|
+
const queryClient = useQueryClient();
|
|
148
|
+
const previous = useRef(owner);
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const before = previous.current;
|
|
151
|
+
previous.current = owner;
|
|
152
|
+
if (owner !== before && before !== null)
|
|
153
|
+
queryClient.clear();
|
|
154
|
+
}, [owner, queryClient]);
|
|
155
|
+
}
|
|
156
|
+
//# sourceMappingURL=react.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"react.js","sourceRoot":"","sources":["../src/react.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n\tfocusManager,\n\thashKey,\n\tkeepPreviousData,\n\ttype QueryKey,\n\ttype UseQueryOptions,\n\ttype UseQueryResult,\n\tuseQuery,\n\tuseQueryClient\n} from '@tanstack/react-query'\nimport { useEffect, useRef, useState, useSyncExternalStore } from 'react'\nimport {\n\tcreateInvalidationCoordinator,\n\tcreateStreamScope,\n\tcreateSubscriptionRunner,\n\ttype InvalidationStreamOptions,\n\ttype LiveConnectionStatus,\n\ttype SubscriptionRunner,\n\tseedQueryFn\n} from './realtime.js'\n\nexport type { InvalidationDomain, LiveConnectionStatus } from './realtime.js'\n\n/** An oRPC client procedure fits: `client.order.watch`. */\nexport type SubscribeFn<TInput, TEvent> = (\n\tinput: TInput,\n\toptions: { signal: AbortSignal }\n) => Promise<AsyncIterable<TEvent>>\n\nexport type UseOrpcSubscriptionOptions<TInput, TEvent> = {\n\t/** Run the stream (default: true). */\n\tenabled?: boolean | undefined\n\tonEvent?: (\n\t\tevent: TEvent,\n\t\tmeta: { connectionId: number; input: TInput }\n\t) => void\n\t/** Every successful connect; `connectionId > 1` is a reconnect. */\n\tonConnect?: (connectionId: number) => void\n\tonError?: ((error: Error) => void) | undefined\n\tonClose?: () => void\n\t/**\n\t * Close the stream while the app is not focused (default: true). Focus is\n\t * TanStack's `focusManager`: on React Native, wire it to `AppState` once.\n\t */\n\tpauseWhenUnfocused?: boolean | undefined\n}\n\nconst subscribeFocus = (listener: () => void) =>\n\tfocusManager.subscribe(listener)\nconst getFocus = () => focusManager.isFocused()\n\n/** Whether the app is focused, from TanStack's `focusManager`. */\nfunction useIsFocused(): boolean {\n\treturn useSyncExternalStore(subscribeFocus, getFocus, getFocus)\n}\n\n/**\n * An oRPC event iterator with backoff reconnects that closes while the app is\n * backgrounded. A changed input (compared as JSON) reconnects with a fresh\n * retry budget.\n */\nexport function useOrpcSubscription<TInput, TEvent>(\n\tinput: TInput,\n\tsubscribe: SubscribeFn<TInput, TEvent>,\n\toptions: UseOrpcSubscriptionOptions<TInput, TEvent> = {}\n): { status: LiveConnectionStatus } {\n\tconst { enabled = true, pauseWhenUnfocused = true } = options\n\tconst focused = useIsFocused()\n\tconst shouldRun = enabled && (!pauseWhenUnfocused || focused)\n\tconst [status, setStatus] = useState<LiveConnectionStatus>('idle')\n\n\tconst latest = useRef({ input, subscribe, options, shouldRun })\n\tlatest.current = { input, subscribe, options, shouldRun }\n\n\tconst runnerRef = useRef<SubscriptionRunner | null>(null)\n\tif (!runnerRef.current) {\n\t\tlet openedInput = input\n\t\trunnerRef.current = createSubscriptionRunner<TEvent>({\n\t\t\topen: (signal) => {\n\t\t\t\topenedInput = latest.current.input\n\t\t\t\treturn latest.current.subscribe(openedInput, { signal })\n\t\t\t},\n\t\t\tcanConnect: () => latest.current.shouldRun,\n\t\t\tshouldReconnect: () => latest.current.shouldRun,\n\t\t\tonStatus: setStatus,\n\t\t\tonEvent: (event, connectionId) =>\n\t\t\t\tlatest.current.options.onEvent?.(event, {\n\t\t\t\t\tconnectionId,\n\t\t\t\t\tinput: openedInput\n\t\t\t\t}),\n\t\t\tonConnect: (id) => latest.current.options.onConnect?.(id),\n\t\t\tonError: (error) => latest.current.options.onError?.(error),\n\t\t\tonClose: () => latest.current.options.onClose?.()\n\t\t})\n\t}\n\tconst runner = runnerRef.current\n\tconst serializedInput = JSON.stringify(input)\n\n\t// biome-ignore lint/correctness/useExhaustiveDependencies: a changed input resubscribes\n\tuseEffect(() => {\n\t\tif (!shouldRun) return\n\t\tvoid runner.start()\n\t\treturn () => void runner.disconnect()\n\t}, [runner, shouldRun, serializedInput])\n\n\treturn { status }\n}\n\ntype ApplyEvent<TStreamEvent, TData> = (\n\tevent: TStreamEvent,\n\tprevious: TData | undefined\n) => TData | undefined\n\ntype LiveQueryOptions<\n\tTInput,\n\tTRouteData,\n\tTStreamEvent,\n\tTData,\n\tTSubscriptionInput\n> = {\n\t/** Run the query and its stream (default: true). */\n\tenabled?: boolean | undefined\n\t/** Input for the stream when it differs from the query's. */\n\tsubscriptionInput?: TSubscriptionInput | undefined\n\t/**\n\t * Convert the route output once at fetch time into a differently typed\n\t * cache; `applyEvent` then reduces stream events into that cache.\n\t */\n\tselect?: (data: TRouteData) => TData\n\t/** The raw route output of a seed, before `select` drops fields. */\n\tonSeed?: (data: TRouteData, input: TInput) => void\n\t/**\n\t * Fold a stream event into the cached data. Required when the stream sends\n\t * something other than the cached type (an `fnLive` patch route, or a\n\t * `select`ed cache). Return `undefined` to skip the write.\n\t */\n\tapplyEvent?: ApplyEvent<TStreamEvent, NoInfer<TData>>\n\tonEvent?: (event: TStreamEvent, meta: { connectionId: number }) => void\n\tonError?: ((error: Error) => void) | undefined\n\t/**\n\t * Reconcile a fetched seed with what the stream already wrote. The HTTP\n\t * request can resolve last, so a plain overwrite would drop events that\n\t * arrived while it was in flight. Default: take the fetched value.\n\t */\n\tmergeSeed?: (\n\t\tfetched: NoInfer<TData>,\n\t\tcached: NoInfer<TData> | undefined\n\t) => NoInfer<TData>\n\t/** Show the previous key's data while a new key loads (default: true). */\n\tkeepPreviousData?: boolean\n\t/** Close the stream while the app is not focused (default: true). */\n\tpauseWhenUnfocused?: boolean | undefined\n}\n\ntype LiveQueryOptionsFn<TInput, TData, TError> = (\n\tinput: TInput\n) => UseQueryOptions<TData, TError, TData, QueryKey>\n\ntype LiveQueryResult<TData, TError> = UseQueryResult<TData, TError> & {\n\tliveStatus: LiveConnectionStatus\n}\n\n/**\n * The client half of `fnLive`: the HTTP query seeds the cache, and the live\n * route's stream writes each snapshot into the same cache entry. Marked\n * `meta.live`, so invalidation streams leave it alone.\n *\n * ```tsx\n * const order = useLiveQuery({ id }, (input) => orpc.order.get.queryOptions({ input }), client.order.watch)\n * ```\n *\n * One signature, not overloads: TypeScript fixes an untyped callback's\n * parameters at the first overload it tries, which made valid calls fail.\n */\nexport function useLiveQuery<\n\tTInput,\n\tTRouteData,\n\tTStreamEvent,\n\tTData = TRouteData,\n\tTError = Error,\n\tTSubscriptionInput = TInput\n>(\n\tinput: TInput,\n\tqueryOptionsFn: LiveQueryOptionsFn<TInput, TRouteData, TError>,\n\tsubscribe: SubscribeFn<NoInfer<TSubscriptionInput>, TStreamEvent>,\n\t...options: [TStreamEvent] extends [TData]\n\t\t? [\n\t\t\t\toptions?: LiveQueryOptions<\n\t\t\t\t\tTInput,\n\t\t\t\t\tTRouteData,\n\t\t\t\t\tTStreamEvent,\n\t\t\t\t\tTData,\n\t\t\t\t\tTSubscriptionInput\n\t\t\t\t>\n\t\t\t]\n\t\t: [\n\t\t\t\toptions: LiveQueryOptions<\n\t\t\t\t\tTInput,\n\t\t\t\t\tTRouteData,\n\t\t\t\t\tTStreamEvent,\n\t\t\t\t\tTData,\n\t\t\t\t\tTSubscriptionInput\n\t\t\t\t> & { applyEvent: ApplyEvent<TStreamEvent, NoInfer<TData>> }\n\t\t\t]\n): LiveQueryResult<TData, TError>\nexport function useLiveQuery<\n\tTInput,\n\tTRouteData,\n\tTStreamEvent,\n\tTData,\n\tTError,\n\tTSubscriptionInput\n>(\n\tinput: TInput,\n\tqueryOptionsFn: LiveQueryOptionsFn<TInput, TRouteData, TError>,\n\tsubscribe: SubscribeFn<TSubscriptionInput, TStreamEvent>,\n\toptions: LiveQueryOptions<\n\t\tTInput,\n\t\tTRouteData,\n\t\tTStreamEvent,\n\t\tTData,\n\t\tTSubscriptionInput\n\t> = {}\n): LiveQueryResult<TData, TError> {\n\tconst queryClient = useQueryClient()\n\tconst focused = useIsFocused()\n\tconst [scope] = useState(createStreamScope)\n\n\t// oRPC query options are plain objects; `queryFn` is read through one shape.\n\tconst raw = queryOptionsFn(input) as unknown as {\n\t\tqueryKey: QueryKey\n\t\tqueryFn?: (context: unknown) => Promise<TRouteData>\n\t\tenabled?: UseQueryOptions['enabled']\n\t\tmeta?: Record<string, unknown>\n\t} & Record<string, unknown>\n\tconst keyHash = hashKey(raw.queryKey)\n\tconst latest = useRef({ raw, keyHash, options })\n\tlatest.current = { raw, keyHash, options }\n\n\tconst { status } = useOrpcSubscription(\n\t\t(options.subscriptionInput === undefined\n\t\t\t? input\n\t\t\t: options.subscriptionInput) as TSubscriptionInput,\n\t\tsubscribe,\n\t\t{\n\t\t\tenabled: options.enabled,\n\t\t\tpauseWhenUnfocused: options.pauseWhenUnfocused,\n\t\t\tonError: options.onError,\n\t\t\tonConnect: (connectionId) =>\n\t\t\t\tscope.open(connectionId, latest.current.keyHash),\n\t\t\tonEvent: (event, { connectionId }) => {\n\t\t\t\tconst current = latest.current\n\t\t\t\t// The consumer callback must not see an event for a scope no longer shown.\n\t\t\t\tif (!scope.owns(connectionId, current.keyHash)) return\n\t\t\t\t// Without `applyEvent` the stream event is the cache type (the first overload).\n\t\t\t\tconst applyEvent =\n\t\t\t\t\tcurrent.options.applyEvent ??\n\t\t\t\t\t((streamEvent: TStreamEvent) => streamEvent as unknown as TData)\n\t\t\t\tconst key = current.raw.queryKey\n\t\t\t\tconst next = applyEvent(event, queryClient.getQueryData<TData>(key))\n\t\t\t\tif (next !== undefined) queryClient.setQueryData(key, next)\n\t\t\t\tcurrent.options.onEvent?.(event, { connectionId })\n\t\t\t}\n\t\t}\n\t)\n\n\tconst wrapsSeed = options.select || options.mergeSeed\n\tconst queryFn =\n\t\traw.queryFn && wrapsSeed\n\t\t\t? seedQueryFn(raw.queryFn, {\n\t\t\t\t\tinput,\n\t\t\t\t\treadCache: () => queryClient.getQueryData<TData>(raw.queryKey),\n\t\t\t\t\tselect: options.select,\n\t\t\t\t\tonSeed: options.onSeed,\n\t\t\t\t\tmergeSeed: options.mergeSeed\n\t\t\t\t})\n\t\t\t: raw.queryFn\n\tconst runs = options.enabled !== false && focused\n\tconst query = useQuery({\n\t\t...raw,\n\t\t...(queryFn ? { queryFn } : {}),\n\t\tenabled: runs ? (raw.enabled ?? true) : false,\n\t\t...(options.keepPreviousData === false\n\t\t\t? {}\n\t\t\t: { placeholderData: keepPreviousData }),\n\t\tstaleTime: Number.POSITIVE_INFINITY,\n\t\tmeta: { ...raw.meta, live: true }\n\t} as UseQueryOptions<TData, TError, TData, QueryKey>)\n\n\treturn Object.assign(query, { liveStatus: status })\n}\n\n/**\n * One app-wide stream of `{ domain }` change events that keeps list and count\n * queries fresh: each event invalidates its domain's query keys (debounced),\n * and a reconnect that proves healthy invalidates every domain to cover\n * events lost while it was down. Mount once, in the signed-in shell.\n *\n * ```ts\n * useInvalidationStream({}, client.realtime.subscribeChanges, {\n * enabled: signedIn,\n * domains: { order: { debounceMs: 100, queryKeys: [orpc.order.key()] } }\n * })\n * ```\n */\nexport function useInvalidationStream<\n\tTInput,\n\tTEvent extends { domain: string }\n>(\n\tinput: TInput,\n\tsubscribe: SubscribeFn<TInput, TEvent>,\n\toptions: InvalidationStreamOptions<TEvent> &\n\t\tPick<\n\t\t\tUseOrpcSubscriptionOptions<TInput, TEvent>,\n\t\t\t'enabled' | 'pauseWhenUnfocused'\n\t\t>\n): { status: LiveConnectionStatus } {\n\tconst queryClient = useQueryClient()\n\tconst latestOptions = useRef(options)\n\tlatestOptions.current = options\n\t// `domains`, `refetchType` and `logger` are read on mount; `onEvent` stays current.\n\tconst [coordinator] = useState(() =>\n\t\tcreateInvalidationCoordinator<TEvent>(queryClient, {\n\t\t\t...options,\n\t\t\tonEvent: (event) => latestOptions.current.onEvent?.(event)\n\t\t})\n\t)\n\tuseEffect(() => coordinator.dispose, [coordinator])\n\n\treturn useOrpcSubscription<TInput, TEvent>(input, subscribe, {\n\t\tenabled: options.enabled,\n\t\tpauseWhenUnfocused: options.pauseWhenUnfocused,\n\t\tonEvent: coordinator.event,\n\t\tonConnect: coordinator.connect,\n\t\tonError: coordinator.lost,\n\t\tonClose: coordinator.lost\n\t})\n}\n\n/**\n * Clear the query cache when the signed-in owner of the cached data changes\n * (another user, another organization), so the next one never sees it.\n * `null` means signed out: signing in starts from the cache sign-out emptied.\n * Pass the same owner as the persister's `buster` so a saved cache from\n * someone else is discarded on the next launch too.\n */\nexport function useQueryCacheOwner(owner: string | null): void {\n\tconst queryClient = useQueryClient()\n\tconst previous = useRef(owner)\n\tuseEffect(() => {\n\t\tconst before = previous.current\n\t\tprevious.current = owner\n\t\tif (owner !== before && before !== null) queryClient.clear()\n\t}, [owner, queryClient])\n}\n"]}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { FnLogger } from './logger.js';
|
|
2
|
+
export type LiveConnectionStatus = 'idle' | 'connecting' | 'connected' | 'error' | 'closed';
|
|
3
|
+
/**
|
|
4
|
+
* Backoff for attempt N (1-based): exponential from 1s capped at 15s, plus
|
|
5
|
+
* 0-25% jitter so a fleet of clients doesn't thundering-herd a recovering
|
|
6
|
+
* server. The delay is capped, never the attempts: a dead network keeps
|
|
7
|
+
* retrying at the ceiling instead of killing the stream for good.
|
|
8
|
+
*/
|
|
9
|
+
export declare function nextReconnectDelayMs(errorCount: number, random?: () => number): number;
|
|
10
|
+
type SubscriptionRunnerOptions<TEvent> = {
|
|
11
|
+
open: (signal: AbortSignal) => Promise<AsyncIterable<TEvent>>;
|
|
12
|
+
onEvent: (event: TEvent, connectionId: number) => void;
|
|
13
|
+
onStatus: (status: LiveConnectionStatus) => void;
|
|
14
|
+
onConnect?: ((connectionId: number) => void) | undefined;
|
|
15
|
+
onError?: ((error: Error) => void) | undefined;
|
|
16
|
+
onClose?: (() => void) | undefined;
|
|
17
|
+
/** May a stream run at all right now (e.g. the app is in the foreground)? */
|
|
18
|
+
canConnect: () => boolean;
|
|
19
|
+
/** Reconnect with backoff after an unexpected end? Checked when scheduling and firing. */
|
|
20
|
+
shouldReconnect: () => boolean;
|
|
21
|
+
};
|
|
22
|
+
export type SubscriptionRunner = ReturnType<typeof createSubscriptionRunner>;
|
|
23
|
+
/**
|
|
24
|
+
* One oRPC event-iterator stream with generation-guarded connects, abortable
|
|
25
|
+
* teardown and backoff reconnects. Every connect claims a new connection id;
|
|
26
|
+
* a superseded attempt closes its own iterator and never reports events.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createSubscriptionRunner<TEvent>(options: SubscriptionRunnerOptions<TEvent>): {
|
|
29
|
+
connect: () => Promise<void>;
|
|
30
|
+
disconnect: () => Promise<void>;
|
|
31
|
+
/** Connect with a fresh retry budget (input change, foreground, re-enable). */
|
|
32
|
+
start(): Promise<void>;
|
|
33
|
+
/** Connected, connecting, or waiting to reconnect. */
|
|
34
|
+
isLive: () => boolean;
|
|
35
|
+
getStatus: () => LiveConnectionStatus;
|
|
36
|
+
dispose(): Promise<void>;
|
|
37
|
+
};
|
|
38
|
+
type QueryKeyLike = readonly unknown[];
|
|
39
|
+
/** The part of a TanStack `QueryClient` the coordinator uses. */
|
|
40
|
+
type InvalidatingQueryClient = {
|
|
41
|
+
invalidateQueries(filters: {
|
|
42
|
+
queryKey: QueryKeyLike;
|
|
43
|
+
refetchType?: 'active' | 'inactive' | 'all' | 'none';
|
|
44
|
+
predicate?: (query: {
|
|
45
|
+
meta?: Record<string, unknown> | undefined;
|
|
46
|
+
}) => boolean;
|
|
47
|
+
}): Promise<unknown>;
|
|
48
|
+
};
|
|
49
|
+
export type InvalidationDomain = {
|
|
50
|
+
/**
|
|
51
|
+
* Batch a burst of events into one invalidation (ms). The window runs from
|
|
52
|
+
* the first event, so a steady stream cannot postpone the refresh forever.
|
|
53
|
+
*/
|
|
54
|
+
debounceMs?: number;
|
|
55
|
+
queryKeys: readonly QueryKeyLike[];
|
|
56
|
+
};
|
|
57
|
+
export type InvalidationStreamOptions<TEvent extends {
|
|
58
|
+
domain: string;
|
|
59
|
+
}> = {
|
|
60
|
+
/** Query keys to invalidate per event `domain`. Unmapped domains are ignored. */
|
|
61
|
+
domains: {
|
|
62
|
+
[K in TEvent['domain']]?: InvalidationDomain;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Passed to `invalidateQueries`. Default: TanStack's `'active'`, which marks
|
|
66
|
+
* inactive queries stale so they refetch on their next use. `'all'` also
|
|
67
|
+
* refetches every cached query of the domain now, on each event.
|
|
68
|
+
*/
|
|
69
|
+
refetchType?: 'active' | 'inactive' | 'all' | 'none';
|
|
70
|
+
/** Every event, after its domain is invalidated (alerts, chimes). */
|
|
71
|
+
onEvent?: (event: TEvent) => void;
|
|
72
|
+
/** Receives one `invalidation_stream.disconnected` warning per minute at most. */
|
|
73
|
+
logger?: Pick<FnLogger, 'warn'>;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Turns a stream of `{ domain }` change events into debounced
|
|
77
|
+
* `invalidateQueries` calls. Live queries (`meta.live`) are skipped: their own
|
|
78
|
+
* stream keeps them current. Events published while the stream was down are
|
|
79
|
+
* lost, so a reconnect that proves healthy invalidates every mapped domain.
|
|
80
|
+
*/
|
|
81
|
+
export declare function createInvalidationCoordinator<TEvent extends {
|
|
82
|
+
domain: string;
|
|
83
|
+
}>(queryClient: InvalidatingQueryClient, options: InvalidationStreamOptions<TEvent>): {
|
|
84
|
+
event(event: TEvent): void;
|
|
85
|
+
connect(connectionId: number): void;
|
|
86
|
+
lost(): void;
|
|
87
|
+
dispose(): void;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Which connection and query key the open live stream belongs to. An event
|
|
91
|
+
* counts only while both still match: the input changes synchronously but the
|
|
92
|
+
* stream is torn down asynchronously, so a frame in flight from the previous
|
|
93
|
+
* subscription would otherwise land in the new key (and, with
|
|
94
|
+
* `staleTime: Infinity`, stay there).
|
|
95
|
+
*/
|
|
96
|
+
export declare function createStreamScope(): {
|
|
97
|
+
open(connectionId: number, keyHash: string): void;
|
|
98
|
+
owns: (connectionId: number, keyHash: string) => boolean;
|
|
99
|
+
};
|
|
100
|
+
/**
|
|
101
|
+
* Wrap a live query's HTTP `queryFn`: report the raw seed, convert it with
|
|
102
|
+
* `select`, and reconcile it with what the stream already wrote. The cache is
|
|
103
|
+
* read after the request resolves, because stream events can arrive while it
|
|
104
|
+
* is in flight.
|
|
105
|
+
*/
|
|
106
|
+
export declare function seedQueryFn<TRouteData, TData, TInput>(queryFn: (context: unknown) => Promise<TRouteData>, options: {
|
|
107
|
+
input: TInput;
|
|
108
|
+
readCache: () => TData | undefined;
|
|
109
|
+
select?: ((data: TRouteData) => TData) | undefined;
|
|
110
|
+
onSeed?: ((data: TRouteData, input: TInput) => void) | undefined;
|
|
111
|
+
mergeSeed?: ((fetched: TData, cached: TData | undefined) => TData) | undefined;
|
|
112
|
+
}): (context: unknown) => Promise<TData>;
|
|
113
|
+
export {};
|
|
114
|
+
//# sourceMappingURL=realtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"realtime.d.ts","sourceRoot":"","sources":["../src/realtime.ts"],"names":[],"mappings":"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"}
|
package/dist/realtime.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
const BASE_RECONNECT_DELAY_MS = 1000;
|
|
2
|
+
const MAX_RECONNECT_DELAY_MS = 15_000;
|
|
3
|
+
/**
|
|
4
|
+
* Backoff for attempt N (1-based): exponential from 1s capped at 15s, plus
|
|
5
|
+
* 0-25% jitter so a fleet of clients doesn't thundering-herd a recovering
|
|
6
|
+
* server. The delay is capped, never the attempts: a dead network keeps
|
|
7
|
+
* retrying at the ceiling instead of killing the stream for good.
|
|
8
|
+
*/
|
|
9
|
+
export function nextReconnectDelayMs(errorCount, random = Math.random) {
|
|
10
|
+
const attempt = Math.max(1, errorCount);
|
|
11
|
+
const base = Math.min(BASE_RECONNECT_DELAY_MS * 2 ** (attempt - 1), MAX_RECONNECT_DELAY_MS);
|
|
12
|
+
return Math.round(base + base * 0.25 * random());
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* One oRPC event-iterator stream with generation-guarded connects, abortable
|
|
16
|
+
* teardown and backoff reconnects. Every connect claims a new connection id;
|
|
17
|
+
* a superseded attempt closes its own iterator and never reports events.
|
|
18
|
+
*/
|
|
19
|
+
export function createSubscriptionRunner(options) {
|
|
20
|
+
let status = 'idle';
|
|
21
|
+
let abortController = null;
|
|
22
|
+
let iterator = null;
|
|
23
|
+
let connectPromise = null;
|
|
24
|
+
let activeConnectionId = 0;
|
|
25
|
+
/** Set by deliberate teardown so stream-end does not schedule a retry. */
|
|
26
|
+
let intentionalDisconnect = false;
|
|
27
|
+
let errorCount = 0;
|
|
28
|
+
let reconnectTimer = null;
|
|
29
|
+
let disposed = false;
|
|
30
|
+
const setStatus = (next) => {
|
|
31
|
+
status = next;
|
|
32
|
+
options.onStatus(next);
|
|
33
|
+
};
|
|
34
|
+
function clearReconnectTimer() {
|
|
35
|
+
if (reconnectTimer === null)
|
|
36
|
+
return;
|
|
37
|
+
clearTimeout(reconnectTimer);
|
|
38
|
+
reconnectTimer = null;
|
|
39
|
+
}
|
|
40
|
+
function scheduleReconnect() {
|
|
41
|
+
if (intentionalDisconnect || disposed || reconnectTimer !== null)
|
|
42
|
+
return;
|
|
43
|
+
if (!options.shouldReconnect())
|
|
44
|
+
return;
|
|
45
|
+
reconnectTimer = setTimeout(() => {
|
|
46
|
+
reconnectTimer = null;
|
|
47
|
+
if (disposed || intentionalDisconnect || !options.shouldReconnect())
|
|
48
|
+
return;
|
|
49
|
+
void connect();
|
|
50
|
+
}, nextReconnectDelayMs(errorCount));
|
|
51
|
+
}
|
|
52
|
+
function handleUnexpectedEnd(error) {
|
|
53
|
+
errorCount += 1;
|
|
54
|
+
// Close the socket of a failed stream; on React Native an unaborted
|
|
55
|
+
// expo/fetch request keeps holding a connection from the device's pool.
|
|
56
|
+
abortController?.abort();
|
|
57
|
+
abortController = null;
|
|
58
|
+
iterator = null;
|
|
59
|
+
if (error) {
|
|
60
|
+
setStatus('error');
|
|
61
|
+
options.onError?.(error);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
setStatus('closed');
|
|
65
|
+
options.onClose?.();
|
|
66
|
+
}
|
|
67
|
+
scheduleReconnect();
|
|
68
|
+
}
|
|
69
|
+
async function connect() {
|
|
70
|
+
if (disposed || !options.canConnect())
|
|
71
|
+
return;
|
|
72
|
+
if (connectPromise)
|
|
73
|
+
return connectPromise;
|
|
74
|
+
if (status === 'connected' || reconnectTimer !== null)
|
|
75
|
+
return;
|
|
76
|
+
intentionalDisconnect = false;
|
|
77
|
+
const thisConnect = (async () => {
|
|
78
|
+
setStatus('connecting');
|
|
79
|
+
// Claim the generation and controller before awaiting, so a
|
|
80
|
+
// disconnect while opening supersedes this attempt.
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
abortController = controller;
|
|
83
|
+
const connectionId = ++activeConnectionId;
|
|
84
|
+
try {
|
|
85
|
+
const iterable = await options.open(controller.signal);
|
|
86
|
+
const localIterator = iterable[Symbol.asyncIterator]();
|
|
87
|
+
if (connectionId !== activeConnectionId || controller.signal.aborted) {
|
|
88
|
+
await closeIterator(localIterator);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
iterator = localIterator;
|
|
92
|
+
setStatus('connected');
|
|
93
|
+
options.onConnect?.(connectionId);
|
|
94
|
+
void consume(localIterator, connectionId);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (connectionId !== activeConnectionId || controller.signal.aborted)
|
|
98
|
+
return;
|
|
99
|
+
handleUnexpectedEnd(toError(error));
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
connectPromise = thisConnect;
|
|
103
|
+
try {
|
|
104
|
+
await thisConnect;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
if (connectPromise === thisConnect)
|
|
108
|
+
connectPromise = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function consume(localIterator, connectionId) {
|
|
112
|
+
try {
|
|
113
|
+
while (status === 'connected' && connectionId === activeConnectionId) {
|
|
114
|
+
const result = await localIterator.next();
|
|
115
|
+
if (status !== 'connected' || connectionId !== activeConnectionId)
|
|
116
|
+
return;
|
|
117
|
+
if (result.done) {
|
|
118
|
+
// Proxy or idle end after a live connect: recoverable.
|
|
119
|
+
if (!intentionalDisconnect)
|
|
120
|
+
handleUnexpectedEnd(null);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Reset backoff only once the stream delivers. A server that accepts
|
|
124
|
+
// and then dies before yielding must keep escalating.
|
|
125
|
+
errorCount = 0;
|
|
126
|
+
options.onEvent(result.value, connectionId);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (connectionId !== activeConnectionId)
|
|
131
|
+
return;
|
|
132
|
+
if (abortController?.signal.aborted || intentionalDisconnect) {
|
|
133
|
+
setStatus('closed');
|
|
134
|
+
options.onClose?.();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
handleUnexpectedEnd(toError(error));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function disconnect() {
|
|
141
|
+
intentionalDisconnect = true;
|
|
142
|
+
clearReconnectTimer();
|
|
143
|
+
if (status === 'idle')
|
|
144
|
+
return;
|
|
145
|
+
activeConnectionId++;
|
|
146
|
+
abortController?.abort();
|
|
147
|
+
// A fresh connect() may start while the superseded attempt winds down.
|
|
148
|
+
connectPromise = null;
|
|
149
|
+
const localIterator = iterator;
|
|
150
|
+
iterator = null;
|
|
151
|
+
abortController = null;
|
|
152
|
+
if (status !== 'closed') {
|
|
153
|
+
setStatus('closed');
|
|
154
|
+
options.onClose?.();
|
|
155
|
+
}
|
|
156
|
+
await closeIterator(localIterator);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
connect,
|
|
160
|
+
disconnect,
|
|
161
|
+
/** Connect with a fresh retry budget (input change, foreground, re-enable). */
|
|
162
|
+
start() {
|
|
163
|
+
errorCount = 0;
|
|
164
|
+
intentionalDisconnect = false;
|
|
165
|
+
return connect();
|
|
166
|
+
},
|
|
167
|
+
/** Connected, connecting, or waiting to reconnect. */
|
|
168
|
+
isLive: () => status === 'connected' ||
|
|
169
|
+
status === 'connecting' ||
|
|
170
|
+
reconnectTimer !== null,
|
|
171
|
+
getStatus: () => status,
|
|
172
|
+
dispose() {
|
|
173
|
+
disposed = true;
|
|
174
|
+
return disconnect();
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
async function closeIterator(iterator) {
|
|
179
|
+
try {
|
|
180
|
+
await iterator?.return?.(undefined);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Cleanup errors are not the caller's concern.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const toError = (error) => error instanceof Error ? error : new Error(String(error));
|
|
187
|
+
/**
|
|
188
|
+
* How long a reconnected stream must stay up, having delivered nothing, before
|
|
189
|
+
* it counts as working. Waiting for an event alone would leave a quiet
|
|
190
|
+
* reconnect stale until the next change; not waiting at all lets a stream that
|
|
191
|
+
* dies the instant it opens refetch everything once per retry.
|
|
192
|
+
*/
|
|
193
|
+
const SETTLE_MS = 1_500;
|
|
194
|
+
const DISCONNECT_LOG_INTERVAL_MS = 60_000;
|
|
195
|
+
/**
|
|
196
|
+
* Turns a stream of `{ domain }` change events into debounced
|
|
197
|
+
* `invalidateQueries` calls. Live queries (`meta.live`) are skipped: their own
|
|
198
|
+
* stream keeps them current. Events published while the stream was down are
|
|
199
|
+
* lost, so a reconnect that proves healthy invalidates every mapped domain.
|
|
200
|
+
*/
|
|
201
|
+
export function createInvalidationCoordinator(queryClient, options) {
|
|
202
|
+
const timers = new Map();
|
|
203
|
+
let settleTimer = null;
|
|
204
|
+
let catchUpPending = false;
|
|
205
|
+
let lastLostLogAt = Number.NEGATIVE_INFINITY;
|
|
206
|
+
const domainConfig = (domain) => Object.hasOwn(options.domains, domain)
|
|
207
|
+
? options.domains[domain]
|
|
208
|
+
: undefined;
|
|
209
|
+
function invalidate(config) {
|
|
210
|
+
for (const queryKey of config.queryKeys) {
|
|
211
|
+
void queryClient.invalidateQueries({
|
|
212
|
+
queryKey,
|
|
213
|
+
...(options.refetchType ? { refetchType: options.refetchType } : {}),
|
|
214
|
+
predicate: (query) => query.meta?.live !== true
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function invalidateAll() {
|
|
219
|
+
for (const config of Object.values(options.domains)) {
|
|
220
|
+
if (config)
|
|
221
|
+
invalidate(config);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function clearSettleTimer() {
|
|
225
|
+
if (settleTimer === null)
|
|
226
|
+
return;
|
|
227
|
+
clearTimeout(settleTimer);
|
|
228
|
+
settleTimer = null;
|
|
229
|
+
}
|
|
230
|
+
function markHealthy() {
|
|
231
|
+
clearSettleTimer();
|
|
232
|
+
if (!catchUpPending)
|
|
233
|
+
return;
|
|
234
|
+
catchUpPending = false;
|
|
235
|
+
invalidateAll();
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
event(event) {
|
|
239
|
+
// A delivered event proves the connection; no need to wait it out.
|
|
240
|
+
markHealthy();
|
|
241
|
+
// The stream is typed, but a JavaScript caller or a newer server may send anything.
|
|
242
|
+
const domain = event?.domain;
|
|
243
|
+
if (typeof domain !== 'string')
|
|
244
|
+
return;
|
|
245
|
+
const config = domainConfig(domain);
|
|
246
|
+
const delay = config?.debounceMs ?? 0;
|
|
247
|
+
if (config && delay <= 0) {
|
|
248
|
+
invalidate(config);
|
|
249
|
+
}
|
|
250
|
+
else if (config && !timers.has(domain)) {
|
|
251
|
+
const timer = setTimeout(() => {
|
|
252
|
+
timers.delete(domain);
|
|
253
|
+
invalidate(config);
|
|
254
|
+
}, delay);
|
|
255
|
+
timers.set(domain, timer);
|
|
256
|
+
}
|
|
257
|
+
options.onEvent?.(event);
|
|
258
|
+
},
|
|
259
|
+
connect(connectionId) {
|
|
260
|
+
clearSettleTimer();
|
|
261
|
+
catchUpPending ||= connectionId > 1;
|
|
262
|
+
settleTimer = setTimeout(markHealthy, SETTLE_MS);
|
|
263
|
+
},
|
|
264
|
+
lost() {
|
|
265
|
+
// Reconnecting is the subscription's job; this connection can no longer settle.
|
|
266
|
+
clearSettleTimer();
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
if (now - lastLostLogAt < DISCONNECT_LOG_INTERVAL_MS)
|
|
269
|
+
return;
|
|
270
|
+
lastLostLogAt = now;
|
|
271
|
+
options.logger?.warn('invalidation_stream.disconnected');
|
|
272
|
+
},
|
|
273
|
+
dispose() {
|
|
274
|
+
clearSettleTimer();
|
|
275
|
+
for (const timer of timers.values())
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
timers.clear();
|
|
278
|
+
catchUpPending = false;
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Which connection and query key the open live stream belongs to. An event
|
|
284
|
+
* counts only while both still match: the input changes synchronously but the
|
|
285
|
+
* stream is torn down asynchronously, so a frame in flight from the previous
|
|
286
|
+
* subscription would otherwise land in the new key (and, with
|
|
287
|
+
* `staleTime: Infinity`, stay there).
|
|
288
|
+
*/
|
|
289
|
+
export function createStreamScope() {
|
|
290
|
+
let openConnectionId = null;
|
|
291
|
+
let openKeyHash = null;
|
|
292
|
+
return {
|
|
293
|
+
open(connectionId, keyHash) {
|
|
294
|
+
openConnectionId = connectionId;
|
|
295
|
+
openKeyHash = keyHash;
|
|
296
|
+
},
|
|
297
|
+
owns: (connectionId, keyHash) => connectionId === openConnectionId && keyHash === openKeyHash
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Wrap a live query's HTTP `queryFn`: report the raw seed, convert it with
|
|
302
|
+
* `select`, and reconcile it with what the stream already wrote. The cache is
|
|
303
|
+
* read after the request resolves, because stream events can arrive while it
|
|
304
|
+
* is in flight.
|
|
305
|
+
*/
|
|
306
|
+
export function seedQueryFn(queryFn, options) {
|
|
307
|
+
const { select, onSeed, mergeSeed } = options;
|
|
308
|
+
return async (context) => {
|
|
309
|
+
const fetched = await queryFn(context);
|
|
310
|
+
onSeed?.(fetched, options.input);
|
|
311
|
+
// Without `select` the route output is the cache type.
|
|
312
|
+
const data = select ? select(fetched) : fetched;
|
|
313
|
+
return mergeSeed ? mergeSeed(data, options.readCache()) : data;
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
//# sourceMappingURL=realtime.js.map
|