@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,473 @@
1
+ import { useRef, useSyncExternalStore } from 'octane';
2
+ import {
3
+ BaseQueryBuilder,
4
+ createLiveQueryCollection,
5
+ createLiveQueryObserver,
6
+ isCollection,
7
+ } from '@tanstack/db';
8
+ import { splitTrailingSlot, subSlot } from './slot';
9
+ import type {
10
+ Collection,
11
+ CollectionStatus,
12
+ Context,
13
+ GetResult,
14
+ InferResultType,
15
+ InitialQueryBuilder,
16
+ LiveQueryCollectionConfig,
17
+ LiveQueryObserver,
18
+ NonSingleResult,
19
+ QueryBuilder,
20
+ SingleResult,
21
+ } from '@tanstack/db';
22
+
23
+ const DEFAULT_GC_TIME_MS = 1; // Live queries created by useLiveQuery are cleaned up immediately (0 disables GC)
24
+
25
+ export type UseLiveQueryStatus = CollectionStatus | `disabled`;
26
+
27
+ /**
28
+ * Create a live query using a query function
29
+ * @param queryFn - Query function that defines what data to fetch
30
+ * @param deps - Array of dependencies that trigger query re-execution when changed
31
+ * @returns Object with reactive data, state, and status information
32
+ * @example
33
+ * // Basic query with object syntax
34
+ * const { data, isLoading } = useLiveQuery((q) =>
35
+ * q.from({ todos: todosCollection })
36
+ * .where(({ todos }) => eq(todos.completed, false))
37
+ * .select(({ todos }) => ({ id: todos.id, text: todos.text }))
38
+ * )
39
+ *
40
+ * @example
41
+ * // Single result query
42
+ * const { data } = useLiveQuery(
43
+ * (q) => q.from({ todos: todosCollection })
44
+ * .where(({ todos }) => eq(todos.id, 1))
45
+ * .findOne()
46
+ * )
47
+ *
48
+ * @example
49
+ * // With dependencies that trigger re-execution
50
+ * const { data, state } = useLiveQuery(
51
+ * (q) => q.from({ todos: todosCollection })
52
+ * .where(({ todos }) => gt(todos.priority, minPriority)),
53
+ * [minPriority] // Re-run when minPriority changes
54
+ * )
55
+ *
56
+ * @example
57
+ * // Join pattern
58
+ * const { data } = useLiveQuery((q) =>
59
+ * q.from({ issues: issueCollection })
60
+ * .join({ persons: personCollection }, ({ issues, persons }) =>
61
+ * eq(issues.userId, persons.id)
62
+ * )
63
+ * .select(({ issues, persons }) => ({
64
+ * id: issues.id,
65
+ * title: issues.title,
66
+ * userName: persons.name
67
+ * }))
68
+ * )
69
+ *
70
+ * @example
71
+ * // Handle loading and error states
72
+ * const { data, isLoading, isError, status } = useLiveQuery((q) =>
73
+ * q.from({ todos: todoCollection })
74
+ * )
75
+ *
76
+ * if (isLoading) return <div>Loading...</div>
77
+ * if (isError) return <div>Error: {status}</div>
78
+ *
79
+ * return (
80
+ * <ul>
81
+ * {data.map(todo => <li key={todo.id}>{todo.text}</li>)}
82
+ * </ul>
83
+ * )
84
+ */
85
+ // Overload 1: Accept query function that always returns QueryBuilder
86
+ export function useLiveQuery<TContext extends Context>(
87
+ queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
88
+ deps?: Array<unknown>,
89
+ ): {
90
+ state: Map<string | number, GetResult<TContext>>;
91
+ data: InferResultType<TContext>;
92
+ collection: Collection<GetResult<TContext>, string | number, {}>;
93
+ status: CollectionStatus; // Can't be disabled if always returns QueryBuilder
94
+ isLoading: boolean;
95
+ isReady: boolean;
96
+ isIdle: boolean;
97
+ isError: boolean;
98
+ isCleanedUp: boolean;
99
+ isEnabled: true; // Always true if always returns QueryBuilder
100
+ };
101
+
102
+ // Overload 2: Accept query function that can return undefined/null
103
+ export function useLiveQuery<TContext extends Context>(
104
+ queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext> | undefined | null,
105
+ deps?: Array<unknown>,
106
+ ): {
107
+ state: Map<string | number, GetResult<TContext>> | undefined;
108
+ data: InferResultType<TContext> | undefined;
109
+ collection: Collection<GetResult<TContext>, string | number, {}> | undefined;
110
+ status: UseLiveQueryStatus;
111
+ isLoading: boolean;
112
+ isReady: boolean;
113
+ isIdle: boolean;
114
+ isError: boolean;
115
+ isCleanedUp: boolean;
116
+ isEnabled: boolean;
117
+ };
118
+
119
+ // Overload 3: Accept query function that can return LiveQueryCollectionConfig
120
+ export function useLiveQuery<TContext extends Context>(
121
+ queryFn: (q: InitialQueryBuilder) => LiveQueryCollectionConfig<TContext> | undefined | null,
122
+ deps?: Array<unknown>,
123
+ ): {
124
+ state: Map<string | number, GetResult<TContext>> | undefined;
125
+ data: InferResultType<TContext> | undefined;
126
+ collection: Collection<GetResult<TContext>, string | number, {}> | undefined;
127
+ status: UseLiveQueryStatus;
128
+ isLoading: boolean;
129
+ isReady: boolean;
130
+ isIdle: boolean;
131
+ isError: boolean;
132
+ isCleanedUp: boolean;
133
+ isEnabled: boolean;
134
+ };
135
+
136
+ // Overload 4: Accept query function that can return Collection
137
+ export function useLiveQuery<
138
+ TResult extends object,
139
+ TKey extends string | number,
140
+ TUtils extends Record<string, any>,
141
+ >(
142
+ queryFn: (q: InitialQueryBuilder) => Collection<TResult, TKey, TUtils> | undefined | null,
143
+ deps?: Array<unknown>,
144
+ ): {
145
+ state: Map<TKey, TResult> | undefined;
146
+ data: Array<TResult> | undefined;
147
+ collection: Collection<TResult, TKey, TUtils> | undefined;
148
+ status: UseLiveQueryStatus;
149
+ isLoading: boolean;
150
+ isReady: boolean;
151
+ isIdle: boolean;
152
+ isError: boolean;
153
+ isCleanedUp: boolean;
154
+ isEnabled: boolean;
155
+ };
156
+
157
+ // Overload 5: Accept query function that can return all types
158
+ export function useLiveQuery<
159
+ TContext extends Context,
160
+ TResult extends object,
161
+ TKey extends string | number,
162
+ TUtils extends Record<string, any>,
163
+ >(
164
+ queryFn: (
165
+ q: InitialQueryBuilder,
166
+ ) =>
167
+ | QueryBuilder<TContext>
168
+ | LiveQueryCollectionConfig<TContext>
169
+ | Collection<TResult, TKey, TUtils>
170
+ | undefined
171
+ | null,
172
+ deps?: Array<unknown>,
173
+ ): {
174
+ state: Map<string | number, GetResult<TContext>> | Map<TKey, TResult> | undefined;
175
+ data: InferResultType<TContext> | Array<TResult> | undefined;
176
+ collection:
177
+ | Collection<GetResult<TContext>, string | number, {}>
178
+ | Collection<TResult, TKey, TUtils>
179
+ | undefined;
180
+ status: UseLiveQueryStatus;
181
+ isLoading: boolean;
182
+ isReady: boolean;
183
+ isIdle: boolean;
184
+ isError: boolean;
185
+ isCleanedUp: boolean;
186
+ isEnabled: boolean;
187
+ };
188
+
189
+ /**
190
+ * Create a live query using configuration object
191
+ * @param config - Configuration object with query and options
192
+ * @param deps - Array of dependencies that trigger query re-execution when changed
193
+ * @returns Object with reactive data, state, and status information
194
+ * @example
195
+ * // Basic config object usage
196
+ * const { data, status } = useLiveQuery({
197
+ * query: (q) => q.from({ todos: todosCollection }),
198
+ * gcTime: 60000
199
+ * })
200
+ *
201
+ * @example
202
+ * // With query builder and options
203
+ * const queryBuilder = new Query()
204
+ * .from({ persons: collection })
205
+ * .where(({ persons }) => gt(persons.age, 30))
206
+ * .select(({ persons }) => ({ id: persons.id, name: persons.name }))
207
+ *
208
+ * const { data, isReady } = useLiveQuery({ query: queryBuilder })
209
+ *
210
+ * @example
211
+ * // Handle all states uniformly
212
+ * const { data, isLoading, isReady, isError } = useLiveQuery({
213
+ * query: (q) => q.from({ items: itemCollection })
214
+ * })
215
+ *
216
+ * if (isLoading) return <div>Loading...</div>
217
+ * if (isError) return <div>Something went wrong</div>
218
+ * if (!isReady) return <div>Preparing...</div>
219
+ *
220
+ * return <div>{data.length} items loaded</div>
221
+ */
222
+ // Overload 6: Accept config object
223
+ export function useLiveQuery<TContext extends Context>(
224
+ config: LiveQueryCollectionConfig<TContext>,
225
+ deps?: Array<unknown>,
226
+ ): {
227
+ state: Map<string | number, GetResult<TContext>>;
228
+ data: InferResultType<TContext>;
229
+ collection: Collection<GetResult<TContext>, string | number, {}>;
230
+ status: CollectionStatus; // Can't be disabled for config objects
231
+ isLoading: boolean;
232
+ isReady: boolean;
233
+ isIdle: boolean;
234
+ isError: boolean;
235
+ isCleanedUp: boolean;
236
+ isEnabled: true; // Always true for config objects
237
+ };
238
+
239
+ /**
240
+ * Subscribe to an existing live query collection
241
+ * @param liveQueryCollection - Pre-created live query collection to subscribe to
242
+ * @returns Object with reactive data, state, and status information
243
+ * @example
244
+ * // Using pre-created live query collection
245
+ * const myLiveQuery = createLiveQueryCollection((q) =>
246
+ * q.from({ todos: todosCollection }).where(({ todos }) => eq(todos.active, true))
247
+ * )
248
+ * const { data, collection } = useLiveQuery(myLiveQuery)
249
+ *
250
+ * @example
251
+ * // Access collection methods directly
252
+ * const { data, collection, isReady } = useLiveQuery(existingCollection)
253
+ *
254
+ * // Use collection for mutations
255
+ * const handleToggle = (id) => {
256
+ * collection.update(id, draft => { draft.completed = !draft.completed })
257
+ * }
258
+ *
259
+ * @example
260
+ * // Handle states consistently
261
+ * const { data, isLoading, isError } = useLiveQuery(sharedCollection)
262
+ *
263
+ * if (isLoading) return <div>Loading...</div>
264
+ * if (isError) return <div>Error loading data</div>
265
+ *
266
+ * return <div>{data.map(item => <Item key={item.id} {...item} />)}</div>
267
+ */
268
+ // Overload 7: Accept pre-created live query collection
269
+ export function useLiveQuery<
270
+ TResult extends object,
271
+ TKey extends string | number,
272
+ TUtils extends Record<string, any>,
273
+ >(
274
+ liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult,
275
+ ): {
276
+ state: Map<TKey, TResult>;
277
+ data: Array<TResult>;
278
+ collection: Collection<TResult, TKey, TUtils>;
279
+ status: CollectionStatus; // Can't be disabled for pre-created live query collections
280
+ isLoading: boolean;
281
+ isReady: boolean;
282
+ isIdle: boolean;
283
+ isError: boolean;
284
+ isCleanedUp: boolean;
285
+ isEnabled: true; // Always true for pre-created live query collections
286
+ };
287
+
288
+ // Overload 8: Accept pre-created live query collection with singleResult: true
289
+ export function useLiveQuery<
290
+ TResult extends object,
291
+ TKey extends string | number,
292
+ TUtils extends Record<string, any>,
293
+ >(
294
+ liveQueryCollection: Collection<TResult, TKey, TUtils> & SingleResult,
295
+ ): {
296
+ state: Map<TKey, TResult>;
297
+ data: TResult | undefined;
298
+ collection: Collection<TResult, TKey, TUtils> & SingleResult;
299
+ status: CollectionStatus; // Can't be disabled for pre-created live query collections
300
+ isLoading: boolean;
301
+ isReady: boolean;
302
+ isIdle: boolean;
303
+ isError: boolean;
304
+ isCleanedUp: boolean;
305
+ isEnabled: true; // Always true for pre-created live query collections
306
+ };
307
+
308
+ // Implementation - use function overloads to infer the actual collection type
309
+ export function useLiveQuery(configOrQueryOrCollection: any, ...rest: Array<unknown>) {
310
+ const [args, slot] = splitTrailingSlot(rest);
311
+ const deps = (args[0] as Array<unknown> | undefined) ?? [];
312
+ // Check if it's already a collection
313
+ const inputIsCollection = isCollection(configOrQueryOrCollection);
314
+
315
+ // Use refs to cache collection and track dependencies
316
+ const collectionRef = useRef<Collection<object, string | number, {}> | null>(
317
+ null,
318
+ subSlot(slot, `coll-ref`),
319
+ );
320
+ const depsRef = useRef<Array<unknown> | null>(null, subSlot(slot, `deps-ref`));
321
+ const configRef = useRef<unknown>(null, subSlot(slot, `cfg-ref`));
322
+
323
+ // The shared observer owns the collection subscription, the ready-race, the
324
+ // status-transition notifications, and the stable per-revision snapshot. It
325
+ // replaces the previous hand-rolled `subscribeChanges` + version-counter,
326
+ // which only fired on row changes and therefore missed status-only
327
+ // transitions (a collection going to `error` or `cleaned-up` without any row
328
+ // delta left `status`/`isError`/`isCleanedUp` stale). See TanStack DB #1642.
329
+ const observerRef = useRef<LiveQueryObserver<object, string | number> | null>(
330
+ null,
331
+ subSlot(slot, `obs-ref`),
332
+ );
333
+
334
+ // Check if we need to create/recreate the collection
335
+ const needsNewCollection =
336
+ !collectionRef.current ||
337
+ (inputIsCollection && configRef.current !== configOrQueryOrCollection) ||
338
+ (!inputIsCollection &&
339
+ (depsRef.current === null ||
340
+ depsRef.current.length !== deps.length ||
341
+ depsRef.current.some((dep, i) => dep !== deps[i])));
342
+
343
+ if (needsNewCollection) {
344
+ if (inputIsCollection) {
345
+ // Warn when passing a collection directly with on-demand sync mode
346
+ // In on-demand mode, data is only loaded when queries with predicates request it
347
+ // Passing the collection directly doesn't provide any predicates, so no data loads
348
+ const syncMode = (configOrQueryOrCollection as { config?: { syncMode?: string } }).config
349
+ ?.syncMode;
350
+ if (syncMode === `on-demand`) {
351
+ console.warn(
352
+ `[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery ` +
353
+ `will not load any data. In on-demand mode, data is only loaded when queries with predicates request it.\n\n` +
354
+ `Instead, use a query builder function:\n` +
355
+ ` const { data } = useLiveQuery((q) => q.from({ c: myCollection }).select(({ c }) => c))\n\n` +
356
+ `Or switch to syncMode "eager" if you want all data to sync automatically.`,
357
+ );
358
+ }
359
+ // It's already a collection, ensure sync is started for Octane hooks
360
+ configOrQueryOrCollection.startSyncImmediate();
361
+ collectionRef.current = configOrQueryOrCollection;
362
+ configRef.current = configOrQueryOrCollection;
363
+ } else {
364
+ // Handle different callback return types
365
+ if (typeof configOrQueryOrCollection === `function`) {
366
+ // Call the function with a query builder to see what it returns
367
+ const queryBuilder = new BaseQueryBuilder() as InitialQueryBuilder;
368
+ const result = configOrQueryOrCollection(queryBuilder);
369
+
370
+ if (result === undefined || result === null) {
371
+ // Callback returned undefined/null - disabled query
372
+ collectionRef.current = null;
373
+ } else if (isCollection(result)) {
374
+ // Callback returned a Collection instance - use it directly
375
+ result.startSyncImmediate();
376
+ collectionRef.current = result;
377
+ } else if (result instanceof BaseQueryBuilder) {
378
+ // Callback returned QueryBuilder - create live query collection using the original callback
379
+ // (not the result, since the result might be from a different query builder instance)
380
+ collectionRef.current = createLiveQueryCollection({
381
+ query: configOrQueryOrCollection,
382
+ startSync: true,
383
+ gcTime: DEFAULT_GC_TIME_MS,
384
+ });
385
+ } else if (result && typeof result === `object`) {
386
+ // Assume it's a LiveQueryCollectionConfig
387
+ collectionRef.current = createLiveQueryCollection({
388
+ startSync: true,
389
+ gcTime: DEFAULT_GC_TIME_MS,
390
+ ...result,
391
+ });
392
+ } else {
393
+ // Unexpected return type
394
+ throw new Error(
395
+ `useLiveQuery callback must return a QueryBuilder, LiveQueryCollectionConfig, Collection, undefined, or null. Got: ${typeof result}`,
396
+ );
397
+ }
398
+ depsRef.current = [...deps];
399
+ } else {
400
+ // Original logic for config objects
401
+ collectionRef.current = createLiveQueryCollection({
402
+ startSync: true,
403
+ gcTime: DEFAULT_GC_TIME_MS,
404
+ ...configOrQueryOrCollection,
405
+ });
406
+ depsRef.current = [...deps];
407
+ }
408
+ }
409
+ }
410
+
411
+ // Recreate the observer when the underlying collection changes (including to
412
+ // `null` for a disabled query, which yields a stable disabled snapshot). The
413
+ // observer is not disposed explicitly here or on unmount: useSyncExternalStore
414
+ // unsubscribes it when the subscribe function changes or the component
415
+ // unmounts, which detaches the collection subscription and lets the observer
416
+ // be GC'd. An unmount effect that disposed it would misfire under effect
417
+ // replay, leaving a disposed observer in the ref.
418
+ if (needsNewCollection) {
419
+ // Wholesale mode: Octane re-reads getSnapshot() on notify (matching
420
+ // useSyncExternalStore), preserves the hook's loading policy, and delivers
421
+ // nothing synchronously during subscribe — so subscribe never notifies the
422
+ // store from inside its own call.
423
+ observerRef.current = createLiveQueryObserver(collectionRef.current, {
424
+ mode: `wholesale`,
425
+ });
426
+ }
427
+ const observer = observerRef.current!;
428
+
429
+ // Stable subscribe/getSnapshot bound to the current observer. The observer
430
+ // returns a stable per-revision snapshot whose shape (state, data, collection,
431
+ // status + flags, isEnabled) is exactly what this hook exposes, so no
432
+ // post-processing is needed.
433
+ const subscribeRef = useRef<((onStoreChange: () => void) => () => void) | null>(
434
+ null,
435
+ subSlot(slot, `sub-ref`),
436
+ );
437
+ if (!subscribeRef.current || needsNewCollection) {
438
+ subscribeRef.current = (onStoreChange: () => void) => {
439
+ let unsubscribed = false;
440
+ const unsub = observer.subscribe(() => {
441
+ if (!unsubscribed) onStoreChange();
442
+ });
443
+ // Nudge Octane to re-read the snapshot on the next microtask. Octane's
444
+ // useSyncExternalStore runs its commit-time tear-check BEFORE this passive
445
+ // subscribe effect calls us (React re-checks AFTER subscribe), so a
446
+ // collection that is already `ready` — or that starts sync synchronously
447
+ // during subscribe — publishes no change the observer can forward and the
448
+ // first committed value would stay stale. Deferring to a microtask keeps
449
+ // the notify outside the render→commit window. See the
450
+ // `eager-onstorechange` regression test.
451
+ queueMicrotask(() => {
452
+ if (!unsubscribed) onStoreChange();
453
+ });
454
+ return () => {
455
+ unsubscribed = true;
456
+ unsub();
457
+ };
458
+ };
459
+ }
460
+
461
+ const getSnapshotRef = useRef<(() => unknown) | null>(null, subSlot(slot, `gs-ref`));
462
+ if (!getSnapshotRef.current || needsNewCollection) {
463
+ getSnapshotRef.current = () => observer.getSnapshot();
464
+ }
465
+
466
+ // Keep implementation return loose to satisfy the overload signatures.
467
+ return useSyncExternalStore(
468
+ subscribeRef.current,
469
+ getSnapshotRef.current,
470
+ getSnapshotRef.current,
471
+ subSlot(slot, `uses`),
472
+ ) as any;
473
+ }
@@ -0,0 +1,74 @@
1
+ import { useEffect, useRef } from 'octane';
2
+ import { createEffect } from '@tanstack/db';
3
+ import { splitTrailingSlot, subSlot } from './slot';
4
+ import type { Effect, EffectConfig } from '@tanstack/db';
5
+
6
+ /**
7
+ * React hook for creating a reactive effect that fires handlers when rows
8
+ * enter, exit, or update within a query result.
9
+ *
10
+ * The effect is created on mount and disposed on unmount. If `deps` change,
11
+ * the previous effect is disposed and a new one is created.
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * function ChatComponent() {
16
+ * useLiveQueryEffect(
17
+ * {
18
+ * query: (q) => q.from({ msg: messages }).where(({ msg }) => eq(msg.role, 'user')),
19
+ * skipInitial: true,
20
+ * onEnter: async (event) => {
21
+ * await generateResponse(event.value)
22
+ * },
23
+ * },
24
+ * []
25
+ * )
26
+ *
27
+ * return <div>...</div>
28
+ * }
29
+ * ```
30
+ */
31
+ export function useLiveQueryEffect<
32
+ TRow extends object = Record<string, unknown>,
33
+ TKey extends string | number = string | number,
34
+ >(config: EffectConfig<TRow, TKey>, deps?: Array<unknown>): void;
35
+ export function useLiveQueryEffect(config: any, ...rest: Array<unknown>): void {
36
+ // Parse the optional `deps` array and the compiler-injected trailing slot
37
+ // together from `...rest`, exactly as useLiveQuery/useLiveInfiniteQuery do.
38
+ // Binding the slot to a named `deps` parameter breaks the common no-deps call
39
+ // `useLiveQueryEffect(config)`: the injected slot lands in `deps`, leaving
40
+ // `rest` empty and the slot undefined, so every ref/effect below loses its
41
+ // call-site identity.
42
+ const [args, slot] = splitTrailingSlot(rest);
43
+ const deps = (args[0] as Array<unknown> | undefined) ?? [];
44
+
45
+ const configRef = useRef<EffectConfig<any, any>>(config, subSlot(slot, `cfg-ref`));
46
+ configRef.current = config;
47
+
48
+ useEffect(
49
+ () => {
50
+ const effect: Effect = createEffect({
51
+ id: config.id,
52
+ query: config.query,
53
+ skipInitial: config.skipInitial,
54
+ onEnter: (event, ctx) => configRef.current.onEnter?.(event, ctx),
55
+ onUpdate: (event, ctx) => configRef.current.onUpdate?.(event, ctx),
56
+ onExit: (event, ctx) => configRef.current.onExit?.(event, ctx),
57
+ onBatch: (events, ctx) => configRef.current.onBatch?.(events, ctx),
58
+ onError: config.onError
59
+ ? (error, event) => configRef.current.onError?.(error, event)
60
+ : undefined,
61
+ onSourceError: config.onSourceError
62
+ ? (error) => configRef.current.onSourceError?.(error)
63
+ : undefined,
64
+ });
65
+
66
+ return () => {
67
+ // Fire-and-forget disposal; AbortSignal cancels in-flight work
68
+ effect.dispose();
69
+ };
70
+ },
71
+ deps,
72
+ subSlot(slot, `effect`),
73
+ );
74
+ }