@tanstack/svelte-db 0.1.93 → 0.2.0

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/README.md CHANGED
@@ -1,3 +1,20 @@
1
+ <div align="center">
2
+ <picture>
3
+ <source
4
+ media="(prefers-color-scheme: dark)"
5
+ srcset="https://tanstack.com/api/readme/db.png?framework=svelte&theme=dark"
6
+ />
7
+ <source
8
+ media="(prefers-color-scheme: light)"
9
+ srcset="https://tanstack.com/api/readme/db.png?framework=svelte"
10
+ />
11
+ <img
12
+ src="https://tanstack.com/api/readme/db.png?framework=svelte"
13
+ alt="TanStack Svelte DB"
14
+ width="900"
15
+ />
16
+ </picture>
17
+ </div>
1
18
  # @tanstack/svelte-db
2
19
 
3
20
  Svelte helpers for TanStack DB. See [TanStack/db](https://github.com/TanStack/db) for more details.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './useLiveQuery.svelte.js';
2
+ export * from './useLiveInfiniteQuery.svelte.js';
2
3
  export * from '@tanstack/db';
3
4
  export type { Collection } from '@tanstack/db';
4
5
  export { createTransaction } from '@tanstack/db';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Re-export all public APIs
2
2
  export * from './useLiveQuery.svelte.js';
3
+ export * from './useLiveInfiniteQuery.svelte.js';
3
4
  // Re-export everything from @tanstack/db
4
5
  export * from '@tanstack/db';
5
6
  export { createTransaction } from '@tanstack/db';
@@ -0,0 +1,40 @@
1
+ import type { UseLiveQueryReturnWithCollection, useLiveQuery } from './useLiveQuery.svelte.js';
2
+ import type { Collection, Context, InferResultType, InitialQueryBuilder, NonSingleResult, QueryBuilder } from '@tanstack/db';
3
+ type MaybeGetter<T> = T | (() => T);
4
+ type InfiniteQueryOptions = {
5
+ pageSize?: number;
6
+ initialPageParam?: number;
7
+ };
8
+ export type LiveInfiniteQueryConfig<TRow> = InfiniteQueryOptions & {
9
+ /**
10
+ * @deprecated Pagination uses the shared controller's peek-ahead strategy.
11
+ * This remains for compatibility with TanStack Query conventions.
12
+ */
13
+ getNextPageParam?: (lastPage: Array<TRow>, allPages: Array<Array<TRow>>, lastPageParam: number, allPageParams: Array<number>) => number | undefined;
14
+ };
15
+ export type UseLiveInfiniteQueryConfig<TContext extends Context> = LiveInfiniteQueryConfig<InferResultType<TContext>[number]>;
16
+ export type UseLiveInfiniteQueryReturn<TContext extends Context> = Omit<ReturnType<typeof useLiveQuery<TContext>>, `data`> & {
17
+ data: InferResultType<TContext>;
18
+ pages: Array<Array<InferResultType<TContext>[number]>>;
19
+ pageParams: Array<number>;
20
+ fetchNextPage: () => Promise<void>;
21
+ hasNextPage: boolean;
22
+ isFetchingNextPage: boolean;
23
+ error: unknown;
24
+ };
25
+ export type UseLiveInfiniteQueryReturnWithCollection<TResult extends object, TKey extends string | number, TUtils extends Record<string, any>> = Omit<UseLiveQueryReturnWithCollection<TResult, TKey, TUtils, Array<TResult>>, `data`> & {
26
+ data: Array<TResult>;
27
+ pages: Array<Array<TResult>>;
28
+ pageParams: Array<number>;
29
+ fetchNextPage: () => Promise<void>;
30
+ hasNextPage: boolean;
31
+ isFetchingNextPage: boolean;
32
+ error: unknown;
33
+ };
34
+ /**
35
+ * Create a Svelte-native reactive view over the shared live-query window
36
+ * controller. The query must include an `orderBy` clause.
37
+ */
38
+ export declare function useLiveInfiniteQuery<TResult extends object, TKey extends string | number, TUtils extends Record<string, any>>(liveQueryCollection: MaybeGetter<Collection<TResult, TKey, TUtils> & NonSingleResult>, config: LiveInfiniteQueryConfig<TResult>): UseLiveInfiniteQueryReturnWithCollection<TResult, TKey, TUtils>;
39
+ export declare function useLiveInfiniteQuery<TContext extends Context>(queryFn: (q: InitialQueryBuilder) => QueryBuilder<TContext>, config: UseLiveInfiniteQueryConfig<TContext>, deps?: Array<() => unknown>): UseLiveInfiniteQueryReturn<TContext>;
40
+ export {};
@@ -0,0 +1,143 @@
1
+ import { createLiveQueryCollection, createLiveQueryWindowController, isCollection, } from '@tanstack/db';
2
+ import { untrack } from 'svelte';
3
+ const DEFAULT_PAGE_SIZE = 20;
4
+ const DEFAULT_GC_TIME_MS = 1;
5
+ function hasSetWindow(collection) {
6
+ return typeof collection.utils.setWindow === `function`;
7
+ }
8
+ function normalizePageSize(pageSize) {
9
+ if (pageSize === undefined ||
10
+ !Number.isSafeInteger(pageSize) ||
11
+ pageSize <= 0) {
12
+ return DEFAULT_PAGE_SIZE;
13
+ }
14
+ return pageSize;
15
+ }
16
+ function resolveInput(input) {
17
+ if (isCollection(input)) {
18
+ return { kind: `collection`, collection: input };
19
+ }
20
+ if (typeof input !== `function`) {
21
+ throw new Error(`useLiveInfiniteQuery: First argument must be either a pre-created live query collection or a query function. ` +
22
+ `Received: ${typeof input}`);
23
+ }
24
+ // A zero-argument function can be a reactive collection getter. Query
25
+ // callbacks conventionally accept the builder, so avoid probing those.
26
+ if (input.length === 0) {
27
+ try {
28
+ const collection = input();
29
+ if (isCollection(collection)) {
30
+ return { kind: `collection`, collection };
31
+ }
32
+ }
33
+ catch {
34
+ // Query callbacks that close over their builder can still have arity 0.
35
+ }
36
+ }
37
+ return {
38
+ kind: `query`,
39
+ query: input,
40
+ };
41
+ }
42
+ export function useLiveInfiniteQuery(queryFnOrCollection, config, deps = []) {
43
+ let validatedCollection = null;
44
+ const pageSize = $derived(normalizePageSize(config.pageSize));
45
+ const initialPageParam = $derived(config.initialPageParam ?? 0);
46
+ const controller = $derived.by(() => {
47
+ for (const dependency of deps)
48
+ dependency();
49
+ const input = resolveInput(queryFnOrCollection);
50
+ if (input.kind === `collection`) {
51
+ const collection = input.collection;
52
+ if (!hasSetWindow(collection)) {
53
+ throw new Error(`useLiveInfiniteQuery: Pre-created live query collection must have an orderBy clause for infinite pagination to work. ` +
54
+ `Please add .orderBy() to your createLiveQueryCollection query.`);
55
+ }
56
+ if (validatedCollection !== collection) {
57
+ validatedCollection = collection;
58
+ const currentWindow = collection.utils.getWindow?.();
59
+ const expectedLimit = pageSize + 1;
60
+ if (currentWindow &&
61
+ (currentWindow.offset !== 0 || currentWindow.limit !== expectedLimit)) {
62
+ console.warn(`useLiveInfiniteQuery: Pre-created collection has window {offset: ${currentWindow.offset}, limit: ${currentWindow.limit}} ` +
63
+ `but the hook expects {offset: 0, limit: ${expectedLimit}}. Adjusting window now.`);
64
+ }
65
+ }
66
+ return createLiveQueryWindowController(collection, {
67
+ pageSize,
68
+ initialPageParam,
69
+ });
70
+ }
71
+ const collection = createLiveQueryCollection({
72
+ query: (q) => input
73
+ .query(q)
74
+ .limit(pageSize + 1)
75
+ .offset(0),
76
+ startSync: false,
77
+ gcTime: DEFAULT_GC_TIME_MS,
78
+ });
79
+ return createLiveQueryWindowController(collection, {
80
+ pageSize,
81
+ initialPageParam,
82
+ });
83
+ });
84
+ let snapshot = $state.raw(untrack(() => controller.getSnapshot()));
85
+ $effect(() => {
86
+ const currentController = controller;
87
+ snapshot = currentController.getSnapshot();
88
+ const unsubscribe = currentController.subscribe(() => {
89
+ snapshot = currentController.getSnapshot();
90
+ });
91
+ snapshot = currentController.getSnapshot();
92
+ return () => {
93
+ unsubscribe();
94
+ currentController.dispose();
95
+ };
96
+ });
97
+ const fetchNextPage = () => controller.fetchNextPage();
98
+ return {
99
+ get state() {
100
+ return snapshot.state;
101
+ },
102
+ get data() {
103
+ return snapshot.data;
104
+ },
105
+ get collection() {
106
+ return snapshot.collection;
107
+ },
108
+ get status() {
109
+ return snapshot.status;
110
+ },
111
+ get isLoading() {
112
+ return snapshot.isLoading;
113
+ },
114
+ get isReady() {
115
+ return snapshot.isReady;
116
+ },
117
+ get isIdle() {
118
+ return snapshot.isIdle;
119
+ },
120
+ get isError() {
121
+ return snapshot.isError;
122
+ },
123
+ get isCleanedUp() {
124
+ return snapshot.isCleanedUp;
125
+ },
126
+ get pages() {
127
+ return snapshot.pages;
128
+ },
129
+ get pageParams() {
130
+ return snapshot.pageParams;
131
+ },
132
+ get hasNextPage() {
133
+ return snapshot.hasNextPage;
134
+ },
135
+ get isFetchingNextPage() {
136
+ return snapshot.isFetchingNextPage;
137
+ },
138
+ get error() {
139
+ return snapshot.error;
140
+ },
141
+ fetchNextPage,
142
+ };
143
+ }
@@ -2,7 +2,7 @@
2
2
  import { untrack } from 'svelte';
3
3
  // eslint-disable-next-line import/no-duplicates -- See https://github.com/un-ts/eslint-plugin-import-x/issues/308
4
4
  import { SvelteMap } from 'svelte/reactivity';
5
- import { BaseQueryBuilder, createLiveQueryCollection, isCollection, isSingleResultCollection, } from '@tanstack/db';
5
+ import { BaseQueryBuilder, createLiveQueryCollection, createLiveQueryObserver, isCollection, isSingleResultCollection, } from '@tanstack/db';
6
6
  function toValue(value) {
7
7
  if (typeof value === `function`) {
8
8
  return value();
@@ -89,11 +89,19 @@ export function useLiveQuery(configOrQueryOrCollection, deps = []) {
89
89
  internalData.push(...Array.from(currentCollection.values()));
90
90
  });
91
91
  };
92
- // Track current unsubscribe function
93
- let currentUnsubscribe = null;
92
+ // The shared observer owns subscription, the ready-race, and status; Svelte
93
+ // materializes into its own rune-backed map (granular) + ordered array.
94
+ let currentObserver = null;
95
+ const syncFromObserver = (observer, currentCollection) => {
96
+ status = observer.getSnapshot().status;
97
+ syncDataFromCollection(currentCollection);
98
+ };
94
99
  // Watch for collection changes and subscribe to updates
95
100
  $effect(() => {
96
101
  const currentCollection = collection;
102
+ // Tear down any previous observer.
103
+ currentObserver?.dispose();
104
+ currentObserver = null;
97
105
  // Handle null collection (disabled query)
98
106
  if (!currentCollection) {
99
107
  status = `disabled`;
@@ -101,68 +109,45 @@ export function useLiveQuery(configOrQueryOrCollection, deps = []) {
101
109
  state.clear();
102
110
  internalData = [];
103
111
  });
104
- if (currentUnsubscribe) {
105
- currentUnsubscribe();
106
- currentUnsubscribe = null;
107
- }
108
112
  return;
109
113
  }
110
- // Update status state whenever the effect runs
111
- status = currentCollection.status;
112
- // Clean up previous subscription
113
- if (currentUnsubscribe) {
114
- currentUnsubscribe();
115
- }
116
- // Initialize state with current collection data
117
- untrack(() => {
118
- state.clear();
119
- for (const [key, value] of currentCollection.entries()) {
120
- state.set(key, value);
121
- }
122
- });
123
- // Initialize data array in correct order
124
- syncDataFromCollection(currentCollection);
125
- // Listen for the first ready event to catch status transitions
126
- // that might not trigger change events (fixes async status transition bug)
127
- currentCollection.onFirstReady(() => {
128
- // Update status directly - Svelte's reactivity system handles the update automatically
129
- // Note: We cannot use flushSync here as it's disallowed inside effects in async mode
130
- status = currentCollection.status;
131
- });
132
- // Subscribe to collection changes with granular updates
133
- const subscription = currentCollection.subscribeChanges((changes) => {
134
- // Apply each change individually to the reactive state
114
+ const observer = createLiveQueryObserver(currentCollection);
115
+ currentObserver = observer;
116
+ // Initial rows arrive as the observer's first delta (includeInitialState);
117
+ // apply them and every subsequent delta granularly to the rune-backed map.
118
+ untrack(() => state.clear());
119
+ const unsubscribe = observer.subscribe((changes) => {
135
120
  untrack(() => {
136
- for (const change of changes) {
137
- switch (change.type) {
138
- case `insert`:
139
- case `update`:
140
- state.set(change.key, change.value);
141
- break;
142
- case `delete`:
143
- state.delete(change.key);
144
- break;
121
+ if (changes) {
122
+ for (const change of changes) {
123
+ switch (change.type) {
124
+ case `insert`:
125
+ case `update`:
126
+ state.set(change.key, change.value);
127
+ break;
128
+ case `delete`:
129
+ state.delete(change.key);
130
+ break;
131
+ }
132
+ }
133
+ }
134
+ else {
135
+ // Cleanup and other status-only publications carry no row deltas.
136
+ // Rebuild the keyed view so it cannot diverge from ordered data.
137
+ state.clear();
138
+ for (const [key, value] of observer.getSnapshot().state ?? []) {
139
+ state.set(key, value);
145
140
  }
146
141
  }
147
142
  });
148
- // Update the data array to maintain sorted order
149
- syncDataFromCollection(currentCollection);
150
- // Update status state on every change
151
- status = currentCollection.status;
152
- }, {
153
- includeInitialState: true,
143
+ syncFromObserver(observer, currentCollection);
154
144
  });
155
- currentUnsubscribe = subscription.unsubscribe.bind(subscription);
156
- // Preload collection data if not already started
157
- if (currentCollection.status === `idle`) {
158
- currentCollection.preload().catch(console.error);
159
- }
145
+ syncFromObserver(observer, currentCollection);
160
146
  // Cleanup when effect is invalidated
161
147
  return () => {
162
- if (currentUnsubscribe) {
163
- currentUnsubscribe();
164
- currentUnsubscribe = null;
165
- }
148
+ unsubscribe();
149
+ observer.dispose();
150
+ currentObserver = null;
166
151
  };
167
152
  });
168
153
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/svelte-db",
3
- "version": "0.1.93",
3
+ "version": "0.2.0",
4
4
  "description": "Svelte integration for @tanstack/db",
5
5
  "author": "Kyle Mathews",
6
6
  "license": "MIT",
@@ -35,7 +35,7 @@
35
35
  "skills"
36
36
  ],
37
37
  "dependencies": {
38
- "@tanstack/db": "0.6.16"
38
+ "@tanstack/db": "0.7.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "svelte": "^5.0.0"