@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 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?: QuestpieDbSyncMode;
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 Promise.all(transaction.mutations.map((mutation) => client.update({
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 (let index = 0; index < transaction.mutations.length; index++) {
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), persistedRow(persisted[index], {
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 (let index = 0; index < transaction.mutations.length; index++) {
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 = persistedRow(persisted[index], {
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
- 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)));
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.24.0",
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.24.0"
52
+ "questpie": "^3.25.1"
53
53
  }
54
54
  }