pbtsdb 0.7.3 → 0.8.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/llms.txt CHANGED
@@ -202,16 +202,12 @@ function BooksList() {
202
202
  const c = createCollection<Schema>(pb, queryClient);
203
203
  const authors = c('authors', {});
204
204
  const books = c('books', {
205
- expand: {
206
- author: authors // Auto-expand and auto-upsert
207
- }
205
+ relations: { author: authors }, // where expanded authors are upserted
206
+ alwaysExpand: ['author'], // expanded on every fetch
208
207
  });
209
208
 
210
- // Expand is automatic - no .expand() call needed
211
209
  const { data } = useLiveQuery((q) => q.from({ books }));
212
-
213
- // Expanded records automatically inserted into authors collection
214
- // data[0].expand?.author is typed!
210
+ // data[0].expand?.author is typed and populated
215
211
  ```
216
212
 
217
213
  **When to use Auto-Expand:**
@@ -219,6 +215,14 @@ const { data } = useLiveQuery((q) => q.from({ books }));
219
215
  - Performance: Single server query with server-side expand
220
216
  - Related data always available when fetching parent records
221
217
 
218
+ **Per-query expand**
219
+ ```typescript
220
+ const books = c('books', { relations: { author: authors, tags } });
221
+ const { data } = useLiveQuery((q) => q.from({ books: books.expand('tags') }));
222
+ // data[0].expand?.tags typed; nested paths like 'book.author' resolve through
223
+ // the target collection's own relations. Views share the base store.
224
+ ```
225
+
222
226
  **Approach 2: TanStack Joins**
223
227
  ```typescript
224
228
  const c = createCollection<Schema>(pb, queryClient);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pbtsdb",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "Type-safe PocketBase integration with TanStack Query and TanStack DB",
5
5
  "keywords": [
6
6
  "pocketbase",
@@ -78,9 +78,9 @@
78
78
  },
79
79
  "devDependencies": {
80
80
  "@biomejs/biome": "^2.5.0",
81
- "@tanstack/db": ">=0.6.8",
82
- "@tanstack/query-db-collection": ">=1.0.40",
83
- "@tanstack/react-db": ">=0.1.86",
81
+ "@tanstack/db": ">=0.9.0",
82
+ "@tanstack/query-db-collection": ">=1.2.13",
83
+ "@tanstack/react-db": ">=0.3.8",
84
84
  "@tanstack/react-query": ">=5.101.0",
85
85
  "@testing-library/react": ">=16.3.2",
86
86
  "@types/node": ">=25.0.10",
@@ -1,517 +0,0 @@
1
- import { createCollection as createCollection$1, parseWhereExpression, parseOrderByExpression } from '@tanstack/db';
2
- export { BTreeIndex, BasicIndex, ReverseIndex, createEffect, toArray } from '@tanstack/db';
3
- import { queryCollectionOptions, DeleteOperationItemNotFoundError } from '@tanstack/query-db-collection';
4
-
5
- // src/core.ts
6
-
7
- // src/logger.ts
8
- var defaultLogger = {
9
- debug: (msg, context) => {
10
- if (process.env.NODE_ENV === "development") {
11
- console.debug(`[pbtsdb] ${msg}`, context || "");
12
- }
13
- },
14
- info: (msg, context) => {
15
- console.info(`[pbtsdb] ${msg}`, context || "");
16
- },
17
- warn: (msg, context) => {
18
- console.warn(`[pbtsdb] ${msg}`, context || "");
19
- },
20
- error: (msg, context) => {
21
- console.error(`[pbtsdb] ${msg}`, context || "");
22
- }
23
- };
24
- var currentLogger = defaultLogger;
25
- var logger = {
26
- debug: (msg, context) => currentLogger.debug(msg, context),
27
- info: (msg, context) => currentLogger.info(msg, context),
28
- warn: (msg, context) => currentLogger.warn(msg, context),
29
- error: (msg, context) => currentLogger.error(msg, context)
30
- };
31
- function setLogger(customLogger) {
32
- currentLogger = customLogger;
33
- }
34
- function resetLogger() {
35
- currentLogger = defaultLogger;
36
- }
37
- function escapeValue(value) {
38
- if (value === null) {
39
- return "null";
40
- }
41
- if (typeof value === "boolean") {
42
- return value ? "true" : "false";
43
- }
44
- if (typeof value === "number") {
45
- return value.toString();
46
- }
47
- if (typeof value === "string") {
48
- return `"${value.replace(/"/g, '\\"')}"`;
49
- }
50
- if (value instanceof Date) {
51
- return `"${value.toISOString()}"`;
52
- }
53
- if (Array.isArray(value)) {
54
- return `[${value.map(escapeValue).join(",")}]`;
55
- }
56
- return `"${String(value)}"`;
57
- }
58
- function fieldPathToString(path) {
59
- return path.join(".");
60
- }
61
- function convertToPocketBaseFilter(where) {
62
- if (!where) {
63
- return void 0;
64
- }
65
- const result = parseWhereExpression(where, {
66
- handlers: {
67
- eq: (field, value) => {
68
- return `${fieldPathToString(field)} = ${escapeValue(value)}`;
69
- },
70
- gt: (field, value) => {
71
- return `${fieldPathToString(field)} > ${escapeValue(value)}`;
72
- },
73
- gte: (field, value) => {
74
- return `${fieldPathToString(field)} >= ${escapeValue(value)}`;
75
- },
76
- lt: (field, value) => {
77
- return `${fieldPathToString(field)} < ${escapeValue(value)}`;
78
- },
79
- lte: (field, value) => {
80
- return `${fieldPathToString(field)} <= ${escapeValue(value)}`;
81
- },
82
- and: (...conditions) => {
83
- if (conditions.length === 0) return "";
84
- if (conditions.length === 1) return conditions[0];
85
- return `(${conditions.join(" && ")})`;
86
- },
87
- or: (...conditions) => {
88
- if (conditions.length === 0) return "";
89
- if (conditions.length === 1) return conditions[0];
90
- return `(${conditions.join(" || ")})`;
91
- },
92
- not: (condition) => {
93
- return `!(${condition})`;
94
- },
95
- in: (field, values) => {
96
- const valueArray = Array.isArray(values) ? values : [values];
97
- const uniqueValues = [...new Set(valueArray)];
98
- const fieldStr = fieldPathToString(field);
99
- const conditions = uniqueValues.map((v) => `${fieldStr} = ${escapeValue(v)}`);
100
- return conditions.length > 1 ? `(${conditions.join(" || ")})` : conditions[0];
101
- },
102
- like: (field, value) => {
103
- return `${fieldPathToString(field)} ~ ${escapeValue(value)}`;
104
- },
105
- isNull: (field) => {
106
- return `${fieldPathToString(field)} = null`;
107
- },
108
- isUndefined: (field) => {
109
- return `${fieldPathToString(field)} = null`;
110
- }
111
- },
112
- onUnknownOperator: (operator, _args) => {
113
- throw new Error(
114
- `Unsupported operator '${operator}' for PocketBase filter conversion. Supported operators: eq, gt, gte, lt, lte, in, like, and, or, not, isNull, isUndefined`
115
- );
116
- }
117
- });
118
- return result || void 0;
119
- }
120
- function convertToPocketBaseSort(orderBy) {
121
- if (!orderBy) {
122
- return void 0;
123
- }
124
- const sorts = parseOrderByExpression(orderBy);
125
- if (sorts.length === 0) {
126
- return void 0;
127
- }
128
- return sorts.map((sort) => {
129
- const field = fieldPathToString(sort.field);
130
- return sort.direction === "desc" ? `-${field}` : field;
131
- }).join(",");
132
- }
133
-
134
- // src/collection.ts
135
- function createCollection(pb, queryClient, factoryOptions) {
136
- return (collectionName, options) => {
137
- const expandStores = options?.expand;
138
- const expandString = expandStores ? Object.keys(expandStores).sort().join(",") : void 0;
139
- const ignoreAutoCancellation = options?.ignoreAutoCancellation ?? true;
140
- const refetchOnMutation = options?.refetchOnMutation ?? false;
141
- async function upsertExpandedRelation(key, value, stores) {
142
- const targetStore = stores[key];
143
- if (!targetStore.utils) return;
144
- if (!targetStore.isReady()) {
145
- if (targetStore.config?.syncMode === "on-demand") {
146
- await targetStore._sync.startSync();
147
- } else {
148
- logger.warn(
149
- `not syncing ${key} on ${collectionName} because store is not yet ready`
150
- );
151
- return;
152
- }
153
- }
154
- const values = Array.isArray(value) ? value : [value];
155
- targetStore.utils.writeUpsert(values);
156
- }
157
- async function upsertExpandedRelations(items) {
158
- if (!expandStores) return;
159
- for (const record of items) {
160
- const expandData = record.expand;
161
- if (!expandData) continue;
162
- for (const [key, value] of Object.entries(expandData)) {
163
- await upsertExpandedRelation(key, value, expandStores);
164
- }
165
- }
166
- }
167
- async function fetchItems(loadOptions) {
168
- const filter = convertToPocketBaseFilter(loadOptions?.where);
169
- const sort = convertToPocketBaseSort(loadOptions?.orderBy);
170
- const limit = loadOptions?.limit;
171
- if (limit) {
172
- const result = await pb.collection(collectionName).getList(1, limit, {
173
- filter,
174
- sort,
175
- skipTotal: true,
176
- // Optimize by skipping total count
177
- expand: expandString
178
- });
179
- return result.items;
180
- }
181
- return await pb.collection(collectionName).getFullList({
182
- filter,
183
- sort,
184
- expand: expandString
185
- });
186
- }
187
- const inFlightConfirmedIds = /* @__PURE__ */ new Set();
188
- function markConfirmedPresent(records) {
189
- if (inFlightConfirmedIds.size === 0) return;
190
- for (const record of records) {
191
- const id = record?.id;
192
- if (typeof id !== "string") continue;
193
- for (const confirmed of inFlightConfirmedIds) confirmed.add(id);
194
- }
195
- }
196
- function withRowsConfirmedMidFlight(items, confirmedMidFlight) {
197
- if (confirmedMidFlight.size === 0) return items;
198
- const resultIds = new Set(
199
- items.map((item) => item?.id)
200
- );
201
- const mergedIds = [...confirmedMidFlight].filter(
202
- (id) => !resultIds.has(id) && collection._state.syncedData.has(id)
203
- );
204
- if (mergedIds.length === 0) return items;
205
- logger.debug("Merging rows confirmed while fetch was in flight", {
206
- collectionName,
207
- ids: mergedIds
208
- });
209
- return [
210
- ...items,
211
- ...mergedIds.map((id) => collection._state.syncedData.get(id))
212
- ];
213
- }
214
- async function fetchRecords(loadOptions, queryKey) {
215
- const confirmedMidFlight = /* @__PURE__ */ new Set();
216
- inFlightConfirmedIds.add(confirmedMidFlight);
217
- try {
218
- let items;
219
- try {
220
- items = await fetchItems(loadOptions);
221
- } catch (error) {
222
- if (ignoreAutoCancellation && error instanceof Error && error.message.includes("autocancelled")) {
223
- return withRowsConfirmedMidFlight(
224
- queryClient.getQueryData(queryKey ?? [collectionName]) ?? [],
225
- confirmedMidFlight
226
- );
227
- }
228
- throw error;
229
- }
230
- await upsertExpandedRelations(items);
231
- return withRowsConfirmedMidFlight(items, confirmedMidFlight);
232
- } finally {
233
- inFlightConfirmedIds.delete(confirmedMidFlight);
234
- }
235
- }
236
- const collectionOptions = queryCollectionOptions({
237
- ...options?.collectionOptions,
238
- queryClient,
239
- queryKey: [collectionName],
240
- syncMode: options?.syncMode ?? "eager",
241
- queryFn: async (ctx) => {
242
- return fetchRecords(
243
- ctx.meta?.loadSubsetOptions,
244
- ctx.queryKey
245
- );
246
- },
247
- getKey: (item) => {
248
- const record = item;
249
- if (!record || typeof record !== "object" || !("id" in record)) {
250
- throw new Error(
251
- `Record in collection '${collectionName}' is missing required 'id' field. Received: ${JSON.stringify(item)}`
252
- );
253
- }
254
- return record.id;
255
- },
256
- onInsert: options?.onInsert === false ? void 0 : options?.onInsert ?? (async ({ transaction }) => {
257
- const created = await Promise.all(
258
- transaction.mutations.map(async (mutation) => {
259
- const {
260
- created: _created,
261
- updated: _updated,
262
- collectionId: _collectionId,
263
- collectionName: _collectionName,
264
- ...data
265
- } = mutation.modified;
266
- return pb.collection(collectionName).create(data);
267
- })
268
- );
269
- writeBackAfterPersisted(transaction, created);
270
- return { refetch: refetchOnMutation };
271
- }),
272
- onUpdate: options?.onUpdate === false ? void 0 : options?.onUpdate ?? (async ({ transaction }) => {
273
- const updated = await Promise.all(
274
- transaction.mutations.map(async (mutation) => {
275
- const recordWithId = mutation.original;
276
- return pb.collection(collectionName).update(recordWithId.id, mutation.changes);
277
- })
278
- );
279
- writeBackAfterPersisted(transaction, updated);
280
- return { refetch: refetchOnMutation };
281
- }),
282
- onDelete: options?.onDelete === false ? void 0 : options?.onDelete ?? (async ({ transaction }) => {
283
- await Promise.all(
284
- transaction.mutations.map(async (mutation) => {
285
- const recordWithId = mutation.original;
286
- await pb.collection(collectionName).delete(recordWithId.id);
287
- })
288
- );
289
- return { refetch: refetchOnMutation };
290
- })
291
- });
292
- function writeBackAfterPersisted(transaction, records) {
293
- markConfirmedPresent(records);
294
- void transaction.isPersisted.promise.then(
295
- () => {
296
- if (!collection.utils || !collection.isReady()) return;
297
- const fresh = records.filter((record) => !isStaleServerRecord(record));
298
- if (fresh.length === 0) return;
299
- writeOwn(() => collection.utils.writeUpsert(fresh));
300
- },
301
- // A rejected transaction rolled its draft back; there is
302
- // nothing to write and nothing to report here.
303
- () => void 0
304
- );
305
- }
306
- let applyingOwnWrite = false;
307
- function writeOwn(fn) {
308
- applyingOwnWrite = true;
309
- try {
310
- fn();
311
- } finally {
312
- applyingOwnWrite = false;
313
- }
314
- }
315
- function syncedWriteKey(op) {
316
- if (op.type !== "insert" && op.type !== "update") return null;
317
- if (typeof op.key === "string") return op.key;
318
- const id = op.value?.id;
319
- return typeof id === "string" ? id : null;
320
- }
321
- function shouldDropSyncedWrite(op) {
322
- const key = syncedWriteKey(op);
323
- if (key === null) return false;
324
- if (!applyingOwnWrite && hasPendingOptimisticMutation(key) && collection._state.syncedData.has(key)) {
325
- logger.debug("Dropping synced write for optimistically-pending row", {
326
- collectionName,
327
- id: key
328
- });
329
- return true;
330
- }
331
- if (isStaleServerRecord(op.value)) {
332
- logger.debug("Dropping stale synced write", { collectionName, id: key });
333
- return true;
334
- }
335
- return false;
336
- }
337
- const innerSync = collectionOptions.sync.sync;
338
- collectionOptions.sync = {
339
- ...collectionOptions.sync,
340
- sync: (params) => {
341
- const guardedWrite = (message) => {
342
- if (shouldDropSyncedWrite(
343
- message
344
- )) {
345
- return;
346
- }
347
- return params.write(message);
348
- };
349
- return innerSync({ ...params, write: guardedWrite });
350
- }
351
- };
352
- const collection = createCollection$1(collectionOptions);
353
- function hasPendingOptimisticMutation(key) {
354
- const state = collection._state;
355
- return state.optimisticUpserts.has(key) || state.optimisticDeletes.has(key);
356
- }
357
- function recordUpdatedAt(record) {
358
- const updated = record?.updated;
359
- return typeof updated === "string" && updated !== "" ? updated : void 0;
360
- }
361
- function isStaleServerRecord(record) {
362
- const id = record?.id;
363
- if (typeof id !== "string") return false;
364
- const incoming = recordUpdatedAt(record);
365
- if (!incoming) return false;
366
- const current = recordUpdatedAt(
367
- collection._state.syncedData.get(id)
368
- );
369
- if (current === void 0) return false;
370
- return incoming < current;
371
- }
372
- function isStaleEcho(event) {
373
- if (event.action !== "create" && event.action !== "update") return false;
374
- if (!isStaleServerRecord(event.record)) return false;
375
- logger.debug("Ignoring stale realtime echo", {
376
- collectionName,
377
- id: event.record?.id
378
- });
379
- return true;
380
- }
381
- let unsubscribeFn = null;
382
- let isSubscribed = false;
383
- let subscriptionPromise = null;
384
- let subscriptionResolve = null;
385
- function markEchoPresence(event) {
386
- if (event.action !== "delete") markConfirmedPresent([event.record]);
387
- }
388
- const handleRealtimeEvent = (event) => {
389
- if (!collection.utils) return;
390
- markEchoPresence(event);
391
- if (isStaleEcho(event)) return;
392
- try {
393
- writeOwn(
394
- () => collection.utils.writeBatch(() => {
395
- switch (event.action) {
396
- case "create":
397
- collection.utils.writeInsert(event.record);
398
- break;
399
- case "update":
400
- collection.utils.writeUpsert(event.record);
401
- break;
402
- case "delete":
403
- if (event.record && "id" in event.record) {
404
- collection.utils.writeDelete(
405
- event.record.id
406
- );
407
- }
408
- break;
409
- }
410
- })
411
- );
412
- } catch (error) {
413
- if (error instanceof DeleteOperationItemNotFoundError) {
414
- logger.debug("Ignoring delete echo for already-removed record", {
415
- collectionName,
416
- id: event.record?.id
417
- });
418
- } else {
419
- throw error;
420
- }
421
- }
422
- };
423
- const startSubscription = async () => {
424
- if (isSubscribed) return;
425
- if (!subscriptionPromise) {
426
- subscriptionPromise = new Promise((resolve) => {
427
- subscriptionResolve = resolve;
428
- });
429
- }
430
- try {
431
- unsubscribeFn = await pb.collection(collectionName).subscribe("*", handleRealtimeEvent, factoryOptions?.subscribeOptions?.());
432
- isSubscribed = true;
433
- logger.debug("Subscription started", { collectionName });
434
- if (subscriptionResolve) {
435
- subscriptionResolve();
436
- }
437
- } catch (error) {
438
- logger.error("Failed to start subscription", { collectionName, error });
439
- }
440
- };
441
- const stopSubscription = async () => {
442
- if (!isSubscribed || !unsubscribeFn) return;
443
- try {
444
- await unsubscribeFn();
445
- unsubscribeFn = null;
446
- isSubscribed = false;
447
- subscriptionPromise = null;
448
- subscriptionResolve = null;
449
- logger.debug("Subscription stopped", { collectionName });
450
- } catch (error) {
451
- logger.debug("Unsubscribe failed (expected if connection closed)", {
452
- collectionName,
453
- error
454
- });
455
- }
456
- };
457
- const waitForSubscription = async (timeout = 5e3) => {
458
- if (isSubscribed) return;
459
- if (!subscriptionPromise) {
460
- await new Promise((resolve) => {
461
- const checkInterval = setInterval(() => {
462
- if (subscriptionPromise) {
463
- clearInterval(checkInterval);
464
- resolve();
465
- }
466
- }, 10);
467
- setTimeout(() => {
468
- clearInterval(checkInterval);
469
- resolve();
470
- }, timeout);
471
- });
472
- }
473
- if (subscriptionPromise) {
474
- await Promise.race([
475
- subscriptionPromise,
476
- new Promise(
477
- (_, reject) => setTimeout(() => reject(new Error("Subscription timeout")), timeout)
478
- )
479
- ]);
480
- }
481
- };
482
- collection.on(
483
- "subscribers:change",
484
- (event) => {
485
- const newCount = event.subscriberCount;
486
- const previousCount = event.previousSubscriberCount;
487
- if (newCount > 0 && previousCount === 0) {
488
- startSubscription().catch(() => {
489
- });
490
- } else if (newCount === 0 && previousCount > 0) {
491
- stopSubscription().catch(() => {
492
- });
493
- }
494
- }
495
- );
496
- Object.assign(collection, {
497
- collectionName,
498
- waitForSubscription,
499
- isSubscribed: () => isSubscribed
500
- });
501
- return collection;
502
- };
503
- }
504
-
505
- // src/util.ts
506
- function newRecordId() {
507
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
508
- let result = "";
509
- for (let i = 0; i < 15; i++) {
510
- result += chars.charAt(Math.floor(Math.random() * chars.length));
511
- }
512
- return result;
513
- }
514
-
515
- export { createCollection, newRecordId, resetLogger, setLogger };
516
- //# sourceMappingURL=chunk-P3KUBRZK.js.map
517
- //# sourceMappingURL=chunk-P3KUBRZK.js.map