@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.
- package/LICENSE +21 -0
- package/README.md +34 -0
- package/package.json +59 -0
- package/skills/tanstack-db/SKILL.md +432 -0
- package/src/index.ts +13 -0
- package/src/slot.ts +26 -0
- package/src/useLiveInfiniteQuery.ts +358 -0
- package/src/useLiveQuery.ts +473 -0
- package/src/useLiveQueryEffect.ts +74 -0
- package/src/useLiveSuspenseQuery.ts +251 -0
- package/src/usePacedMutations.ts +151 -0
|
@@ -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
|
+
}
|