@octanejs/tanstack-db 0.0.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.
@@ -0,0 +1,358 @@
1
+ import { useRef, useSyncExternalStore } from 'octane';
2
+ import {
3
+ CollectionImpl,
4
+ createLiveQueryCollection,
5
+ createLiveQueryWindowController,
6
+ deepEquals,
7
+ } from '@tanstack/db';
8
+ import { splitTrailingSlot, subSlot } from './slot';
9
+ // Type-only: used in `ReturnType<typeof useLiveQuery>` in UseLiveInfiniteQueryReturn.
10
+ import type { useLiveQuery } from './useLiveQuery';
11
+ import type {
12
+ Collection,
13
+ Context,
14
+ InferResultType,
15
+ InitialQueryBuilder,
16
+ LiveQueryWindowController,
17
+ NonSingleResult,
18
+ QueryBuilder,
19
+ } from '@tanstack/db';
20
+
21
+ // Live queries created here are cleaned up immediately (0 disables GC).
22
+ const DEFAULT_GC_TIME_MS = 1;
23
+
24
+ type WindowedCollection = Collection<any, any, any> & {
25
+ utils: {
26
+ setWindow: (options: { offset: number; limit: number }) => true | Promise<void>;
27
+ getWindow: () => { offset: number; limit: number | null } | undefined;
28
+ };
29
+ };
30
+
31
+ /**
32
+ * Does this pre-created collection support windowing (i.e. has an ORDER BY)?
33
+ *
34
+ * In TanStack DB 0.7.0 every live-query collection exposes `setWindow`/`getWindow`
35
+ * on `utils`, so a bare `typeof setWindow === 'function'` check is always true and
36
+ * cannot detect a missing ORDER BY — calling `setWindow` without one throws
37
+ * `SetWindowRequiresOrderByError` later, inside the controller's subscribe (a
38
+ * passive effect Octane swallows), never reaching the caller. `getWindow()`
39
+ * instead returns the current window only for an ordered query and `undefined`
40
+ * otherwise, independent of preload/sync state, so it is the reliable render-time
41
+ * signal that lets the hook reject a non-orderBy collection synchronously.
42
+ */
43
+ function supportsWindowing(
44
+ collection: Collection<any, any, any>,
45
+ ): collection is WindowedCollection {
46
+ const utils = collection.utils as WindowedCollection[`utils`] | undefined;
47
+ return typeof utils?.setWindow === `function` && utils.getWindow?.() !== undefined;
48
+ }
49
+
50
+ export type UseLiveInfiniteQueryConfig<TContext extends Context> = {
51
+ pageSize?: number;
52
+ initialPageParam?: number;
53
+ /**
54
+ * @deprecated This callback is not used by the current implementation.
55
+ * Pagination is determined internally via a peek-ahead strategy.
56
+ * Provided for API compatibility with TanStack Query conventions.
57
+ */
58
+ getNextPageParam?: (
59
+ lastPage: Array<InferResultType<TContext>[number]>,
60
+ allPages: Array<Array<InferResultType<TContext>[number]>>,
61
+ lastPageParam: number,
62
+ allPageParams: Array<number>,
63
+ ) => number | undefined;
64
+ };
65
+
66
+ export type UseLiveInfiniteQueryReturn<TContext extends Context> = Omit<
67
+ ReturnType<typeof useLiveQuery<TContext>>,
68
+ `data`
69
+ > & {
70
+ data: InferResultType<TContext>;
71
+ pages: Array<Array<InferResultType<TContext>[number]>>;
72
+ pageParams: Array<number>;
73
+ fetchNextPage: () => void;
74
+ hasNextPage: boolean;
75
+ isFetchingNextPage: boolean;
76
+ /** The last pagination failure, cleared when a retry begins. */
77
+ error: unknown;
78
+ };
79
+
80
+ /**
81
+ * Create an infinite query using a query function with live updates
82
+ *
83
+ * Uses `utils.setWindow()` to dynamically adjust the limit/offset window
84
+ * without recreating the live query collection on each page change.
85
+ *
86
+ * @param queryFn - Query function that defines what data to fetch. Must include `.orderBy()` for setWindow to work.
87
+ * @param config - Configuration including pageSize and getNextPageParam
88
+ * @param deps - Array of dependencies that trigger query re-execution when changed
89
+ * @returns Object with pages, data, and pagination controls
90
+ *
91
+ * @example
92
+ * // Basic infinite query
93
+ * const { data, pages, fetchNextPage, hasNextPage } = useLiveInfiniteQuery(
94
+ * (q) => q
95
+ * .from({ posts: postsCollection })
96
+ * .orderBy(({ posts }) => posts.createdAt, 'desc')
97
+ * .select(({ posts }) => ({
98
+ * id: posts.id,
99
+ * title: posts.title
100
+ * })),
101
+ * {
102
+ * pageSize: 20,
103
+ * getNextPageParam: (lastPage, allPages) =>
104
+ * lastPage.length === 20 ? allPages.length : undefined
105
+ * }
106
+ * )
107
+ *
108
+ * @example
109
+ * // With dependencies
110
+ * const { pages, fetchNextPage } = useLiveInfiniteQuery(
111
+ * (q) => q
112
+ * .from({ posts: postsCollection })
113
+ * .where(({ posts }) => eq(posts.category, category))
114
+ * .orderBy(({ posts }) => posts.createdAt, 'desc'),
115
+ * {
116
+ * pageSize: 10,
117
+ * getNextPageParam: (lastPage) =>
118
+ * lastPage.length === 10 ? lastPage.length : undefined
119
+ * },
120
+ * [category]
121
+ * )
122
+ *
123
+ * @example
124
+ * // Router loader pattern with pre-created collection
125
+ * // In loader:
126
+ * const postsQuery = createLiveQueryCollection({
127
+ * query: (q) => q
128
+ * .from({ posts: postsCollection })
129
+ * .orderBy(({ posts }) => posts.createdAt, 'desc')
130
+ * .limit(20)
131
+ * })
132
+ * await postsQuery.preload()
133
+ * return { postsQuery }
134
+ *
135
+ * // In component:
136
+ * const { postsQuery } = useLoaderData()
137
+ * const { data, fetchNextPage, hasNextPage } = useLiveInfiniteQuery(
138
+ * postsQuery,
139
+ * {
140
+ * pageSize: 20,
141
+ * getNextPageParam: (lastPage) => lastPage.length === 20 ? lastPage.length : undefined
142
+ * }
143
+ * )
144
+ */
145
+
146
+ // Overload for pre-created collection (non-single result)
147
+ export function useLiveInfiniteQuery<
148
+ TResult extends object,
149
+ TKey extends string | number,
150
+ TUtils extends Record<string, any>,
151
+ >(
152
+ liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult,
153
+ config: UseLiveInfiniteQueryConfig<any>,
154
+ ): UseLiveInfiniteQueryReturn<any>;
155
+
156
+ // Overload for query function
157
+ export function useLiveInfiniteQuery<TContext extends Context>(
158
+ queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
159
+ config: UseLiveInfiniteQueryConfig<TContext>,
160
+ deps?: Array<unknown>,
161
+ ): UseLiveInfiniteQueryReturn<TContext>;
162
+
163
+ // Implementation
164
+ export function useLiveInfiniteQuery<TContext extends Context>(
165
+ queryFnOrCollection: any,
166
+ config: UseLiveInfiniteQueryConfig<TContext>,
167
+ ...rest: Array<unknown>
168
+ ): UseLiveInfiniteQueryReturn<TContext> {
169
+ const [args, slot] = splitTrailingSlot(rest);
170
+ const deps = (args[0] as Array<unknown> | undefined) ?? [];
171
+
172
+ const pageSize = config.pageSize ?? 20;
173
+ if (pageSize <= 0) {
174
+ throw new Error(
175
+ `useLiveInfiniteQuery: pageSize must be a positive integer. Received: ${pageSize}`,
176
+ );
177
+ }
178
+ const initialPageParam = config.initialPageParam ?? 0;
179
+
180
+ // Detect if input is a collection or query function
181
+ const isCollection = queryFnOrCollection instanceof CollectionImpl;
182
+
183
+ // Validate input type
184
+ if (!isCollection && typeof queryFnOrCollection !== `function`) {
185
+ throw new Error(
186
+ `useLiveInfiniteQuery: First argument must be either a pre-created live query collection (CollectionImpl) ` +
187
+ `or a query function. Received: ${typeof queryFnOrCollection}`,
188
+ );
189
+ }
190
+
191
+ // The shared window controller (TanStack DB #1675) owns the physical window,
192
+ // committed pages, pagination error, and the fetch/reset lifecycle. It
193
+ // coordinates a per-consumer window lease over the underlying collection, so
194
+ // two hooks pointed at one pre-created collection no longer truncate each
195
+ // other's window and an unmount restores the surviving consumer's window.
196
+ // It also rolls back a failed page load and exposes it on `snapshot.error`
197
+ // with a clean retry, replacing the old fire-and-forget `setWindow` that
198
+ // merely logged and permanently consumed the page.
199
+ const collectionRef = useRef<Collection<any, any, any> | null>(null, subSlot(slot, `coll-ref`));
200
+ const controllerRef = useRef<LiveQueryWindowController<any, any> | null>(
201
+ null,
202
+ subSlot(slot, `ctrl-ref`),
203
+ );
204
+ const configRef = useRef<unknown>(null, subSlot(slot, `cfg-ref`));
205
+ const depsRef = useRef<Array<unknown> | null>(null, subSlot(slot, `deps-ref`));
206
+ const pageSizeRef = useRef(pageSize, subSlot(slot, `page-size-ref`));
207
+ const initialPageParamRef = useRef(initialPageParam, subSlot(slot, `page-param-ref`));
208
+ const validatedCollectionRef = useRef<unknown>(null, subSlot(slot, `validated-ref`));
209
+ const inputKind = isCollection ? `collection` : `query`;
210
+ const inputKindRef = useRef<`collection` | `query` | null>(null, subSlot(slot, `kind-ref`));
211
+ const previousInputKind = inputKindRef.current;
212
+
213
+ const dependenciesChanged =
214
+ !isCollection &&
215
+ (depsRef.current === null ||
216
+ depsRef.current.length !== deps.length ||
217
+ depsRef.current.some((dep, index) => dep !== deps[index]));
218
+ const dependenciesStructurallyEqual =
219
+ !isCollection && depsRef.current !== null && deepEquals(depsRef.current, deps);
220
+ const needsNewCollection =
221
+ !collectionRef.current ||
222
+ inputKindRef.current !== inputKind ||
223
+ (isCollection && configRef.current !== queryFnOrCollection) ||
224
+ dependenciesChanged;
225
+ const pageShapeChanged =
226
+ pageSizeRef.current !== pageSize || initialPageParamRef.current !== initialPageParam;
227
+ const needsNewController = !controllerRef.current || needsNewCollection || pageShapeChanged;
228
+
229
+ if (needsNewCollection) {
230
+ inputKindRef.current = inputKind;
231
+ if (isCollection) {
232
+ const collection = queryFnOrCollection as Collection<any, any, any>;
233
+ if (!supportsWindowing(collection)) {
234
+ // Surfaced synchronously during render (not from a passive effect), so
235
+ // a caller — and a test's expect().toThrow — observes it directly.
236
+ throw new Error(
237
+ `useLiveInfiniteQuery: Pre-created live query collection must have an orderBy clause for infinite pagination to work (setWindow() is unavailable without one). ` +
238
+ `Please add .orderBy() to your createLiveQueryCollection query.`,
239
+ );
240
+ }
241
+ // Warn once per collection instance if its current window doesn't match
242
+ // the first page the hook is about to enforce.
243
+ if (validatedCollectionRef.current !== collection) {
244
+ validatedCollectionRef.current = collection;
245
+ const currentWindow = collection.utils.getWindow?.();
246
+ if (currentWindow && (currentWindow.offset !== 0 || currentWindow.limit !== pageSize + 1)) {
247
+ console.warn(
248
+ `useLiveInfiniteQuery: Pre-created collection has window {offset: ${currentWindow.offset}, limit: ${currentWindow.limit}} ` +
249
+ `but the hook expects {offset: 0, limit: ${pageSize + 1}}. Adjusting window now.`,
250
+ );
251
+ }
252
+ }
253
+ collectionRef.current = collection;
254
+ configRef.current = queryFnOrCollection;
255
+ } else {
256
+ // Wrap the query with the first page's peek-ahead window; the controller
257
+ // grows the limit from here. Construction happens during render, so keep
258
+ // synchronization idle until the committed controller subscription first
259
+ // acquires the matching window lease.
260
+ collectionRef.current = createLiveQueryCollection({
261
+ query: (q: InitialQueryBuilder) =>
262
+ queryFnOrCollection(q)
263
+ .limit(pageSize + 1)
264
+ .offset(0),
265
+ startSync: false,
266
+ gcTime: DEFAULT_GC_TIME_MS,
267
+ });
268
+ depsRef.current = [...deps];
269
+ }
270
+ }
271
+
272
+ if (needsNewController) {
273
+ const previousController = controllerRef.current;
274
+ // Preserve the committed page count across a controller swap when the
275
+ // underlying data window is unchanged (same collection, or same query with
276
+ // structurally-equal deps). A genuine deps change resets to the first page.
277
+ const canPreservePageCount =
278
+ previousController !== null &&
279
+ (!needsNewCollection || (previousInputKind === `query` && dependenciesStructurallyEqual));
280
+ const initialPageCount = canPreservePageCount
281
+ ? Math.max(1, previousController.getSnapshot().pages.length)
282
+ : 1;
283
+ pageSizeRef.current = pageSize;
284
+ initialPageParamRef.current = initialPageParam;
285
+ controllerRef.current = createLiveQueryWindowController(collectionRef.current, {
286
+ pageSize,
287
+ initialPageParam,
288
+ initialPageCount,
289
+ });
290
+ }
291
+ const controller = controllerRef.current!;
292
+
293
+ // Stable subscribe / getSnapshot / fetchNextPage bound to the current
294
+ // controller; recreated only when the controller is swapped.
295
+ const subscribeRef = useRef<((onStoreChange: () => void) => () => void) | null>(
296
+ null,
297
+ subSlot(slot, `sub-ref`),
298
+ );
299
+ const getSnapshotRef = useRef<(() => ReturnType<typeof controller.getSnapshot>) | null>(
300
+ null,
301
+ subSlot(slot, `gs-ref`),
302
+ );
303
+ const fetchNextPageRef = useRef<(() => void) | null>(null, subSlot(slot, `fetch-ref`));
304
+ if (needsNewController || !subscribeRef.current) {
305
+ subscribeRef.current = (onStoreChange: () => void) => {
306
+ let unsubscribed = false;
307
+ const unsub = controller.subscribe(() => {
308
+ if (!unsubscribed) onStoreChange();
309
+ });
310
+ // The controller starts sync and, for a pre-created collection whose
311
+ // window differs from the hook's page shape, grows the window via
312
+ // setWindow synchronously during subscribe. That growth publishes no
313
+ // change the observer can forward, and Octane's useSyncExternalStore
314
+ // tear-check already ran before this passive subscribe effect. Nudge
315
+ // Octane to re-read the freshly-grown snapshot on the next microtask.
316
+ // See useLiveQuery's matching note and the eager-onstorechange test.
317
+ queueMicrotask(() => {
318
+ if (!unsubscribed) onStoreChange();
319
+ });
320
+ return () => {
321
+ unsubscribed = true;
322
+ unsub();
323
+ };
324
+ };
325
+ getSnapshotRef.current = () => controller.getSnapshot();
326
+ fetchNextPageRef.current = () => {
327
+ // Pagination errors surface on the controller snapshot's `error`; the void
328
+ // callback has no promise channel, so consume the rejection here.
329
+ void controller.fetchNextPage().catch(() => {});
330
+ };
331
+ }
332
+
333
+ const snapshot = useSyncExternalStore(
334
+ subscribeRef.current,
335
+ getSnapshotRef.current!,
336
+ getSnapshotRef.current!,
337
+ subSlot(slot, `uses`),
338
+ );
339
+
340
+ return {
341
+ data: snapshot.data,
342
+ state: snapshot.state,
343
+ status: snapshot.status,
344
+ isLoading: snapshot.isLoading,
345
+ isReady: snapshot.isReady,
346
+ isIdle: snapshot.isIdle,
347
+ isError: snapshot.isError,
348
+ isCleanedUp: snapshot.isCleanedUp,
349
+ collection: snapshot.collection,
350
+ isEnabled: snapshot.isEnabled,
351
+ pages: snapshot.pages,
352
+ pageParams: snapshot.pageParams,
353
+ fetchNextPage: fetchNextPageRef.current!,
354
+ hasNextPage: snapshot.hasNextPage,
355
+ isFetchingNextPage: snapshot.isFetchingNextPage,
356
+ error: snapshot.error,
357
+ } as unknown as UseLiveInfiniteQueryReturn<TContext>;
358
+ }