@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,251 @@
1
+ import { use, useRef } from 'octane';
2
+ import { splitTrailingSlot, subSlot } from './slot';
3
+ import { useLiveQuery } from './useLiveQuery';
4
+ import type {
5
+ Collection,
6
+ Context,
7
+ GetResult,
8
+ InferResultType,
9
+ InitialQueryBuilder,
10
+ LiveQueryCollectionConfig,
11
+ NonSingleResult,
12
+ QueryBuilder,
13
+ SingleResult,
14
+ } from '@tanstack/db';
15
+
16
+ // Shared, already-fulfilled thenable handed to `use()` on the non-suspending
17
+ // paths (see the divergence note in the hook body). It carries the React 19
18
+ // `cache()` "settled thenable" shape (`status: 'fulfilled'`), which Octane's
19
+ // `use()` returns synchronously without instrumentation. A bare
20
+ // `Promise.resolve()` would NOT work: `use()` tags an untagged thenable
21
+ // `'pending'` synchronously (the fulfillment callback runs a microtask later),
22
+ // so its first use would suspend and flash the fallback even though data is
23
+ // ready. Its resolved value is never read — only the settled status matters — so
24
+ // one module-level instance is safe to share across every hook and render.
25
+ const SETTLED: Promise<void> & { status: 'fulfilled'; value: undefined } = Object.assign(
26
+ Promise.resolve(),
27
+ { status: `fulfilled` as const, value: undefined },
28
+ );
29
+
30
+ /**
31
+ * Create a live query with React Suspense support
32
+ * @param queryFn - Query function that defines what data to fetch
33
+ * @param deps - Array of dependencies that trigger query re-execution when changed
34
+ * @returns Object with reactive data and state - data is guaranteed to be defined
35
+ * @throws Promise when data is loading (caught by Suspense boundary)
36
+ * @throws Error when collection fails (caught by Error boundary)
37
+ * @example
38
+ * // Basic usage with Suspense
39
+ * function TodoList() {
40
+ * const { data } = useLiveSuspenseQuery((q) =>
41
+ * q.from({ todos: todosCollection })
42
+ * .where(({ todos }) => eq(todos.completed, false))
43
+ * .select(({ todos }) => ({ id: todos.id, text: todos.text }))
44
+ * )
45
+ *
46
+ * return (
47
+ * <ul>
48
+ * {data.map(todo => <li key={todo.id}>{todo.text}</li>)}
49
+ * </ul>
50
+ * )
51
+ * }
52
+ *
53
+ * function App() {
54
+ * return (
55
+ * <Suspense fallback={<div>Loading...</div>}>
56
+ * <TodoList />
57
+ * </Suspense>
58
+ * )
59
+ * }
60
+ *
61
+ * @example
62
+ * // Single result query
63
+ * const { data } = useLiveSuspenseQuery(
64
+ * (q) => q.from({ todos: todosCollection })
65
+ * .where(({ todos }) => eq(todos.id, 1))
66
+ * .findOne()
67
+ * )
68
+ * // data is guaranteed to be the single item (or undefined if not found)
69
+ *
70
+ * @example
71
+ * // With dependencies that trigger re-suspension
72
+ * const { data } = useLiveSuspenseQuery(
73
+ * (q) => q.from({ todos: todosCollection })
74
+ * .where(({ todos }) => gt(todos.priority, minPriority)),
75
+ * [minPriority] // Re-suspends when minPriority changes
76
+ * )
77
+ *
78
+ * @example
79
+ * // With Error boundary
80
+ * function App() {
81
+ * return (
82
+ * <ErrorBoundary fallback={<div>Error loading data</div>}>
83
+ * <Suspense fallback={<div>Loading...</div>}>
84
+ * <TodoList />
85
+ * </Suspense>
86
+ * </ErrorBoundary>
87
+ * )
88
+ * }
89
+ *
90
+ * @remarks
91
+ * **Important:** This hook does NOT support disabled queries (returning undefined/null).
92
+ * Following TanStack Query's useSuspenseQuery design, the query callback must always
93
+ * return a valid query, collection, or config object.
94
+ *
95
+ * ❌ **This will cause a type error:**
96
+ * ```ts
97
+ * useLiveSuspenseQuery(
98
+ * (q) => userId ? q.from({ users }) : undefined // ❌ Error!
99
+ * )
100
+ * ```
101
+ *
102
+ * ✅ **Use conditional rendering instead:**
103
+ * ```ts
104
+ * function Profile({ userId }: { userId: string }) {
105
+ * const { data } = useLiveSuspenseQuery(
106
+ * (q) => q.from({ users }).where(({ users }) => eq(users.id, userId))
107
+ * )
108
+ * return <div>{data.name}</div>
109
+ * }
110
+ *
111
+ * // In parent component:
112
+ * {userId ? <Profile userId={userId} /> : <div>No user</div>}
113
+ * ```
114
+ *
115
+ * ✅ **Or use useLiveQuery for conditional queries:**
116
+ * ```ts
117
+ * const { data, isEnabled } = useLiveQuery(
118
+ * (q) => userId ? q.from({ users }) : undefined, // ✅ Supported!
119
+ * [userId]
120
+ * )
121
+ * ```
122
+ */
123
+ // Overload 1: Accept query function that always returns QueryBuilder
124
+ export function useLiveSuspenseQuery<TContext extends Context>(
125
+ queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>,
126
+ deps?: Array<unknown>,
127
+ ): {
128
+ state: Map<string | number, GetResult<TContext>>;
129
+ data: InferResultType<TContext>;
130
+ collection: Collection<GetResult<TContext>, string | number, {}>;
131
+ };
132
+
133
+ // Overload 2: Accept config object
134
+ export function useLiveSuspenseQuery<TContext extends Context>(
135
+ config: LiveQueryCollectionConfig<TContext>,
136
+ deps?: Array<unknown>,
137
+ ): {
138
+ state: Map<string | number, GetResult<TContext>>;
139
+ data: InferResultType<TContext>;
140
+ collection: Collection<GetResult<TContext>, string | number, {}>;
141
+ };
142
+
143
+ // Overload 3: Accept pre-created live query collection
144
+ export function useLiveSuspenseQuery<
145
+ TResult extends object,
146
+ TKey extends string | number,
147
+ TUtils extends Record<string, any>,
148
+ >(
149
+ liveQueryCollection: Collection<TResult, TKey, TUtils> & NonSingleResult,
150
+ ): {
151
+ state: Map<TKey, TResult>;
152
+ data: Array<TResult>;
153
+ collection: Collection<TResult, TKey, TUtils>;
154
+ };
155
+
156
+ // Overload 4: Accept pre-created live query collection with singleResult: true
157
+ export function useLiveSuspenseQuery<
158
+ TResult extends object,
159
+ TKey extends string | number,
160
+ TUtils extends Record<string, any>,
161
+ >(
162
+ liveQueryCollection: Collection<TResult, TKey, TUtils> & SingleResult,
163
+ ): {
164
+ state: Map<TKey, TResult>;
165
+ data: TResult | undefined;
166
+ collection: Collection<TResult, TKey, TUtils> & SingleResult;
167
+ };
168
+
169
+ // Implementation - uses useLiveQuery internally and adds Suspense logic
170
+ export function useLiveSuspenseQuery(configOrQueryOrCollection: any, ...rest: Array<unknown>) {
171
+ const [args, slot] = splitTrailingSlot(rest);
172
+ const deps = (args[0] as Array<unknown> | undefined) ?? [];
173
+
174
+ const promiseRef = useRef<Promise<void> | null>(null, subSlot(slot, `promise-ref`));
175
+ const collectionRef = useRef<Collection<any, any, any> | null>(null, subSlot(slot, `coll-ref`));
176
+ const hasBeenReadyRef = useRef(false, subSlot(slot, `ready-ref`));
177
+
178
+ // Use useLiveQuery to handle collection management and reactivity.
179
+ // Namespace the nested slot so useLiveQuery's internal refs (e.g. `coll-ref`)
180
+ // don't alias this wrapper's own refs of the same tag.
181
+ const result = (useLiveQuery as any)(configOrQueryOrCollection, deps, subSlot(slot, `lq`));
182
+
183
+ // Reset promise and ready state when collection changes (deps changed)
184
+ if (collectionRef.current !== result.collection) {
185
+ promiseRef.current = null;
186
+ collectionRef.current = result.collection;
187
+ hasBeenReadyRef.current = false;
188
+ }
189
+
190
+ // SUSPENSE LOGIC: Throw promise or error based on collection status
191
+
192
+ if (!result.isEnabled) {
193
+ // Suspense queries cannot be disabled - this matches TanStack Query's useSuspenseQuery behavior
194
+ throw new Error(
195
+ `useLiveSuspenseQuery does not support disabled queries (callback returned undefined/null). ` +
196
+ `The Suspense pattern requires data to always be defined (T, not T | undefined). ` +
197
+ `Solutions: ` +
198
+ `1) Use conditional rendering - don't render the component until the condition is met. ` +
199
+ `2) Use useLiveQuery instead, which supports disabled queries with the 'isEnabled' flag.`,
200
+ );
201
+ }
202
+
203
+ // It’s not recommended to suspend a render based on a store value returned by useSyncExternalStore.
204
+ // result.status is the snapshot from syncExternalStore. We read the fresh status from the collection reference instead.
205
+ const collectionStatus = result.collection.status;
206
+
207
+ // Track when we reach ready state
208
+ if (collectionStatus === `ready`) {
209
+ hasBeenReadyRef.current = true;
210
+ promiseRef.current = null;
211
+ }
212
+
213
+ // Only throw errors during initial load (before first ready)
214
+ // After success, errors surface as stale data (matches TanStack Query behavior).
215
+ // This throw aborts the whole component body (no later hook runs), so it does
216
+ // not perturb the call-order invariant the `use()` below depends on.
217
+ if (collectionStatus === `error` && !hasBeenReadyRef.current) {
218
+ promiseRef.current = null;
219
+ // TODO: Once collections hold a reference to their last error object (#671),
220
+ // we should rethrow that actual error instead of creating a generic message
221
+ throw new Error(`Collection "${result.collection.id}" failed to load`);
222
+ }
223
+
224
+ // OCTANE DIVERGENCE: suspend via `use(thenable)`, not `throw promise`, and call
225
+ // `use()` UNCONDITIONALLY — exactly once on every path that keeps rendering.
226
+ //
227
+ // Upstream react-db `throw`s the preload promise, and a React `throw` suspends
228
+ // the whole component with no positional state. Octane Suspense instead only
229
+ // recognizes the sentinel `use()` produces (a raw thrown promise reaches the
230
+ // error path and never renders the fallback), and Octane tracks `use(thenable)`
231
+ // by dynamic call-order index (the runtime's `__thenableIdx`), like React's
232
+ // positional `thenableState` — NOT by compiler slot. So skipping `use()` on the
233
+ // ready / stale-after-error paths would shift the thenable index of any sibling
234
+ // `use()` or second `useLiveSuspenseQuery` in the same component, which could
235
+ // then read a neighbor's fulfilled thenable and expose still-pending data as
236
+ // ready. Handing `use()` an already-resolved thenable when we are not loading
237
+ // keeps the call count stable and returns synchronously without suspending.
238
+ // Reusing the `promiseRef` identity lets `use()` dedupe the thenable across the
239
+ // suspension's replay renders.
240
+ const isLoading = collectionStatus === `loading` || collectionStatus === `idle`;
241
+ const preloadPromise = isLoading ? (promiseRef.current ??= result.collection.preload()) : SETTLED;
242
+ use(preloadPromise);
243
+
244
+ // Return data without status/loading flags (handled by Suspense/ErrorBoundary)
245
+ // If error after success, return last known good state (stale data)
246
+ return {
247
+ state: result.state,
248
+ data: result.data,
249
+ collection: result.collection,
250
+ };
251
+ }
@@ -0,0 +1,151 @@
1
+ import { useCallback, useMemo, useRef } from 'octane';
2
+ import { createPacedMutations } from '@tanstack/db';
3
+ import { splitTrailingSlot, subSlot } from './slot';
4
+ import type { PacedMutationsConfig, Transaction } from '@tanstack/db';
5
+
6
+ /**
7
+ * React hook for managing paced mutations with timing strategies.
8
+ *
9
+ * Provides optimistic mutations with pluggable strategies like debouncing,
10
+ * queuing, or throttling. The optimistic updates are applied immediately via
11
+ * `onMutate`, and the actual persistence is controlled by the strategy.
12
+ *
13
+ * @param config - Configuration including onMutate, mutationFn and strategy
14
+ * @returns A mutate function that accepts variables and returns Transaction objects
15
+ *
16
+ * @example
17
+ * ```tsx
18
+ * // Debounced auto-save
19
+ * function AutoSaveForm({ formId }: { formId: string }) {
20
+ * const mutate = usePacedMutations<string>({
21
+ * onMutate: (value) => {
22
+ * // Apply optimistic update immediately
23
+ * formCollection.update(formId, draft => {
24
+ * draft.content = value
25
+ * })
26
+ * },
27
+ * mutationFn: async ({ transaction }) => {
28
+ * await api.save(transaction.mutations)
29
+ * },
30
+ * strategy: debounceStrategy({ wait: 500 })
31
+ * })
32
+ *
33
+ * const handleChange = async (value: string) => {
34
+ * const tx = mutate(value)
35
+ *
36
+ * // Optional: await persistence or handle errors
37
+ * try {
38
+ * await tx.isPersisted.promise
39
+ * console.log('Saved!')
40
+ * } catch (error) {
41
+ * console.error('Save failed:', error)
42
+ * }
43
+ * }
44
+ *
45
+ * return <textarea onChange={e => handleChange(e.target.value)} />
46
+ * }
47
+ * ```
48
+ *
49
+ * @example
50
+ * ```tsx
51
+ * // Throttled slider updates
52
+ * function VolumeSlider() {
53
+ * const mutate = usePacedMutations<number>({
54
+ * onMutate: (volume) => {
55
+ * settingsCollection.update('volume', draft => {
56
+ * draft.value = volume
57
+ * })
58
+ * },
59
+ * mutationFn: async ({ transaction }) => {
60
+ * await api.updateVolume(transaction.mutations)
61
+ * },
62
+ * strategy: throttleStrategy({ wait: 200 })
63
+ * })
64
+ *
65
+ * return <input type="range" onChange={e => mutate(+e.target.value)} />
66
+ * }
67
+ * ```
68
+ *
69
+ * @example
70
+ * ```tsx
71
+ * // Debounce with leading/trailing for color picker (persist first + final only)
72
+ * function ColorPicker() {
73
+ * const mutate = usePacedMutations<string>({
74
+ * onMutate: (color) => {
75
+ * themeCollection.update('primary', draft => {
76
+ * draft.color = color
77
+ * })
78
+ * },
79
+ * mutationFn: async ({ transaction }) => {
80
+ * await api.updateTheme(transaction.mutations)
81
+ * },
82
+ * strategy: debounceStrategy({ wait: 0, leading: true, trailing: true })
83
+ * })
84
+ *
85
+ * return (
86
+ * <input
87
+ * type="color"
88
+ * onChange={e => mutate(e.target.value)}
89
+ * />
90
+ * )
91
+ * }
92
+ * ```
93
+ */
94
+ export function usePacedMutations<TVariables = unknown, T extends object = Record<string, unknown>>(
95
+ config: PacedMutationsConfig<TVariables, T>,
96
+ ...rest: Array<unknown>
97
+ ): (variables: TVariables) => Transaction<T> {
98
+ const [, slot] = splitTrailingSlot(rest);
99
+
100
+ // Keep refs to the latest callbacks so we can call them without recreating the instance
101
+ const onMutateRef = useRef(config.onMutate, subSlot(slot, `on-mutate-ref`));
102
+ onMutateRef.current = config.onMutate;
103
+
104
+ const mutationFnRef = useRef(config.mutationFn, subSlot(slot, `mutation-fn-ref`));
105
+ mutationFnRef.current = config.mutationFn;
106
+
107
+ // Create stable wrappers that always call the latest version
108
+ const stableOnMutate = useCallback<typeof config.onMutate>(
109
+ (variables) => {
110
+ return onMutateRef.current(variables);
111
+ },
112
+ [],
113
+ subSlot(slot, `on-mutate-cb`),
114
+ );
115
+
116
+ const stableMutationFn = useCallback<typeof config.mutationFn>(
117
+ (params) => {
118
+ return mutationFnRef.current(params);
119
+ },
120
+ [],
121
+ subSlot(slot, `mutation-fn-cb`),
122
+ );
123
+
124
+ // Create paced mutations instance with proper dependency tracking
125
+ // Serialize strategy for stable comparison since strategy objects are recreated on each render
126
+ const mutate = useMemo(
127
+ () => {
128
+ return createPacedMutations<TVariables, T>({
129
+ ...config,
130
+ onMutate: stableOnMutate,
131
+ mutationFn: stableMutationFn,
132
+ });
133
+ },
134
+ [
135
+ stableOnMutate,
136
+ stableMutationFn,
137
+ config.metadata,
138
+ // Serialize strategy to avoid recreating when object reference changes but values are same
139
+ JSON.stringify({
140
+ type: config.strategy._type,
141
+ options: config.strategy.options,
142
+ }),
143
+ ],
144
+ subSlot(slot, `mutate-memo`),
145
+ );
146
+
147
+ // Return stable mutate callback
148
+ const stableMutate = useCallback(mutate, [mutate], subSlot(slot, `mutate-cb`));
149
+
150
+ return stableMutate;
151
+ }