@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominic Gannaway
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @octanejs/tanstack-db
2
+
3
+ Octane live-query hooks for [TanStack DB](https://github.com/TanStack/db).
4
+
5
+ Re-exports [`@tanstack/db`](https://tanstack.com/db) unchanged and implements its
6
+ live-query binding surface on Octane hooks:
7
+
8
+ - `useLiveQuery`
9
+ - `useLiveInfiniteQuery`
10
+ - `useLiveSuspenseQuery`
11
+ - `useLiveQueryEffect`
12
+ - `usePacedMutations`
13
+
14
+ Install `octane` alongside this package and configure the Octane compiler in your
15
+ build tool (see [octanejs.dev](https://octanejs.dev/docs/build-tools)).
16
+
17
+ ## Compatibility
18
+
19
+ Ports the React live-query hooks of `@tanstack/react-db@0.1.96` onto Octane and
20
+ re-exports the framework-neutral `@tanstack/db@0.7.0` core unchanged.
21
+ `useLiveQuery`/`useLiveSuspenseQuery` run on db's shared `createLiveQueryObserver`
22
+ and `useLiveInfiniteQuery` on the coordinated `createLiveQueryWindowController`.
23
+
24
+ Intentional differences from React:
25
+
26
+ - **Suspense** integrates via Octane's `use(thenable)` rather than throwing a raw
27
+ promise (observable behavior — fallback then data — matches).
28
+ - **`useLiveInfiniteQuery`** rejects a pre-created collection that lacks an
29
+ `orderBy` synchronously during render, so the error reaches the caller.
30
+ - **StrictMode double-invocation** is not applicable (Octane has no development
31
+ double-invoke).
32
+
33
+ See `UPSTREAM.md` for the pin and export crosswalk and `status.json` for the
34
+ tracked binding status.
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@octanejs/tanstack-db",
3
+ "version": "0.0.1",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.22.2"
8
+ },
9
+ "octane": {
10
+ "hookSlots": {
11
+ "manual": [
12
+ "src"
13
+ ]
14
+ }
15
+ },
16
+ "description": "TanStack DB bindings for Octane — reuses @tanstack/db's framework-agnostic core with Octane live-query hooks.",
17
+ "author": "Kyle Mathews",
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/octanejs/octane.git",
24
+ "directory": "packages/tanstack-db"
25
+ },
26
+ "homepage": "https://tanstack.com/db",
27
+ "keywords": [
28
+ "optimistic",
29
+ "octane",
30
+ "typescript",
31
+ "tanstack-intent"
32
+ ],
33
+ "main": "src/index.ts",
34
+ "module": "src/index.ts",
35
+ "types": "src/index.ts",
36
+ "files": [
37
+ "src",
38
+ "skills",
39
+ "README.md"
40
+ ],
41
+ "exports": {
42
+ ".": "./src/index.ts"
43
+ },
44
+ "sideEffects": false,
45
+ "dependencies": {
46
+ "@tanstack/db": "^0.7.0"
47
+ },
48
+ "peerDependencies": {
49
+ "octane": "0.1.38"
50
+ },
51
+ "devDependencies": {
52
+ "vitest": "^4.1.10",
53
+ "@octanejs/testing-library": "0.1.35",
54
+ "octane": "0.1.38"
55
+ },
56
+ "scripts": {
57
+ "test": "vitest run"
58
+ }
59
+ }
@@ -0,0 +1,432 @@
1
+ ---
2
+ name: tanstack-db
3
+ description: >
4
+ Octane bindings for TanStack DB. useLiveQuery hook with dependency arrays
5
+ (8 overloads: query function, config object, pre-created collection,
6
+ disabled state via returning undefined/null). useLiveSuspenseQuery for
7
+ Octane Suspense with Error Boundaries (data always defined).
8
+ useLiveInfiniteQuery for cursor-based pagination (pageSize, fetchNextPage,
9
+ hasNextPage, isFetchingNextPage). usePacedMutations for debounced Octane
10
+ state updates. Return shape: data, state, collection, status, isLoading,
11
+ isReady, isError. Import from @octanejs/tanstack-db (re-exports all of
12
+ @tanstack/db).
13
+ type: framework
14
+ library: db
15
+ framework: octane
16
+ library_version: '0.6.0'
17
+ requires:
18
+ - db-core
19
+ sources:
20
+ - 'TanStack/db:docs/framework/octane/overview.md'
21
+ - 'TanStack/db:docs/guides/live-queries.md'
22
+ - 'TanStack/db:packages/octane-db/src/useLiveQuery.ts'
23
+ - 'TanStack/db:packages/octane-db/src/useLiveInfiniteQuery.ts'
24
+ - 'TanStack/db:packages/octane-db/src/useLiveQueryEffect.ts'
25
+ ---
26
+
27
+ This skill builds on db-core. Read it first for collection setup, query builder, and mutation patterns.
28
+
29
+ # TanStack DB — Octane
30
+
31
+ ## Setup
32
+
33
+ ```tsx
34
+ import { useLiveQuery, eq, not } from '@octanejs/tanstack-db'
35
+
36
+ function TodoList() {
37
+ const { data: todos, isLoading } = useLiveQuery((q) =>
38
+ q
39
+ .from({ todo: todoCollection })
40
+ .where(({ todo }) => not(todo.completed))
41
+ .orderBy(({ todo }) => todo.created_at, 'asc'),
42
+ )
43
+
44
+ if (isLoading) return <div>Loading...</div>
45
+
46
+ return (
47
+ <ul>
48
+ {todos.map((todo) => (
49
+ <li key={todo.id}>{todo.text}</li>
50
+ ))}
51
+ </ul>
52
+ )
53
+ }
54
+ ```
55
+
56
+ `@octanejs/tanstack-db` re-exports everything from `@tanstack/db`. In Octane projects, import everything from `@octanejs/tanstack-db`.
57
+
58
+ ## Hooks
59
+
60
+ ### useLiveQuery
61
+
62
+ ```tsx
63
+ // Query function with dependency array
64
+ const {
65
+ data,
66
+ state,
67
+ collection,
68
+ status,
69
+ isLoading,
70
+ isReady,
71
+ isError,
72
+ isIdle,
73
+ isCleanedUp,
74
+ } = useLiveQuery(
75
+ (q) =>
76
+ q
77
+ .from({ todo: todoCollection })
78
+ .where(({ todo }) => eq(todo.userId, userId)),
79
+ [userId],
80
+ )
81
+
82
+ // Config object
83
+ const { data } = useLiveQuery({
84
+ query: (q) => q.from({ todo: todoCollection }),
85
+ gcTime: 60000,
86
+ })
87
+
88
+ // Pre-created collection (from route loader)
89
+ const { data } = useLiveQuery(preloadedCollection)
90
+
91
+ // Conditional query — return undefined/null to disable
92
+ const { data, status } = useLiveQuery(
93
+ (q) => {
94
+ if (!userId) return undefined
95
+ return q
96
+ .from({ todo: todoCollection })
97
+ .where(({ todo }) => eq(todo.userId, userId))
98
+ },
99
+ [userId],
100
+ )
101
+ // When disabled: status='disabled', data=undefined
102
+ ```
103
+
104
+ ### useLiveSuspenseQuery
105
+
106
+ ```tsx
107
+ // data is ALWAYS defined — never undefined
108
+ // Must wrap in <Suspense> and <ErrorBoundary>
109
+ function TodoList() {
110
+ const { data: todos } = useLiveSuspenseQuery((q) =>
111
+ q.from({ todo: todoCollection }),
112
+ )
113
+
114
+ return (
115
+ <ul>
116
+ {todos.map((t) => (
117
+ <li key={t.id}>{t.text}</li>
118
+ ))}
119
+ </ul>
120
+ )
121
+ }
122
+
123
+ // With deps — re-suspends when deps change
124
+ const { data } = useLiveSuspenseQuery(
125
+ (q) =>
126
+ q
127
+ .from({ todo: todoCollection })
128
+ .where(({ todo }) => eq(todo.category, category)),
129
+ [category],
130
+ )
131
+ ```
132
+
133
+ ### useLiveInfiniteQuery
134
+
135
+ ```tsx
136
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
137
+ useLiveInfiniteQuery(
138
+ (q) =>
139
+ q
140
+ .from({ posts: postsCollection })
141
+ .where(({ posts }) => eq(posts.category, category))
142
+ .orderBy(({ posts }) => posts.createdAt, 'desc'),
143
+ { pageSize: 20 },
144
+ [category],
145
+ )
146
+
147
+ // data is the flat array of all loaded pages
148
+ // fetchNextPage() loads the next page
149
+ // hasNextPage is true when more data is available
150
+ ```
151
+
152
+ ### usePacedMutations
153
+
154
+ ```tsx
155
+ import { usePacedMutations, debounceStrategy } from "@octanejs/tanstack-db"
156
+
157
+ const mutate = usePacedMutations({
158
+ onMutate: (value: string) => {
159
+ noteCollection.update(noteId, (draft) => {
160
+ draft.content = value
161
+ })
162
+ },
163
+ mutationFn: async ({ transaction }) => {
164
+ await api.notes.update(noteId, transaction.mutations[0].changes)
165
+ },
166
+ strategy: debounceStrategy({ wait: 500 }),
167
+ })
168
+
169
+ // In handler:
170
+ <textarea onChange={(e) => mutate(e.target.value)} />
171
+ ```
172
+
173
+ ### useLiveQueryEffect
174
+
175
+ ```tsx
176
+ import { useLiveQueryEffect, eq } from '@octanejs/tanstack-db'
177
+
178
+ // Fire side effects when rows enter, exit, or update a query result.
179
+ // No render output — the effect is created on mount, disposed on unmount,
180
+ // and recreated when deps change.
181
+ function ChatComponent() {
182
+ useLiveQueryEffect(
183
+ {
184
+ query: (q) =>
185
+ q.from({ msg: messages }).where(({ msg }) => eq(msg.role, 'user')),
186
+ skipInitial: true,
187
+ onEnter: async (event) => {
188
+ await generateResponse(event.value)
189
+ },
190
+ },
191
+ [],
192
+ )
193
+
194
+ return <div>...</div>
195
+ }
196
+ ```
197
+
198
+ ## Includes (Hierarchical Data)
199
+
200
+ When a query uses includes (subqueries in `select`), each child field is a live `Collection` by default. Subscribe to it with `useLiveQuery` in a subcomponent:
201
+
202
+ ```tsx
203
+ function ProjectList() {
204
+ const { data: projects } = useLiveQuery((q) =>
205
+ q.from({ p: projectsCollection }).select(({ p }) => ({
206
+ id: p.id,
207
+ name: p.name,
208
+ issues: q
209
+ .from({ i: issuesCollection })
210
+ .where(({ i }) => eq(i.projectId, p.id))
211
+ .select(({ i }) => ({ id: i.id, title: i.title })),
212
+ })),
213
+ )
214
+
215
+ return (
216
+ <ul>
217
+ {projects.map((project) => (
218
+ <li key={project.id}>
219
+ {project.name}
220
+ <IssueList issuesCollection={project.issues} />
221
+ </li>
222
+ ))}
223
+ </ul>
224
+ )
225
+ }
226
+
227
+ // Child component subscribes to the child Collection
228
+ function IssueList({ issuesCollection }) {
229
+ const { data: issues } = useLiveQuery(issuesCollection)
230
+ return (
231
+ <ul>
232
+ {issues.map((issue) => (
233
+ <li key={issue.id}>{issue.title}</li>
234
+ ))}
235
+ </ul>
236
+ )
237
+ }
238
+ ```
239
+
240
+ Only the affected `IssueList` re-renders when an issue changes — the parent does not.
241
+
242
+ With `toArray()`, child results are plain arrays and the parent re-renders on child changes:
243
+
244
+ ```tsx
245
+ import { toArray, eq } from '@octanejs/tanstack-db'
246
+
247
+ const { data: projects } = useLiveQuery((q) =>
248
+ q.from({ p: projectsCollection }).select(({ p }) => ({
249
+ id: p.id,
250
+ name: p.name,
251
+ issues: toArray(
252
+ q
253
+ .from({ i: issuesCollection })
254
+ .where(({ i }) => eq(i.projectId, p.id))
255
+ .select(({ i }) => ({ id: i.id, title: i.title })),
256
+ ),
257
+ })),
258
+ )
259
+ // project.issues is Array<{ id, title }> — no subcomponent needed
260
+ ```
261
+
262
+ See db-core/live-queries/SKILL.md for full includes rules (correlation conditions, nested includes, aggregates).
263
+
264
+ ## Virtual Properties
265
+
266
+ Live query results include computed, read-only virtual properties on every row:
267
+
268
+ - `$synced`: `true` when the row is confirmed by sync; `false` when it is still optimistic.
269
+ - `$origin`: `"local"` if the last confirmed change came from this client, otherwise `"remote"`.
270
+ - `$key`: the row key for the result.
271
+ - `$collectionId`: the source collection ID.
272
+
273
+ These props are added automatically and can be used in `where`, `select`, and `orderBy` clauses. Do not persist them back to storage.
274
+
275
+ ```tsx
276
+ const { data } = useLiveQuery(
277
+ (q) =>
278
+ q
279
+ .from({ todo: todoCollection })
280
+ .where(({ todo }) => eq(todo.$synced, false)),
281
+ [],
282
+ )
283
+ // Shows only optimistic (unconfirmed) todos
284
+ ```
285
+
286
+ ## Octane-Specific Patterns
287
+
288
+ ### Dependency arrays
289
+
290
+ ```tsx
291
+ // Include ALL external reactive values
292
+ const { data } = useLiveQuery(
293
+ (q) =>
294
+ q
295
+ .from({ todo: todoCollection })
296
+ .where(({ todo }) =>
297
+ and(eq(todo.userId, userId), eq(todo.status, filter)),
298
+ ),
299
+ [userId, filter],
300
+ )
301
+
302
+ // Empty array = static query, never re-runs
303
+ const { data } = useLiveQuery((q) => q.from({ todo: todoCollection }), [])
304
+
305
+ // No array = re-runs on every render (usually wrong)
306
+ ```
307
+
308
+ ### Suspense + Error Boundary
309
+
310
+ ```tsx
311
+ <ErrorBoundary fallback={<div>Error</div>}>
312
+ <Suspense fallback={<div>Loading...</div>}>
313
+ <TodoList />
314
+ </Suspense>
315
+ </ErrorBoundary>
316
+ ```
317
+
318
+ ### Router loader preloading
319
+
320
+ ```tsx
321
+ // In route loader:
322
+ await todoCollection.preload()
323
+
324
+ // In component — data available immediately:
325
+ const { data } = useLiveQuery((q) => q.from({ todo: todoCollection }))
326
+ ```
327
+
328
+ See meta-framework/SKILL.md for full preloading patterns.
329
+
330
+ ## Common Mistakes
331
+
332
+ ### CRITICAL Missing external values in dependency array
333
+
334
+ Wrong:
335
+
336
+ ```tsx
337
+ const { data } = useLiveQuery((q) =>
338
+ q.from({ todo: todoCollection }).where(({ todo }) => eq(todo.userId, userId)),
339
+ )
340
+ ```
341
+
342
+ Correct:
343
+
344
+ ```tsx
345
+ const { data } = useLiveQuery(
346
+ (q) =>
347
+ q
348
+ .from({ todo: todoCollection })
349
+ .where(({ todo }) => eq(todo.userId, userId)),
350
+ [userId],
351
+ )
352
+ ```
353
+
354
+ When the query uses external state not in the deps array, the query won't re-run when that value changes, showing stale results.
355
+
356
+ Source: docs/framework/octane/overview.md
357
+
358
+ ### HIGH useLiveSuspenseQuery without Error Boundary
359
+
360
+ Wrong:
361
+
362
+ ```tsx
363
+ <Suspense fallback={<div>Loading...</div>}>
364
+ <TodoList /> {/* uses useLiveSuspenseQuery */}
365
+ </Suspense>
366
+ ```
367
+
368
+ Correct:
369
+
370
+ ```tsx
371
+ <ErrorBoundary fallback={<div>Error</div>}>
372
+ <Suspense fallback={<div>Loading...</div>}>
373
+ <TodoList />
374
+ </Suspense>
375
+ </ErrorBoundary>
376
+ ```
377
+
378
+ `useLiveSuspenseQuery` throws errors during rendering. Without an Error Boundary, the entire app crashes.
379
+
380
+ Source: docs/guides/live-queries.md
381
+
382
+ ### HIGH "Not a Collection" error from duplicate @tanstack/db
383
+
384
+ If `useLiveQuery` throws `InvalidSourceError: The value provided for alias "todo" is not a Collection`, it usually means two copies of `@tanstack/db` are installed. The collection was created by one copy, but `useLiveQuery` checks `instanceof` against the other.
385
+
386
+ In dev mode, TanStack DB also throws `DuplicateDbInstanceError` if two instances are detected.
387
+
388
+ **Diagnose:**
389
+
390
+ ```bash
391
+ pnpm ls @tanstack/db
392
+ ```
393
+
394
+ If multiple versions appear, fix with one of:
395
+
396
+ **pnpm overrides** (in root package.json):
397
+
398
+ ```json
399
+ {
400
+ "pnpm": {
401
+ "overrides": {
402
+ "@tanstack/db": "^0.6.0"
403
+ }
404
+ }
405
+ }
406
+ ```
407
+
408
+ **Vite resolve.alias** (in vite.config.ts):
409
+
410
+ ```ts
411
+ import path from 'path'
412
+
413
+ export default defineConfig({
414
+ resolve: {
415
+ alias: {
416
+ '@tanstack/db': path.resolve('./node_modules/@tanstack/db'),
417
+ },
418
+ },
419
+ })
420
+ ```
421
+
422
+ The root cause is typically a dependency that bundles its own copy instead of declaring `@tanstack/db` as a `peerDependency`.
423
+
424
+ ### HIGH Tension: Query expressiveness vs. IVM constraints
425
+
426
+ The query builder looks like SQL but has constraints that SQL doesn't — equality joins only, orderBy required for limit/offset, no distinct without select. Agents write SQL-style queries that violate these constraints. See db-core/live-queries/SKILL.md § Common Mistakes for all constraints.
427
+
428
+ See also: db-core/live-queries/SKILL.md — for query builder API and all operators.
429
+
430
+ See also: db-core/mutations-optimistic/SKILL.md — for mutation patterns.
431
+
432
+ See also: meta-framework/SKILL.md — for preloading in route loaders.
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ // Re-export all public APIs
2
+ export * from './useLiveQuery';
3
+ export * from './useLiveSuspenseQuery';
4
+ export * from './usePacedMutations';
5
+ export * from './useLiveInfiniteQuery';
6
+ export * from './useLiveQueryEffect';
7
+
8
+ // Re-export everything from @tanstack/db
9
+ export * from '@tanstack/db';
10
+
11
+ // Re-export some stuff explicitly to ensure the type & value is exported
12
+ export type { Collection } from '@tanstack/db';
13
+ export { createTransaction } from '@tanstack/db';
package/src/slot.ts ADDED
@@ -0,0 +1,26 @@
1
+ const subSlotCache = new Map<symbol, Map<string, symbol>>();
2
+
3
+ /**
4
+ * Derive a stable sub-slot from a wrapper's compiler-injected slot so nested
5
+ * Octane hooks inside a custom hook each get distinct call-site identity.
6
+ */
7
+ export function subSlot(slot: symbol | undefined, tag: string): symbol | undefined {
8
+ if (slot === undefined) return undefined;
9
+ let byTag = subSlotCache.get(slot);
10
+ if (byTag === undefined) subSlotCache.set(slot, (byTag = new Map()));
11
+ let sym = byTag.get(tag);
12
+ if (sym === undefined) {
13
+ sym = Symbol.for(`${slot.description ?? ``}:${tag}`);
14
+ byTag.set(tag, sym);
15
+ }
16
+ return sym;
17
+ }
18
+
19
+ /** Split a compiler-injected trailing slot symbol off hook runtime args. */
20
+ export function splitTrailingSlot<T extends Array<unknown>>(
21
+ args: T,
22
+ ): [Array<unknown>, symbol | undefined] {
23
+ const tail = args[args.length - 1];
24
+ const slot = typeof tail === `symbol` ? tail : undefined;
25
+ return [slot !== undefined ? args.slice(0, -1) : args, slot];
26
+ }