pbtsdb 0.7.3 → 0.9.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
@@ -197,28 +197,52 @@ function BooksList() {
197
197
 
198
198
  ## Common Patterns
199
199
 
200
- **Approach 1: Auto-Expand (Recommended)**
200
+ **Approach 1: Related data (Recommended)**
201
+
202
+ PocketBase `expand` only files related records into their own collections;
203
+ rows never carry `expand`. Read related records from the target collection.
204
+
201
205
  ```typescript
206
+ import { eq } from '@tanstack/db';
207
+ import { materialize } from 'pbtsdb';
208
+
202
209
  const c = createCollection<Schema>(pb, queryClient);
203
- const authors = c('authors', {});
210
+ const authors = c('authors', { syncMode: 'on-demand' });
204
211
  const books = c('books', {
205
- expand: {
206
- author: authors // Auto-expand and auto-upsert
207
- }
212
+ relations: { author: authors }, // where expanded records are filed
213
+ alwaysFetchRelations: ['author'], // fetched with every books request
208
214
  });
209
215
 
210
- // Expand is automatic - no .expand() call needed
211
- const { data } = useLiveQuery((q) => q.from({ books }));
212
-
213
- // Expanded records automatically inserted into authors collection
214
- // data[0].expand?.author is typed!
216
+ const { data } = useLiveQuery((q) =>
217
+ q.from({ b: books }).select(({ b }) => ({
218
+ ...b,
219
+ author: materialize(
220
+ q.from({ a: authors }).where(({ a }) => eq(a.id, b.author)).findOne()
221
+ ),
222
+ }))
223
+ );
224
+ // data[0].author?.name is Authors | undefined and updates when the author changes
215
225
  ```
216
226
 
217
- **When to use Auto-Expand:**
218
- - You want expanded records to populate their own collections automatically
219
- - Performance: Single server query with server-side expand
227
+ An id-only load (`eq(id, x)`, `inArray(id, [...])`, or an `or` of those) is
228
+ served from the synced store when every id is present, except through a
229
+ `fetchRelations()` view, whose fetch always goes to PocketBase; anything else
230
+ is fetched in one batched request.
231
+
232
+ **When to use alwaysFetchRelations:**
233
+ - The parent is the only path by which those rows enter an on-demand collection
234
+ - Performance: single server request per fetch of the parent
220
235
  - Related data always available when fetching parent records
221
236
 
237
+ **Per-query fetchRelations**
238
+ ```typescript
239
+ const books = c('books', { relations: { author: authors, tags } });
240
+ const { data } = useLiveQuery((q) => q.from({ books: books.fetchRelations('tags') }));
241
+ // tags referenced by these books are filed into the tags collection; nested
242
+ // paths like 'book.author' resolve through the target's own relations.
243
+ // Views share the base store, subscription, and mutations.
244
+ ```
245
+
222
246
  **Approach 2: TanStack Joins**
223
247
  ```typescript
224
248
  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.9.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