@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,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
|
+
}
|