@questpie/tanstack-db 3.24.0 → 3.25.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/README.md +10 -0
- package/dist/index.d.mts +40 -7
- package/dist/index.mjs +110 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -36,6 +36,16 @@ pagination, locale, stage, and soft-delete controls. Projections (`columns`),
|
|
|
36
36
|
relation expansion (`with`), extras, and grouping are rejected because every
|
|
37
37
|
TanStack DB row must retain the base collection shape and its canonical `id`.
|
|
38
38
|
|
|
39
|
+
Snapshot mode narrows that set further, to what a live subscription can carry:
|
|
40
|
+
`where`, `orderBy`, `limit`, `offset` and `locale`. `search`, `stage`,
|
|
41
|
+
`includeDeleted` and `localeFallback` are rejected there, because the live
|
|
42
|
+
snapshot would ignore them while the post-mutation repair read applied them —
|
|
43
|
+
one collection describing two different row sets.
|
|
44
|
+
|
|
45
|
+
A mutation that loses an optimistic-concurrency race rejects with
|
|
46
|
+
`QuestpieDbConflictError`. The optimistic state is already rolled back, so
|
|
47
|
+
refetch before retrying: the row still on screen carries the revision that lost.
|
|
48
|
+
|
|
39
49
|
The package currently supports refetch and full-snapshot synchronization. Native
|
|
40
50
|
row-delta synchronization is added independently without changing the collection
|
|
41
51
|
factory API.
|
package/dist/index.d.mts
CHANGED
|
@@ -1,11 +1,36 @@
|
|
|
1
1
|
import { Collection, and, eq, gt, inArray, lt, or } from "@tanstack/db";
|
|
2
2
|
import { useLiveQuery } from "@tanstack/react-db";
|
|
3
3
|
import { QueryClient, QueryKey } from "@tanstack/react-query";
|
|
4
|
-
import { ApplyQuery, CollectionRelations, CollectionSelectFromApp, FindManyOptions, GetCollection, QuestpieApp, QuestpieClient, ResolveRelationsDeep } from "questpie/client";
|
|
4
|
+
import { ApplyQuery, CollectionRelations, CollectionSelectFromApp, FindManyOptions, GetCollection, LiveQueryOptions, QuestpieApp, QuestpieClient, ResolveRelationsDeep } from "questpie/client";
|
|
5
5
|
|
|
6
6
|
//#region src/sync.d.ts
|
|
7
7
|
type QuestpieDbSyncMode = "refetch" | "snapshot";
|
|
8
8
|
//#endregion
|
|
9
|
+
//#region src/collection.d.ts
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A mutation lost an optimistic-concurrency race: QUESTPIE answered `409`
|
|
13
|
+
* (`CONFLICT`), or `412` when the request carried `If-Match`. TanStack DB has
|
|
14
|
+
* already rolled the optimistic state back, so the row on screen is the stale
|
|
15
|
+
* one that lost — refetch the collection before retrying, otherwise the retry
|
|
16
|
+
* carries the same stale revision and conflicts again.
|
|
17
|
+
*
|
|
18
|
+
* `ids` lists every row in the failed transaction. QUESTPIE validates all
|
|
19
|
+
* revisions before writing any row, so a conflict fails the whole batch without
|
|
20
|
+
* naming the individual loser.
|
|
21
|
+
*/
|
|
22
|
+
declare class QuestpieDbConflictError extends Error {
|
|
23
|
+
readonly collection: string;
|
|
24
|
+
readonly operation: "update" | "delete";
|
|
25
|
+
readonly ids: string[];
|
|
26
|
+
constructor(options: {
|
|
27
|
+
collection: string;
|
|
28
|
+
operation: "update" | "delete";
|
|
29
|
+
ids: string[];
|
|
30
|
+
cause: unknown;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
9
34
|
//#region src/types.d.ts
|
|
10
35
|
type CollectionKeys<TApp extends QuestpieApp> = Extract<keyof QuestpieClient<TApp>["collections"], string>;
|
|
11
36
|
type CollectionSelectOf<TApp extends QuestpieApp, K$1 extends CollectionKeys<TApp>> = CollectionSelectFromApp<GetCollection<TApp["collections"], K$1>, {
|
|
@@ -13,6 +38,14 @@ type CollectionSelectOf<TApp extends QuestpieApp, K$1 extends CollectionKeys<TAp
|
|
|
13
38
|
}>;
|
|
14
39
|
type CollectionRelationsOf<TApp extends QuestpieApp, K$1 extends CollectionKeys<TApp>> = ResolveRelationsDeep<CollectionRelations<GetCollection<TApp["collections"], K$1>>, TApp["collections"]>;
|
|
15
40
|
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">;
|
|
41
|
+
/**
|
|
42
|
+
* The find options a `syncMode: "snapshot"` collection may use: the ones the
|
|
43
|
+
* realtime topic carries. `live()` re-runs the query server-side from those
|
|
44
|
+
* topic fields alone, so anything it drops would make the live snapshot and the
|
|
45
|
+
* `find()` repair describe different rows. Derived from `LiveQueryOptions` so
|
|
46
|
+
* the two cannot drift.
|
|
47
|
+
*/
|
|
48
|
+
type SnapshotFindOptionsOf<TApp extends QuestpieApp, K$1 extends CollectionKeys<TApp>> = Pick<FindOptionsOf<TApp, K$1>, Extract<keyof FindOptionsOf<TApp, K$1>, keyof LiveQueryOptions<CollectionSelectOf<TApp, K$1>, CollectionRelationsOf<TApp, K$1>>>>;
|
|
16
49
|
type CollectionRowOf<TApp extends QuestpieApp, K$1 extends CollectionKeys<TApp>> = ApplyQuery<CollectionSelectOf<TApp, K$1>, CollectionRelationsOf<TApp, K$1>, undefined>;
|
|
17
50
|
type IdOf<TRow> = TRow extends {
|
|
18
51
|
id: infer TId;
|
|
@@ -24,13 +57,13 @@ type QuestpieDb<TApp extends QuestpieApp> = {
|
|
|
24
57
|
};
|
|
25
58
|
//#endregion
|
|
26
59
|
//#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> = {
|
|
60
|
+
type QuestpieFindOptions<TApp extends QuestpieApp, TMode extends QuestpieDbSyncMode = QuestpieDbSyncMode> = Partial<{ [K in CollectionKeys<TApp>]: TMode extends "snapshot" ? SnapshotFindOptionsOf<TApp, K> : FindOptionsOf<TApp, K> }>;
|
|
61
|
+
type CreateQuestpieCollectionsOptions<TApp extends QuestpieApp, TMode extends QuestpieDbSyncMode = QuestpieDbSyncMode> = {
|
|
29
62
|
queryClient: QueryClient;
|
|
30
|
-
syncMode?:
|
|
31
|
-
find?: QuestpieFindOptions<TApp>;
|
|
63
|
+
syncMode?: TMode;
|
|
64
|
+
find?: QuestpieFindOptions<TApp, TMode>;
|
|
32
65
|
keyPrefix?: QueryKey;
|
|
33
66
|
};
|
|
34
|
-
declare function createQuestpieCollections<TApp extends QuestpieApp>(client: QuestpieClient<TApp>, options: CreateQuestpieCollectionsOptions<TApp>): QuestpieDb<TApp>;
|
|
67
|
+
declare function createQuestpieCollections<TApp extends QuestpieApp, TMode extends QuestpieDbSyncMode = QuestpieDbSyncMode>(client: QuestpieClient<TApp>, options: CreateQuestpieCollectionsOptions<TApp, TMode>): QuestpieDb<TApp>;
|
|
35
68
|
//#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 };
|
|
69
|
+
export { type CollectionKeys, type CollectionRelationsOf, type CollectionRowOf, type CollectionSelectOf, type CreateQuestpieCollectionsOptions, type FindOptionsOf, type IdOf, type QuestpieCollections, type QuestpieDb, QuestpieDbConflictError, type QuestpieDbSyncMode, type QuestpieFindOptions, type SnapshotFindOptionsOf, and, createQuestpieCollections, eq, gt, inArray, lt, or, useLiveQuery };
|
package/dist/index.mjs
CHANGED
|
@@ -72,6 +72,61 @@ function resolveSync(options) {
|
|
|
72
72
|
|
|
73
73
|
//#endregion
|
|
74
74
|
//#region src/collection.ts
|
|
75
|
+
/**
|
|
76
|
+
* A mutation lost an optimistic-concurrency race: QUESTPIE answered `409`
|
|
77
|
+
* (`CONFLICT`), or `412` when the request carried `If-Match`. TanStack DB has
|
|
78
|
+
* already rolled the optimistic state back, so the row on screen is the stale
|
|
79
|
+
* one that lost — refetch the collection before retrying, otherwise the retry
|
|
80
|
+
* carries the same stale revision and conflicts again.
|
|
81
|
+
*
|
|
82
|
+
* `ids` lists every row in the failed transaction. QUESTPIE validates all
|
|
83
|
+
* revisions before writing any row, so a conflict fails the whole batch without
|
|
84
|
+
* naming the individual loser.
|
|
85
|
+
*/
|
|
86
|
+
var QuestpieDbConflictError = class extends Error {
|
|
87
|
+
collection;
|
|
88
|
+
operation;
|
|
89
|
+
ids;
|
|
90
|
+
constructor(options) {
|
|
91
|
+
super(`QUESTPIE rejected a ${options.operation} on "${options.collection}" as a conflict: ${options.ids.join(", ")}`, { cause: options.cause });
|
|
92
|
+
this.name = "QuestpieDbConflictError";
|
|
93
|
+
this.collection = options.collection;
|
|
94
|
+
this.operation = options.operation;
|
|
95
|
+
this.ids = options.ids;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
function isConflict(error) {
|
|
99
|
+
if (!error || typeof error !== "object") return false;
|
|
100
|
+
const { code, status } = error;
|
|
101
|
+
return status === 409 || status === 412 || code === "CONFLICT";
|
|
102
|
+
}
|
|
103
|
+
async function asConflict(options, run) {
|
|
104
|
+
try {
|
|
105
|
+
return await run();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (!isConflict(error)) throw error;
|
|
108
|
+
throw new QuestpieDbConflictError({
|
|
109
|
+
...options,
|
|
110
|
+
cause: error
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* QUESTPIE owns the `revision` column and only adds it when the collection
|
|
116
|
+
* enables `optimisticConcurrency`, so its presence on the row TanStack DB read
|
|
117
|
+
* is the feature detection. Collections without the feature must not send the
|
|
118
|
+
* key at all: the server ignores it there, which would read as a precondition
|
|
119
|
+
* that was checked when it never was.
|
|
120
|
+
*/
|
|
121
|
+
function revisionOf(original) {
|
|
122
|
+
const revision = original?.revision;
|
|
123
|
+
return typeof revision === "number" ? revision : void 0;
|
|
124
|
+
}
|
|
125
|
+
/** Spreadable form of {@link revisionOf}, so the key is absent when unset. */
|
|
126
|
+
function expectedRevisionOf(original) {
|
|
127
|
+
const revision = revisionOf(original);
|
|
128
|
+
return revision === void 0 ? {} : { expectedRevision: revision };
|
|
129
|
+
}
|
|
75
130
|
function rowId(row) {
|
|
76
131
|
if (typeof row.id !== "string" || row.id.length === 0) throw new Error("QUESTPIE TanStack DB rows require a non-empty string id");
|
|
77
132
|
return row.id;
|
|
@@ -79,6 +134,12 @@ function rowId(row) {
|
|
|
79
134
|
function persistedRow(result, fallback) {
|
|
80
135
|
return result && typeof result === "object" && "id" in result ? result : fallback;
|
|
81
136
|
}
|
|
137
|
+
function persistedRowsById(result) {
|
|
138
|
+
const rows = /* @__PURE__ */ new Map();
|
|
139
|
+
if (!Array.isArray(result)) return rows;
|
|
140
|
+
for (const row of result) if (row && typeof row === "object" && typeof row.id === "string") rows.set(row.id, row);
|
|
141
|
+
return rows;
|
|
142
|
+
}
|
|
82
143
|
function createQuestpieCollection(options) {
|
|
83
144
|
const { client, name, queryClient, queryKey, findOptions, syncMode, onDispose } = options;
|
|
84
145
|
const { getSnapshotRevision, queryFn, reconcileMutation } = resolveSync({
|
|
@@ -122,33 +183,36 @@ function createQuestpieCollection(options) {
|
|
|
122
183
|
},
|
|
123
184
|
onUpdate: async ({ transaction }) => {
|
|
124
185
|
const snapshotRevision = getSnapshotRevision();
|
|
125
|
-
const persisted = await
|
|
186
|
+
const persisted = persistedRowsById(await asConflict({
|
|
187
|
+
collection: name,
|
|
188
|
+
operation: "update",
|
|
189
|
+
ids: transaction.mutations.map((mutation) => String(mutation.key))
|
|
190
|
+
}, () => client.updateBatch({ updates: transaction.mutations.map((mutation) => ({
|
|
126
191
|
id: String(mutation.key),
|
|
127
|
-
data: mutation.changes
|
|
128
|
-
|
|
192
|
+
data: mutation.changes,
|
|
193
|
+
...expectedRevisionOf(mutation.original)
|
|
194
|
+
})) })));
|
|
129
195
|
await reconcileMutation(snapshotRevision, (rows) => {
|
|
130
196
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
131
|
-
for (
|
|
132
|
-
const mutation = transaction.mutations[index];
|
|
197
|
+
for (const mutation of transaction.mutations) {
|
|
133
198
|
const current = byId.get(String(mutation.key));
|
|
134
199
|
if (!current) continue;
|
|
135
|
-
byId.set(String(mutation.key),
|
|
200
|
+
byId.set(String(mutation.key), persisted.get(String(mutation.key)) ?? {
|
|
136
201
|
...current,
|
|
137
202
|
...mutation.changes
|
|
138
|
-
})
|
|
203
|
+
});
|
|
139
204
|
}
|
|
140
205
|
return rows.map((row) => byId.get(row.id) ?? row);
|
|
141
206
|
}, (rows) => {
|
|
142
207
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
143
|
-
for (
|
|
144
|
-
const mutation = transaction.mutations[index];
|
|
208
|
+
for (const mutation of transaction.mutations) {
|
|
145
209
|
const current = byId.get(String(mutation.key));
|
|
146
210
|
if (!current) continue;
|
|
147
211
|
const original = mutation.original;
|
|
148
|
-
const result =
|
|
212
|
+
const result = persisted.get(String(mutation.key)) ?? {
|
|
149
213
|
...original,
|
|
150
214
|
...mutation.changes
|
|
151
|
-
}
|
|
215
|
+
};
|
|
152
216
|
const merged = { ...current };
|
|
153
217
|
for (const key of Object.keys(result)) if (deepEquals(current[key], original[key])) merged[key] = result[key];
|
|
154
218
|
byId.set(String(mutation.key), merged);
|
|
@@ -159,8 +223,23 @@ function createQuestpieCollection(options) {
|
|
|
159
223
|
},
|
|
160
224
|
onDelete: async ({ transaction }) => {
|
|
161
225
|
const snapshotRevision = getSnapshotRevision();
|
|
162
|
-
|
|
163
|
-
const
|
|
226
|
+
const ids = transaction.mutations.map((mutation) => String(mutation.key));
|
|
227
|
+
const expectedRevisions = transaction.mutations.flatMap((mutation) => {
|
|
228
|
+
const revision = revisionOf(mutation.original);
|
|
229
|
+
return revision === void 0 ? [] : [{
|
|
230
|
+
id: String(mutation.key),
|
|
231
|
+
expectedRevision: revision
|
|
232
|
+
}];
|
|
233
|
+
});
|
|
234
|
+
await asConflict({
|
|
235
|
+
collection: name,
|
|
236
|
+
operation: "delete",
|
|
237
|
+
ids
|
|
238
|
+
}, () => client.deleteMany({
|
|
239
|
+
where: { id: { in: ids } },
|
|
240
|
+
...expectedRevisions.length === ids.length ? { expectedRevisions } : {}
|
|
241
|
+
}));
|
|
242
|
+
const deleted = new Set(ids);
|
|
164
243
|
await reconcileMutation(snapshotRevision, (rows) => rows.filter((row) => !deleted.has(row.id)), (rows) => {
|
|
165
244
|
const originals = new Map(transaction.mutations.map((mutation) => [String(mutation.key), mutation.original]));
|
|
166
245
|
return rows.filter((row) => {
|
|
@@ -181,10 +260,26 @@ const SHAPE_CHANGING_FIND_OPTIONS = [
|
|
|
181
260
|
"extras",
|
|
182
261
|
"groupBy"
|
|
183
262
|
];
|
|
263
|
+
/**
|
|
264
|
+
* Find options `find()` honours that the realtime topic cannot carry — the
|
|
265
|
+
* complement of `SnapshotFindOptionsOf`. In snapshot mode the live subscription
|
|
266
|
+
* would silently ignore them while the post-mutation repair read applied them,
|
|
267
|
+
* so the two would describe different row sets.
|
|
268
|
+
*/
|
|
269
|
+
const SNAPSHOT_UNSUPPORTED_FIND_OPTIONS = [
|
|
270
|
+
"search",
|
|
271
|
+
"localeFallback",
|
|
272
|
+
"includeDeleted",
|
|
273
|
+
"stage"
|
|
274
|
+
];
|
|
184
275
|
function assertBaseRowFindOptions(options) {
|
|
185
276
|
if (!options || typeof options !== "object" || Array.isArray(options)) return;
|
|
186
277
|
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
278
|
}
|
|
279
|
+
function assertSnapshotFindOptions(options) {
|
|
280
|
+
if (!options || typeof options !== "object" || Array.isArray(options)) return;
|
|
281
|
+
for (const key of SNAPSHOT_UNSUPPORTED_FIND_OPTIONS) if (key in options) throw new Error(`QUESTPIE TanStack DB find.${key} is not supported in snapshot sync mode because live subscriptions cannot carry it, so snapshots and repair reads would describe different rows`);
|
|
282
|
+
}
|
|
188
283
|
function createQuestpieCollections(client, options) {
|
|
189
284
|
const cache = /* @__PURE__ */ new Map();
|
|
190
285
|
const disposers = /* @__PURE__ */ new Set();
|
|
@@ -200,6 +295,7 @@ function createQuestpieCollections(client, options) {
|
|
|
200
295
|
if (!collectionClient) return void 0;
|
|
201
296
|
const findOptions = options.find?.[property];
|
|
202
297
|
assertBaseRowFindOptions(findOptions);
|
|
298
|
+
if (syncMode === "snapshot") assertSnapshotFindOptions(findOptions);
|
|
203
299
|
const created = createQuestpieCollection({
|
|
204
300
|
client: collectionClient,
|
|
205
301
|
name: property,
|
|
@@ -230,4 +326,4 @@ function createQuestpieCollections(client, options) {
|
|
|
230
326
|
}
|
|
231
327
|
|
|
232
328
|
//#endregion
|
|
233
|
-
export { and, createQuestpieCollections, eq, gt, inArray, lt, or, useLiveQuery };
|
|
329
|
+
export { QuestpieDbConflictError, and, createQuestpieCollections, eq, gt, inArray, lt, or, useLiveQuery };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@questpie/tanstack-db",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.25.1",
|
|
4
4
|
"description": "Typed TanStack DB collections backed by a QUESTPIE client",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -49,6 +49,6 @@
|
|
|
49
49
|
"@tanstack/query-db-collection": "1.1.0",
|
|
50
50
|
"@tanstack/react-db": "0.1.94",
|
|
51
51
|
"@tanstack/react-query": "^5.0.0",
|
|
52
|
-
"questpie": "^3.
|
|
52
|
+
"questpie": "^3.25.1"
|
|
53
53
|
}
|
|
54
54
|
}
|