@questpie/tanstack-db 3.17.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 ADDED
@@ -0,0 +1,41 @@
1
+ # @questpie/tanstack-db
2
+
3
+ Typed TanStack DB collections backed by a QUESTPIE client.
4
+
5
+ ## Per-request setup
6
+
7
+ TanStack Start and other SSR applications must create both the QueryClient and
8
+ the QUESTPIE collections once per request:
9
+
10
+ ```ts
11
+ import { createQuestpieCollections } from "@questpie/tanstack-db";
12
+ import { QueryClient } from "@tanstack/react-query";
13
+ import type { QuestpieClient } from "questpie/client";
14
+
15
+ import type { AppConfig } from "#questpie";
16
+
17
+ export function createRequestDb(client: QuestpieClient<AppConfig>) {
18
+ const queryClient = new QueryClient();
19
+ const db = createQuestpieCollections(client, { queryClient });
20
+
21
+ return { db, queryClient };
22
+ }
23
+ ```
24
+
25
+ Call `db.destroy()` when the request or browser application is torn down. A
26
+ module-scope registry is safe only in the browser; on the server it would leak
27
+ one request's optimistic overlay into another request.
28
+
29
+ Use `syncMode: "snapshot"` to keep the store replaced from QUESTPIE `.live()`
30
+ snapshots. The default is `"refetch"` and uses TanStack Query's authoritative
31
+ refetch after a successful mutation. Successful optimistic mutations are folded
32
+ into the current authoritative snapshot until the next live snapshot arrives.
33
+
34
+ The `find` registry option accepts only row-shape-preserving filters, ordering,
35
+ pagination, locale, stage, and soft-delete controls. Projections (`columns`),
36
+ relation expansion (`with`), extras, and grouping are rejected because every
37
+ TanStack DB row must retain the base collection shape and its canonical `id`.
38
+
39
+ The package currently supports refetch and full-snapshot synchronization. Native
40
+ row-delta synchronization is added independently without changing the collection
41
+ factory API.
@@ -0,0 +1,36 @@
1
+ import { Collection, and, eq, gt, inArray, lt, or } from "@tanstack/db";
2
+ import { useLiveQuery } from "@tanstack/react-db";
3
+ import { QueryClient, QueryKey } from "@tanstack/react-query";
4
+ import { ApplyQuery, CollectionRelations, CollectionSelectFromApp, FindManyOptions, GetCollection, QuestpieApp, QuestpieClient, ResolveRelationsDeep } from "questpie/client";
5
+
6
+ //#region src/sync.d.ts
7
+ type QuestpieDbSyncMode = "refetch" | "snapshot";
8
+ //#endregion
9
+ //#region src/types.d.ts
10
+ type CollectionKeys<TApp extends QuestpieApp> = Extract<keyof QuestpieClient<TApp>["collections"], string>;
11
+ type CollectionSelectOf<TApp extends QuestpieApp, K$1 extends CollectionKeys<TApp>> = CollectionSelectFromApp<GetCollection<TApp["collections"], K$1>, {
12
+ collections: TApp["collections"];
13
+ }>;
14
+ type CollectionRelationsOf<TApp extends QuestpieApp, K$1 extends CollectionKeys<TApp>> = ResolveRelationsDeep<CollectionRelations<GetCollection<TApp["collections"], K$1>>, TApp["collections"]>;
15
+ type FindOptionsOf<TApp extends QuestpieApp, K$1 extends CollectionKeys<TApp>> = Pick<FindManyOptions<CollectionSelectOf<TApp, K$1>, CollectionRelationsOf<TApp, K$1>>, "where" | "orderBy" | "limit" | "offset" | "search" | "locale" | "localeFallback" | "includeDeleted" | "stage">;
16
+ type CollectionRowOf<TApp extends QuestpieApp, K$1 extends CollectionKeys<TApp>> = ApplyQuery<CollectionSelectOf<TApp, K$1>, CollectionRelationsOf<TApp, K$1>, undefined>;
17
+ type IdOf<TRow> = TRow extends {
18
+ id: infer TId;
19
+ } ? Extract<TId, string> : never;
20
+ type QuestpieCollections<TApp extends QuestpieApp> = { [K in CollectionKeys<TApp>]: Collection<CollectionRowOf<TApp, K>, IdOf<CollectionRowOf<TApp, K>>> };
21
+ type QuestpieDb<TApp extends QuestpieApp> = {
22
+ collections: QuestpieCollections<TApp>;
23
+ destroy: () => void;
24
+ };
25
+ //#endregion
26
+ //#region src/factory.d.ts
27
+ type QuestpieFindOptions<TApp extends QuestpieApp> = Partial<{ [K in CollectionKeys<TApp>]: FindOptionsOf<TApp, K> }>;
28
+ type CreateQuestpieCollectionsOptions<TApp extends QuestpieApp> = {
29
+ queryClient: QueryClient;
30
+ syncMode?: QuestpieDbSyncMode;
31
+ find?: QuestpieFindOptions<TApp>;
32
+ keyPrefix?: QueryKey;
33
+ };
34
+ declare function createQuestpieCollections<TApp extends QuestpieApp>(client: QuestpieClient<TApp>, options: CreateQuestpieCollectionsOptions<TApp>): QuestpieDb<TApp>;
35
+ //#endregion
36
+ export { type CollectionKeys, type CollectionRelationsOf, type CollectionRowOf, type CollectionSelectOf, type CreateQuestpieCollectionsOptions, type FindOptionsOf, type IdOf, type QuestpieCollections, type QuestpieDb, type QuestpieDbSyncMode, type QuestpieFindOptions, and, createQuestpieCollections, eq, gt, inArray, lt, or, useLiveQuery };
package/dist/index.mjs ADDED
@@ -0,0 +1,233 @@
1
+ import { and, createCollection, deepEquals, eq, gt, inArray, lt, or } from "@tanstack/db";
2
+ import { queryCollectionOptions } from "@tanstack/query-db-collection";
3
+ import { useLiveQuery } from "@tanstack/react-db";
4
+
5
+ //#region src/sync.ts
6
+ function resolveSync(options) {
7
+ const { client, findOptions, mode, queryClient, queryKey, onDispose } = options;
8
+ if (mode === "refetch") return {
9
+ queryFn: async () => (await client.find(findOptions)).docs,
10
+ getSnapshotRevision: () => 0,
11
+ reconcileMutation: async () => {}
12
+ };
13
+ let latest;
14
+ let initial;
15
+ let snapshotRevision = 0;
16
+ const setLatest = (rows) => {
17
+ latest = rows;
18
+ queryClient.setQueryData(queryKey, latest);
19
+ };
20
+ const acceptSnapshot = (rows) => {
21
+ snapshotRevision += 1;
22
+ setLatest(rows);
23
+ };
24
+ return {
25
+ getSnapshotRevision: () => snapshotRevision,
26
+ reconcileMutation: async (revision, update, fallback) => {
27
+ if (revision === snapshotRevision) {
28
+ setLatest(update(latest ?? queryClient.getQueryData(queryKey) ?? []));
29
+ return;
30
+ }
31
+ const refreshRevision = snapshotRevision;
32
+ try {
33
+ const refreshed = await client.find(findOptions);
34
+ if (refreshRevision === snapshotRevision) acceptSnapshot(refreshed.docs);
35
+ } catch {
36
+ if (refreshRevision === snapshotRevision) setLatest(fallback(latest ?? queryClient.getQueryData(queryKey) ?? []));
37
+ }
38
+ },
39
+ queryFn: () => {
40
+ if (latest) return Promise.resolve(latest);
41
+ if (initial) return initial;
42
+ initial = new Promise((resolve, reject) => {
43
+ let settled = false;
44
+ let dispose = () => {};
45
+ let unregisterDispose = () => {};
46
+ let failedSynchronously = false;
47
+ dispose = client.live(findOptions, (snapshot) => {
48
+ acceptSnapshot(snapshot.docs);
49
+ if (!settled) {
50
+ settled = true;
51
+ resolve(snapshot.docs);
52
+ }
53
+ }, { onError: (error) => {
54
+ if (!settled) {
55
+ settled = true;
56
+ failedSynchronously = true;
57
+ dispose();
58
+ unregisterDispose();
59
+ reject(error);
60
+ }
61
+ } });
62
+ if (failedSynchronously) dispose();
63
+ else unregisterDispose = onDispose(dispose) ?? (() => {});
64
+ }).catch((error) => {
65
+ initial = void 0;
66
+ throw error;
67
+ });
68
+ return initial;
69
+ }
70
+ };
71
+ }
72
+
73
+ //#endregion
74
+ //#region src/collection.ts
75
+ function rowId(row) {
76
+ if (typeof row.id !== "string" || row.id.length === 0) throw new Error("QUESTPIE TanStack DB rows require a non-empty string id");
77
+ return row.id;
78
+ }
79
+ function persistedRow(result, fallback) {
80
+ return result && typeof result === "object" && "id" in result ? result : fallback;
81
+ }
82
+ function createQuestpieCollection(options) {
83
+ const { client, name, queryClient, queryKey, findOptions, syncMode, onDispose } = options;
84
+ const { getSnapshotRevision, queryFn, reconcileMutation } = resolveSync({
85
+ client,
86
+ findOptions,
87
+ mode: syncMode,
88
+ queryClient,
89
+ queryKey,
90
+ onDispose
91
+ });
92
+ const skipRefetch = syncMode === "snapshot" ? { refetch: false } : void 0;
93
+ return createCollection(queryCollectionOptions({
94
+ id: `questpie:${name}`,
95
+ queryClient,
96
+ queryKey,
97
+ queryFn,
98
+ getKey: rowId,
99
+ onInsert: async ({ transaction }) => {
100
+ const snapshotRevision = getSnapshotRevision();
101
+ const persisted = await Promise.all(transaction.mutations.map((mutation) => client.create(mutation.modified)));
102
+ await reconcileMutation(snapshotRevision, (rows) => {
103
+ const next = [...rows];
104
+ for (let index = 0; index < transaction.mutations.length; index++) {
105
+ const optimistic = transaction.mutations[index].modified;
106
+ const row = persistedRow(persisted[index], optimistic);
107
+ const existing = next.findIndex((item) => item.id === row.id);
108
+ if (existing === -1) next.push(row);
109
+ else next[existing] = row;
110
+ }
111
+ return next;
112
+ }, (rows) => {
113
+ const next = [...rows];
114
+ for (let index = 0; index < transaction.mutations.length; index++) {
115
+ const optimistic = transaction.mutations[index].modified;
116
+ const row = persistedRow(persisted[index], optimistic);
117
+ if (!next.some((item) => item.id === row.id)) next.push(row);
118
+ }
119
+ return next;
120
+ });
121
+ return skipRefetch;
122
+ },
123
+ onUpdate: async ({ transaction }) => {
124
+ const snapshotRevision = getSnapshotRevision();
125
+ const persisted = await Promise.all(transaction.mutations.map((mutation) => client.update({
126
+ id: String(mutation.key),
127
+ data: mutation.changes
128
+ })));
129
+ await reconcileMutation(snapshotRevision, (rows) => {
130
+ const byId = new Map(rows.map((row) => [row.id, row]));
131
+ for (let index = 0; index < transaction.mutations.length; index++) {
132
+ const mutation = transaction.mutations[index];
133
+ const current = byId.get(String(mutation.key));
134
+ if (!current) continue;
135
+ byId.set(String(mutation.key), persistedRow(persisted[index], {
136
+ ...current,
137
+ ...mutation.changes
138
+ }));
139
+ }
140
+ return rows.map((row) => byId.get(row.id) ?? row);
141
+ }, (rows) => {
142
+ const byId = new Map(rows.map((row) => [row.id, row]));
143
+ for (let index = 0; index < transaction.mutations.length; index++) {
144
+ const mutation = transaction.mutations[index];
145
+ const current = byId.get(String(mutation.key));
146
+ if (!current) continue;
147
+ const original = mutation.original;
148
+ const result = persistedRow(persisted[index], {
149
+ ...original,
150
+ ...mutation.changes
151
+ });
152
+ const merged = { ...current };
153
+ for (const key of Object.keys(result)) if (deepEquals(current[key], original[key])) merged[key] = result[key];
154
+ byId.set(String(mutation.key), merged);
155
+ }
156
+ return rows.map((row) => byId.get(row.id) ?? row);
157
+ });
158
+ return skipRefetch;
159
+ },
160
+ onDelete: async ({ transaction }) => {
161
+ const snapshotRevision = getSnapshotRevision();
162
+ await Promise.all(transaction.mutations.map((mutation) => client.delete({ id: String(mutation.key) })));
163
+ const deleted = new Set(transaction.mutations.map((mutation) => String(mutation.key)));
164
+ await reconcileMutation(snapshotRevision, (rows) => rows.filter((row) => !deleted.has(row.id)), (rows) => {
165
+ const originals = new Map(transaction.mutations.map((mutation) => [String(mutation.key), mutation.original]));
166
+ return rows.filter((row) => {
167
+ const original = originals.get(row.id);
168
+ return !original || !deepEquals(row, original);
169
+ });
170
+ });
171
+ return skipRefetch;
172
+ }
173
+ }));
174
+ }
175
+
176
+ //#endregion
177
+ //#region src/factory.ts
178
+ const SHAPE_CHANGING_FIND_OPTIONS = [
179
+ "columns",
180
+ "with",
181
+ "extras",
182
+ "groupBy"
183
+ ];
184
+ function assertBaseRowFindOptions(options) {
185
+ if (!options || typeof options !== "object" || Array.isArray(options)) return;
186
+ for (const key of SHAPE_CHANGING_FIND_OPTIONS) if (key in options) throw new Error(`QUESTPIE TanStack DB find.${key} is not supported because collections require base rows with an id`);
187
+ }
188
+ function createQuestpieCollections(client, options) {
189
+ const cache = /* @__PURE__ */ new Map();
190
+ const disposers = /* @__PURE__ */ new Set();
191
+ const syncMode = options.syncMode ?? "refetch";
192
+ let destroyed = false;
193
+ return {
194
+ collections: new Proxy(Object.create(null), { get(_target, property) {
195
+ if (typeof property !== "string") return void 0;
196
+ if (destroyed) throw new Error("QUESTPIE TanStack DB registry has been destroyed");
197
+ const cached = cache.get(property);
198
+ if (cached) return cached;
199
+ const collectionClient = client.collections[property];
200
+ if (!collectionClient) return void 0;
201
+ const findOptions = options.find?.[property];
202
+ assertBaseRowFindOptions(findOptions);
203
+ const created = createQuestpieCollection({
204
+ client: collectionClient,
205
+ name: property,
206
+ queryClient: options.queryClient,
207
+ queryKey: [
208
+ ...options.keyPrefix ?? ["questpie", "tanstack-db"],
209
+ property,
210
+ findOptions
211
+ ],
212
+ findOptions,
213
+ syncMode,
214
+ onDispose: (dispose) => {
215
+ disposers.add(dispose);
216
+ return () => disposers.delete(dispose);
217
+ }
218
+ });
219
+ cache.set(property, created);
220
+ return created;
221
+ } }),
222
+ destroy() {
223
+ if (destroyed) return;
224
+ destroyed = true;
225
+ for (const dispose of disposers) dispose();
226
+ disposers.clear();
227
+ cache.clear();
228
+ }
229
+ };
230
+ }
231
+
232
+ //#endregion
233
+ export { and, createQuestpieCollections, eq, gt, inArray, lt, or, useLiveQuery };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@questpie/tanstack-db",
3
+ "version": "3.17.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/questpie/questpie.git",
7
+ "directory": "packages/tanstack-db"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "type": "module",
13
+ "sideEffects": false,
14
+ "main": "./dist/index.mjs",
15
+ "module": "./dist/index.mjs",
16
+ "types": "./dist/index.d.mts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.mts",
20
+ "default": "./dist/index.mjs"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "publishConfig": {
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.mts",
28
+ "default": "./dist/index.mjs"
29
+ },
30
+ "./package.json": "./package.json"
31
+ }
32
+ },
33
+ "scripts": {
34
+ "build": "tsdown",
35
+ "check-types": "tsc --noEmit",
36
+ "test": "bun test"
37
+ },
38
+ "devDependencies": {
39
+ "@tanstack/db": "0.6.16",
40
+ "@tanstack/query-db-collection": "1.1.0",
41
+ "@tanstack/react-db": "0.1.94",
42
+ "@tanstack/react-query": "^5.0.0",
43
+ "bun-types": "latest",
44
+ "tsdown": "^0.18.3"
45
+ },
46
+ "peerDependencies": {
47
+ "@tanstack/db": "0.6.16",
48
+ "@tanstack/query-db-collection": "1.1.0",
49
+ "@tanstack/react-db": "0.1.94",
50
+ "@tanstack/react-query": "^5.0.0",
51
+ "questpie": "^3.17.0"
52
+ }
53
+ }