pbtsdb 0.9.2 → 0.10.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.
@@ -1,1033 +0,0 @@
1
- import { BTreeIndex, createCollection as createCollection$1, parseWhereExpression, parseOrderByExpression } from '@tanstack/db';
2
- export { BTreeIndex, BasicIndex, ReverseIndex, createEffect, materialize, toArray } from '@tanstack/db';
3
- import { queryCollectionOptions, DeleteOperationItemNotFoundError } from '@tanstack/query-db-collection';
4
-
5
- // src/core.ts
6
-
7
- // src/expand-paths.ts
8
- function normalizePaths(paths) {
9
- const set = /* @__PURE__ */ new Set();
10
- for (const path of paths) {
11
- const trimmed = path.trim();
12
- if (trimmed) set.add(trimmed);
13
- }
14
- return [...set].sort();
15
- }
16
- function splitPaths(csv) {
17
- return csv ? normalizePaths(csv.split(",")) : [];
18
- }
19
- function joinPaths(paths) {
20
- const normalized = normalizePaths(paths);
21
- return normalized.length > 0 ? normalized.join(",") : void 0;
22
- }
23
- var BACK_RELATION_EXPAND_CAP = 1e3;
24
- function parseViaKey(key) {
25
- const index = key.indexOf("_via_");
26
- if (index < 0) return void 0;
27
- const field = key.slice(index + "_via_".length);
28
- return field ? { field } : void 0;
29
- }
30
- function markFiledSubset(target, key, values, parentId) {
31
- const via = parseViaKey(key);
32
- if (!via || values.length >= BACK_RELATION_EXPAND_CAP) return;
33
- target.markSubsetLoaded?.(via.field, parentId);
34
- }
35
- function validateExpandPath(collectionName, targets, path) {
36
- if (!targets) {
37
- throw new Error(
38
- `Cannot expand "${path}" on collection "${collectionName}": no relations declared`
39
- );
40
- }
41
- let current = targets;
42
- for (const segment of path.split(".")) {
43
- const next = current?.[segment];
44
- if (!next) {
45
- throw new Error(
46
- `Cannot expand "${path}" on collection "${collectionName}": segment "${segment}" is not a declared relation`
47
- );
48
- }
49
- current = next.relationTargets;
50
- }
51
- }
52
-
53
- // src/keyed-where.ts
54
- function fieldRef(node) {
55
- return node?.type === "ref" && node.path?.length === 1 ? node.path[0] : void 0;
56
- }
57
- function stringValue(node) {
58
- return node?.type === "val" && typeof node.value === "string" ? node.value : void 0;
59
- }
60
- function collectEq(a, b) {
61
- const field = fieldRef(a) ?? fieldRef(b);
62
- if (field === void 0) return void 0;
63
- const value = stringValue(fieldRef(a) !== void 0 ? b : a);
64
- return value === void 0 ? void 0 : { field, values: [value] };
65
- }
66
- function collectIn(a, b) {
67
- const field = fieldRef(a);
68
- if (field === void 0 || b?.type !== "val" || !Array.isArray(b.value)) return void 0;
69
- return b.value.every((v) => typeof v === "string") ? { field, values: b.value } : void 0;
70
- }
71
- function collectOr(args) {
72
- let field;
73
- const values = [];
74
- for (const arg of args) {
75
- const sub = collect(arg);
76
- if (!sub || field !== void 0 && sub.field !== field) return void 0;
77
- field = sub.field;
78
- values.push(...sub.values);
79
- }
80
- return field === void 0 ? void 0 : { field, values };
81
- }
82
- function collect(node) {
83
- if (node.type !== "func" || !node.args) return void 0;
84
- const [a, b] = node.args;
85
- switch (node.name) {
86
- case "eq":
87
- return collectEq(a, b);
88
- case "in":
89
- return collectIn(a, b);
90
- case "or":
91
- return collectOr(node.args);
92
- default:
93
- return void 0;
94
- }
95
- }
96
- function subsetFromWhere(where) {
97
- if (!where) return void 0;
98
- const subset = collect(where);
99
- return subset ? { field: subset.field, values: [...new Set(subset.values)].sort() } : void 0;
100
- }
101
- function matchesSubset(row, field, wanted) {
102
- const value = row[field];
103
- if (typeof value === "string") return wanted.has(value);
104
- if (Array.isArray(value))
105
- return value.some((item) => typeof item === "string" && wanted.has(item));
106
- return false;
107
- }
108
-
109
- // src/logger.ts
110
- var defaultLogger = {
111
- debug: (msg, context) => {
112
- if (process.env.NODE_ENV === "development") {
113
- console.debug(`[pbtsdb] ${msg}`, context || "");
114
- }
115
- },
116
- info: (msg, context) => {
117
- console.info(`[pbtsdb] ${msg}`, context || "");
118
- },
119
- warn: (msg, context) => {
120
- console.warn(`[pbtsdb] ${msg}`, context || "");
121
- },
122
- error: (msg, context) => {
123
- console.error(`[pbtsdb] ${msg}`, context || "");
124
- }
125
- };
126
- var currentLogger = defaultLogger;
127
- var logger = {
128
- debug: (msg, context) => currentLogger.debug(msg, context),
129
- info: (msg, context) => currentLogger.info(msg, context),
130
- warn: (msg, context) => currentLogger.warn(msg, context),
131
- error: (msg, context) => currentLogger.error(msg, context)
132
- };
133
- function setLogger(customLogger) {
134
- currentLogger = customLogger;
135
- }
136
- function resetLogger() {
137
- currentLogger = defaultLogger;
138
- }
139
- function escapeValue(value) {
140
- if (value === null) {
141
- return "null";
142
- }
143
- if (typeof value === "boolean") {
144
- return value ? "true" : "false";
145
- }
146
- if (typeof value === "number") {
147
- return value.toString();
148
- }
149
- if (typeof value === "string") {
150
- return `"${value.replace(/"/g, '\\"')}"`;
151
- }
152
- if (value instanceof Date) {
153
- return `"${value.toISOString()}"`;
154
- }
155
- if (Array.isArray(value)) {
156
- return `[${value.map(escapeValue).join(",")}]`;
157
- }
158
- return `"${String(value)}"`;
159
- }
160
- function fieldPathToString(path) {
161
- return path.join(".");
162
- }
163
- function convertToPocketBaseFilter(where) {
164
- if (!where) {
165
- return void 0;
166
- }
167
- const result = parseWhereExpression(where, {
168
- handlers: {
169
- eq: (field, value) => {
170
- return `${fieldPathToString(field)} = ${escapeValue(value)}`;
171
- },
172
- gt: (field, value) => {
173
- return `${fieldPathToString(field)} > ${escapeValue(value)}`;
174
- },
175
- gte: (field, value) => {
176
- return `${fieldPathToString(field)} >= ${escapeValue(value)}`;
177
- },
178
- lt: (field, value) => {
179
- return `${fieldPathToString(field)} < ${escapeValue(value)}`;
180
- },
181
- lte: (field, value) => {
182
- return `${fieldPathToString(field)} <= ${escapeValue(value)}`;
183
- },
184
- and: (...conditions) => {
185
- if (conditions.length === 0) return "";
186
- if (conditions.length === 1) return conditions[0];
187
- return `(${conditions.join(" && ")})`;
188
- },
189
- or: (...conditions) => {
190
- if (conditions.length === 0) return "";
191
- if (conditions.length === 1) return conditions[0];
192
- return `(${conditions.join(" || ")})`;
193
- },
194
- not: (condition) => {
195
- return `!(${condition})`;
196
- },
197
- in: (field, values) => {
198
- const valueArray = Array.isArray(values) ? values : [values];
199
- const uniqueValues = [...new Set(valueArray)];
200
- const fieldStr = fieldPathToString(field);
201
- const conditions = uniqueValues.map((v) => `${fieldStr} = ${escapeValue(v)}`);
202
- return conditions.length > 1 ? `(${conditions.join(" || ")})` : conditions[0];
203
- },
204
- like: (field, value) => {
205
- return `${fieldPathToString(field)} ~ ${escapeValue(value)}`;
206
- },
207
- isNull: (field) => {
208
- return `${fieldPathToString(field)} = null`;
209
- },
210
- isUndefined: (field) => {
211
- return `${fieldPathToString(field)} = null`;
212
- }
213
- },
214
- onUnknownOperator: (operator, _args) => {
215
- throw new Error(
216
- `Unsupported operator '${operator}' for PocketBase filter conversion. Supported operators: eq, gt, gte, lt, lte, in, like, and, or, not, isNull, isUndefined`
217
- );
218
- }
219
- });
220
- return result || void 0;
221
- }
222
- function convertToPocketBaseSort(orderBy) {
223
- if (!orderBy) {
224
- return void 0;
225
- }
226
- const sorts = parseOrderByExpression(orderBy);
227
- if (sorts.length === 0) {
228
- return void 0;
229
- }
230
- return sorts.map((sort) => {
231
- const field = fieldPathToString(sort.field);
232
- return sort.direction === "desc" ? `-${field}` : field;
233
- }).join(",");
234
- }
235
-
236
- // src/build-collection.ts
237
- var viewPaths = /* @__PURE__ */ new WeakMap();
238
- var pendingFilings = /* @__PURE__ */ new WeakMap();
239
- function registerPendingFiling(target, field, settles) {
240
- let byField = pendingFilings.get(target);
241
- if (!byField) {
242
- byField = /* @__PURE__ */ new Map();
243
- pendingFilings.set(target, byField);
244
- }
245
- let pending = byField.get(field);
246
- if (!pending) {
247
- pending = [];
248
- byField.set(field, pending);
249
- }
250
- pending.push(settles);
251
- return () => {
252
- const index = pending.indexOf(settles);
253
- if (index !== -1) pending.splice(index, 1);
254
- if (pending.length === 0) byField.delete(field);
255
- if (byField.size === 0) pendingFilings.delete(target);
256
- };
257
- }
258
- async function awaitPendingFiling(target, field) {
259
- const pending = pendingFilings.get(target)?.get(field);
260
- if (!pending || pending.length === 0) return;
261
- await Promise.all(pending);
262
- }
263
- function registerMarkInvalidationEvents(collection, clearMarks) {
264
- collection.onStatusChange((event) => {
265
- if (event.status === "cleaned-up") clearMarks();
266
- });
267
- collection.onTruncate(() => clearMarks());
268
- }
269
- function buildCollection(input) {
270
- const { pb, queryClient, factoryOptions, collectionName, options } = input;
271
- const relationTargets = options?.relations;
272
- const alwaysFetch = normalizePaths(options?.alwaysFetchRelations ?? []);
273
- for (const path of alwaysFetch) validateExpandPath(collectionName, relationTargets, path);
274
- const syncMode = options?.syncMode ?? "eager";
275
- const requestedExpand = /* @__PURE__ */ new Set();
276
- function toRequest(opts) {
277
- const request = {};
278
- const subset = subsetFromWhere(opts?.where);
279
- const filter = subset ? void 0 : convertToPocketBaseFilter(opts?.where);
280
- const sort = convertToPocketBaseSort(opts?.orderBy);
281
- const expand = joinPaths(opts?.expand ?? []);
282
- if (subset) request.subset = subset;
283
- if (filter) request.filter = filter;
284
- if (sort) request.sort = sort;
285
- if (opts?.limit) request.limit = opts.limit;
286
- if (expand) request.expand = expand;
287
- return request;
288
- }
289
- function queryKeyFor(opts) {
290
- const request = toRequest(opts);
291
- return Object.keys(request).length === 0 ? [collectionName] : [collectionName, request];
292
- }
293
- function activeExpand(request) {
294
- return joinPaths([
295
- ...alwaysFetch,
296
- ...splitPaths(request.expand),
297
- ...syncMode === "eager" ? requestedExpand : []
298
- ]);
299
- }
300
- function backRelationTargetsFor(request) {
301
- if (!relationTargets) return [];
302
- const results = [];
303
- for (const key of splitPaths(activeExpand(request)).map((path) => path.split(".")[0])) {
304
- const target = relationTargets[key];
305
- const via = target && parseViaKey(key);
306
- if (target && via) results.push({ target, key, field: via.field });
307
- }
308
- return results;
309
- }
310
- function stripFetchedRelations(items, expandString) {
311
- const heads = new Set(splitPaths(expandString).map((path) => path.split(".")[0]));
312
- if (heads.size === 0) return items;
313
- return items.map((item) => {
314
- const { expand, ...plain } = item;
315
- if (!expand) return item;
316
- const kept = Object.fromEntries(
317
- Object.entries(expand).filter(([key]) => !heads.has(key))
318
- );
319
- return Object.keys(kept).length > 0 ? { ...plain, expand: kept } : plain;
320
- });
321
- }
322
- const ignoreAutoCancellation = options?.ignoreAutoCancellation ?? true;
323
- const refetchOnMutation = options?.refetchOnMutation ?? false;
324
- function eagerSyncInFlight(target) {
325
- return target.status !== void 0 && target.status !== "idle" && target.status !== "cleaned-up";
326
- }
327
- function withoutExpand(values) {
328
- return values.map((value) => {
329
- const { expand: _expand, ...plain } = value;
330
- return plain;
331
- });
332
- }
333
- async function upsertInto(key, target, values) {
334
- if (!target.utils) return false;
335
- if (!target.isReady()) {
336
- if (target.config?.syncMode === "on-demand") {
337
- await target._sync.startSync();
338
- } else if (target.preload && eagerSyncInFlight(target)) {
339
- await target.preload();
340
- } else {
341
- logger.warn(
342
- `not syncing ${key} on ${collectionName} because store is not yet ready`
343
- );
344
- return false;
345
- }
346
- }
347
- target.utils.writeUpsert(withoutExpand(values));
348
- return true;
349
- }
350
- async function upsertExpandedField(key, value, targets, parentId) {
351
- const target = targets[key];
352
- if (!target) {
353
- logger.debug("No relation target for expanded field", { collectionName, key });
354
- return;
355
- }
356
- const values = Array.isArray(value) ? value : [value];
357
- const filed = await upsertInto(key, target, values);
358
- await upsertExpanded(values, target.relationTargets);
359
- if (filed && parentId !== void 0) markFiledSubset(target, key, values, parentId);
360
- }
361
- async function upsertExpanded(records, targets) {
362
- if (!targets) return;
363
- for (const record of records) {
364
- const expandData = record.expand;
365
- if (!expandData) continue;
366
- const id = record.id;
367
- const parentId = typeof id === "string" ? id : void 0;
368
- for (const [key, value] of Object.entries(expandData)) {
369
- await upsertExpandedField(key, value, targets, parentId);
370
- }
371
- }
372
- }
373
- function markEmptyBackRelations(items, request) {
374
- const heads = backRelationTargetsFor(request);
375
- if (heads.length === 0) return;
376
- for (const item of items) {
377
- const { id, expand } = item;
378
- if (typeof id !== "string") continue;
379
- for (const { target, key, field } of heads) {
380
- if (expand?.[key] === void 0) target.markSubsetLoaded?.(field, id);
381
- }
382
- }
383
- }
384
- function subsetFilter({ field, values }) {
385
- return values.map((value) => `${field} = "${value.replace(/"/g, '\\"')}"`).join(" || ");
386
- }
387
- const loadedSubsets = /* @__PURE__ */ new Map();
388
- function markSubsetLoaded(field, value) {
389
- let values = loadedSubsets.get(field);
390
- if (!values) {
391
- values = /* @__PURE__ */ new Set();
392
- loadedSubsets.set(field, values);
393
- }
394
- values.add(value);
395
- }
396
- function loadedSubsetCount() {
397
- let count = 0;
398
- for (const values of loadedSubsets.values()) count += values.size;
399
- return count;
400
- }
401
- function subsetIsLoaded({ field, values }) {
402
- const marked = loadedSubsets.get(field);
403
- return marked !== void 0 && values.every((value) => marked.has(value));
404
- }
405
- function forgetMarkedValue(marked, value) {
406
- if (typeof value === "string") return marked.delete(value) ? [value] : [];
407
- if (!Array.isArray(value)) return [];
408
- return value.filter((item) => typeof item === "string" && marked.delete(item));
409
- }
410
- function matchesMarkedSubset(query, isMarked) {
411
- if (query.queryKey[0] !== collectionName) return false;
412
- const request = query.queryKey[1];
413
- if (!request?.subset) return false;
414
- const { field, values } = request.subset;
415
- return values.some((value) => isMarked(field, value));
416
- }
417
- function invalidateMarkedQueries(isMarked) {
418
- void queryClient.invalidateQueries({ predicate: (query) => matchesMarkedSubset(query, isMarked) }).catch(
419
- (error) => logger.error("Failed to invalidate marked subset queries", {
420
- collectionName,
421
- error
422
- })
423
- );
424
- }
425
- function invalidateForgottenSubset(field, values) {
426
- invalidateMarkedQueries(
427
- (queryField, value) => queryField === field && values.includes(value)
428
- );
429
- }
430
- function invalidateAllMarkedSubsets() {
431
- invalidateMarkedQueries((field, value) => loadedSubsets.get(field)?.has(value) ?? false);
432
- }
433
- function forgetMarksFor(row) {
434
- if (!row || typeof row !== "object") return;
435
- for (const [field, marked] of loadedSubsets) {
436
- const forgotten = forgetMarkedValue(marked, row[field]);
437
- if (forgotten.length > 0) {
438
- queueMicrotask(() => invalidateForgottenSubset(field, forgotten));
439
- }
440
- }
441
- }
442
- function rowsFromStore(ids) {
443
- const rows = [];
444
- for (const id of ids) {
445
- const row = collection._state.syncedData.get(id);
446
- if (!row) return void 0;
447
- rows.push(row);
448
- }
449
- return rows;
450
- }
451
- function idSubsetFromStore(subset, limit) {
452
- const present = rowsFromStore(subset.values);
453
- if (!present) return void 0;
454
- return limit ? present.slice(0, limit) : present;
455
- }
456
- function fieldSubsetFromStore(subset, limit) {
457
- if (!subsetIsLoaded(subset)) return void 0;
458
- const wanted = new Set(subset.values);
459
- const rows = [...collection._state.syncedData.values()].filter(
460
- (row) => matchesSubset(row, subset.field, wanted)
461
- );
462
- return limit ? rows.slice(0, limit) : rows;
463
- }
464
- function servedFromStore(request) {
465
- const { subset, sort, limit } = request;
466
- if (!subset) return void 0;
467
- if (subset.values.length === 0) return [];
468
- if (request.expand) return void 0;
469
- if (sort && limit) return void 0;
470
- return subset.field === "id" ? idSubsetFromStore(subset, limit) : fieldSubsetFromStore(subset, limit);
471
- }
472
- async function fetchItems(request) {
473
- const served = servedFromStore(request);
474
- if (served) return served;
475
- if (request.subset && request.subset.field !== "id" && backRelationTargetsFor(request).length === 0) {
476
- await awaitPendingFiling(collection, request.subset.field);
477
- const servedAfterFiling = servedFromStore(request);
478
- if (servedAfterFiling) return servedAfterFiling;
479
- }
480
- const { sort, limit, subset } = request;
481
- const filter = subset ? subsetFilter(subset) : request.filter;
482
- const expand = activeExpand(request);
483
- if (limit) {
484
- const result = await pb.collection(collectionName).getList(1, limit, {
485
- filter,
486
- sort,
487
- skipTotal: true,
488
- // Optimize by skipping total count
489
- expand
490
- });
491
- const items2 = result.items;
492
- markEmptyBackRelations(items2, request);
493
- return items2;
494
- }
495
- const items = await pb.collection(collectionName).getFullList({
496
- filter,
497
- sort,
498
- expand
499
- });
500
- markEmptyBackRelations(items, request);
501
- return items;
502
- }
503
- const inFlightConfirmedIds = /* @__PURE__ */ new Set();
504
- function markConfirmedPresent(records) {
505
- if (inFlightConfirmedIds.size === 0) return;
506
- for (const record of records) {
507
- const id = record?.id;
508
- if (typeof id !== "string") continue;
509
- for (const confirmed of inFlightConfirmedIds) confirmed.add(id);
510
- }
511
- }
512
- function withRowsConfirmedMidFlight(items, confirmedMidFlight) {
513
- if (confirmedMidFlight.size === 0) return items;
514
- const resultIds = new Set(
515
- items.map((item) => item?.id)
516
- );
517
- const mergedIds = [...confirmedMidFlight].filter(
518
- (id) => !resultIds.has(id) && collection._state.syncedData.has(id)
519
- );
520
- if (mergedIds.length === 0) return items;
521
- logger.debug("Merging rows confirmed while fetch was in flight", {
522
- collectionName,
523
- ids: mergedIds
524
- });
525
- return [
526
- ...items,
527
- ...mergedIds.map((id) => collection._state.syncedData.get(id))
528
- ];
529
- }
530
- async function fetchRecords(request, queryKey) {
531
- const confirmedMidFlight = /* @__PURE__ */ new Set();
532
- inFlightConfirmedIds.add(confirmedMidFlight);
533
- let settleFiling = () => void 0;
534
- const filed = new Promise((resolve) => {
535
- settleFiling = resolve;
536
- });
537
- const unregisterFilings = backRelationTargetsFor(request).map(
538
- ({ target, field }) => registerPendingFiling(target, field, filed)
539
- );
540
- try {
541
- let items;
542
- try {
543
- items = await fetchItems(request);
544
- } catch (error) {
545
- if (ignoreAutoCancellation && error instanceof Error && error.message.includes("autocancelled")) {
546
- return withRowsConfirmedMidFlight(
547
- queryClient.getQueryData(queryKey) ?? [],
548
- confirmedMidFlight
549
- );
550
- }
551
- throw error;
552
- }
553
- await upsertExpanded(items, relationTargets);
554
- return withRowsConfirmedMidFlight(
555
- stripFetchedRelations(items, activeExpand(request)),
556
- confirmedMidFlight
557
- );
558
- } finally {
559
- inFlightConfirmedIds.delete(confirmedMidFlight);
560
- settleFiling();
561
- for (const unregister of unregisterFilings) unregister();
562
- }
563
- }
564
- const queryCollectionConfig = queryCollectionOptions({
565
- autoIndex: "eager",
566
- defaultIndexType: BTreeIndex,
567
- ...options?.collectionOptions,
568
- queryClient,
569
- queryKey: queryKeyFor,
570
- syncMode,
571
- queryFn: async (ctx) => {
572
- const request = ctx.queryKey[1] ?? {};
573
- return fetchRecords(request, ctx.queryKey);
574
- },
575
- getKey: (item) => {
576
- const record = item;
577
- if (!record || typeof record !== "object" || !("id" in record)) {
578
- throw new Error(
579
- `Record in collection '${collectionName}' is missing required 'id' field. Received: ${JSON.stringify(item)}`
580
- );
581
- }
582
- return record.id;
583
- },
584
- onInsert: options?.onInsert === false ? void 0 : options?.onInsert ?? (async ({ transaction }) => {
585
- const created = await Promise.all(
586
- transaction.mutations.map(async (mutation) => {
587
- const {
588
- created: _created,
589
- updated: _updated,
590
- collectionId: _collectionId,
591
- collectionName: _collectionName,
592
- ...data
593
- } = mutation.modified;
594
- return pb.collection(collectionName).create(data);
595
- })
596
- );
597
- writeBackAfterPersisted(transaction, created);
598
- return { refetch: refetchOnMutation };
599
- }),
600
- onUpdate: options?.onUpdate === false ? void 0 : options?.onUpdate ?? (async ({ transaction }) => {
601
- const updated = await Promise.all(
602
- transaction.mutations.map(async (mutation) => {
603
- const recordWithId = mutation.original;
604
- return pb.collection(collectionName).update(recordWithId.id, mutation.changes);
605
- })
606
- );
607
- writeBackAfterPersisted(transaction, updated);
608
- return { refetch: refetchOnMutation };
609
- }),
610
- onDelete: options?.onDelete === false ? void 0 : options?.onDelete ?? (async ({ transaction }) => {
611
- await Promise.all(
612
- transaction.mutations.map(async (mutation) => {
613
- const recordWithId = mutation.original;
614
- await pb.collection(collectionName).delete(recordWithId.id);
615
- })
616
- );
617
- return { refetch: refetchOnMutation };
618
- })
619
- });
620
- const collectionOptions = options?.collectionOptions?.gcTime === void 0 ? queryCollectionConfig : { ...queryCollectionConfig, gcTime: options.collectionOptions.gcTime };
621
- function writeBackAfterPersisted(transaction, records) {
622
- markConfirmedPresent(records);
623
- void transaction.isPersisted.promise.then(
624
- () => {
625
- if (!collection.utils || !collection.isReady()) return;
626
- const fresh = records.filter((record) => !isStaleServerRecord(record));
627
- if (fresh.length === 0) return;
628
- writeOwn(() => collection.utils.writeUpsert(fresh));
629
- },
630
- // A rejected transaction rolled its draft back; there is
631
- // nothing to write and nothing to report here.
632
- () => void 0
633
- );
634
- }
635
- let applyingOwnWrite = false;
636
- function writeOwn(fn) {
637
- applyingOwnWrite = true;
638
- try {
639
- fn();
640
- } finally {
641
- applyingOwnWrite = false;
642
- }
643
- }
644
- function syncedWriteKey(op) {
645
- if (op.type !== "insert" && op.type !== "update") return null;
646
- if (typeof op.key === "string") return op.key;
647
- const id = op.value?.id;
648
- return typeof id === "string" ? id : null;
649
- }
650
- function shouldDropSyncedWrite(op) {
651
- const key = syncedWriteKey(op);
652
- if (key === null) return false;
653
- if (!applyingOwnWrite && hasPendingOptimisticMutation(key) && collection._state.syncedData.has(key)) {
654
- logger.debug("Dropping synced write for optimistically-pending row", {
655
- collectionName,
656
- id: key
657
- });
658
- return true;
659
- }
660
- if (isStaleServerRecord(op.value)) {
661
- logger.debug("Dropping stale synced write", { collectionName, id: key });
662
- return true;
663
- }
664
- return false;
665
- }
666
- let subscribingViewPaths;
667
- function withViewExpand(opts) {
668
- const paths = (opts.subscription && viewPaths.get(opts.subscription)) ?? subscribingViewPaths;
669
- return paths ? { ...opts, expand: paths } : opts;
670
- }
671
- const innerSync = collectionOptions.sync.sync;
672
- collectionOptions.sync = {
673
- ...collectionOptions.sync,
674
- sync: (params) => {
675
- const guardedWrite = (message) => {
676
- const op = message;
677
- if (op.type === "delete" && !applyingOwnWrite) forgetMarksFor(op.value);
678
- if (shouldDropSyncedWrite(op)) return;
679
- return params.write(message);
680
- };
681
- const res = innerSync({ ...params, write: guardedWrite });
682
- if (!res || typeof res === "function") return res;
683
- const { loadSubset, unloadSubset } = res;
684
- return {
685
- ...res,
686
- loadSubset: loadSubset ? (opts) => loadSubset(withViewExpand(opts)) : void 0,
687
- unloadSubset: unloadSubset ? (opts) => unloadSubset(withViewExpand(opts)) : void 0
688
- };
689
- }
690
- };
691
- const collection = createCollection$1(collectionOptions);
692
- const views = /* @__PURE__ */ new Map();
693
- function createView(paths) {
694
- const view = Object.create(collection);
695
- Object.defineProperties(view, {
696
- id: { value: `${collectionName}?expand=${paths.join(",")}` },
697
- subscribeChanges: {
698
- value: (...args) => {
699
- noteViewSubscribed(paths);
700
- subscribingViewPaths = paths;
701
- try {
702
- const subscription = collection.subscribeChanges(...args);
703
- viewPaths.set(subscription, paths);
704
- return subscription;
705
- } finally {
706
- subscribingViewPaths = void 0;
707
- }
708
- }
709
- },
710
- fetchRelations: {
711
- value: () => {
712
- throw new Error(`A view of "${collectionName}" cannot fetch further relations`);
713
- }
714
- }
715
- });
716
- return view;
717
- }
718
- function fetchRelations(...paths) {
719
- for (const path of paths) validateExpandPath(collectionName, relationTargets, path);
720
- const all = normalizePaths([...alwaysFetch, ...paths]);
721
- if (all.every((path) => alwaysFetch.includes(path))) return collection;
722
- const key = all.join(",");
723
- let view = views.get(key);
724
- if (!view) {
725
- view = createView(all);
726
- views.set(key, view);
727
- }
728
- return view;
729
- }
730
- function hasPendingOptimisticMutation(key) {
731
- const state = collection._state;
732
- return state.optimisticUpserts.has(key) || state.optimisticDeletes.has(key);
733
- }
734
- function recordUpdatedAt(record) {
735
- const updated = record?.updated;
736
- return typeof updated === "string" && updated !== "" ? updated : void 0;
737
- }
738
- function isStaleServerRecord(record) {
739
- const id = record?.id;
740
- if (typeof id !== "string") return false;
741
- const incoming = recordUpdatedAt(record);
742
- if (!incoming) return false;
743
- const current = recordUpdatedAt(
744
- collection._state.syncedData.get(id)
745
- );
746
- if (current === void 0) return false;
747
- return incoming < current;
748
- }
749
- function isStaleEcho(event) {
750
- if (event.action !== "create" && event.action !== "update") return false;
751
- if (!isStaleServerRecord(event.record)) return false;
752
- logger.debug("Ignoring stale realtime echo", {
753
- collectionName,
754
- id: event.record?.id
755
- });
756
- return true;
757
- }
758
- let unsubscribeFn = null;
759
- let isSubscribed = false;
760
- let subscriptionPromise = null;
761
- let subscriptionResolve = null;
762
- let subscribedExpand;
763
- let subscriptionWork = Promise.resolve();
764
- function enqueueSubscriptionWork(fn) {
765
- const run = subscriptionWork.then(fn, fn).catch((error) => {
766
- logger.error("Subscription work failed", { collectionName, error });
767
- });
768
- subscriptionWork = run;
769
- return run;
770
- }
771
- function markEchoPresence(event) {
772
- if (event.action !== "delete") markConfirmedPresent([event.record]);
773
- }
774
- const handleRealtimeEvent = (event) => {
775
- if (!collection.utils) return;
776
- markEchoPresence(event);
777
- if (isStaleEcho(event)) return;
778
- const [stored] = stripFetchedRelations([event.record], pendingSubscribeExpand());
779
- try {
780
- writeOwn(
781
- () => collection.utils.writeBatch(() => {
782
- switch (event.action) {
783
- case "create":
784
- collection.utils.writeInsert(stored);
785
- break;
786
- case "update":
787
- collection.utils.writeUpsert(stored);
788
- break;
789
- case "delete":
790
- if (event.record && "id" in event.record) {
791
- collection.utils.writeDelete(event.record.id);
792
- }
793
- break;
794
- }
795
- })
796
- );
797
- } catch (error) {
798
- if (error instanceof DeleteOperationItemNotFoundError) {
799
- logger.debug("Ignoring delete echo for already-removed record", {
800
- collectionName,
801
- id: event.record?.id
802
- });
803
- } else {
804
- throw error;
805
- }
806
- }
807
- if (event.action !== "delete") {
808
- upsertExpanded([event.record], relationTargets).catch(
809
- (error) => logger.error("Failed to upsert expanded records from realtime echo", {
810
- collectionName,
811
- error
812
- })
813
- );
814
- }
815
- };
816
- function pendingSubscribeExpand() {
817
- return joinPaths([...alwaysFetch, ...requestedExpand]);
818
- }
819
- function activeExpandTargets() {
820
- const targets = /* @__PURE__ */ new Set();
821
- for (const path of splitPaths(pendingSubscribeExpand())) {
822
- let current = relationTargets;
823
- for (const segment of path.split(".")) {
824
- const target = current?.[segment];
825
- if (!target) break;
826
- targets.add(target);
827
- current = target.relationTargets;
828
- }
829
- }
830
- return targets;
831
- }
832
- const heldTargetSubscriptions = /* @__PURE__ */ new Map();
833
- function releaseHeldTarget(target) {
834
- const held = heldTargetSubscriptions.get(target);
835
- if (!held) return;
836
- heldTargetSubscriptions.delete(target);
837
- try {
838
- held.unsubscribe();
839
- } catch (error) {
840
- logger.error("Failed to release relation target subscription", {
841
- collectionName,
842
- error
843
- });
844
- }
845
- }
846
- function holdTarget(target) {
847
- if (heldTargetSubscriptions.has(target) || !target.subscribeChanges) return;
848
- try {
849
- const held = target.subscribeChanges(() => {
850
- }, { includeInitialState: false });
851
- heldTargetSubscriptions.set(target, held);
852
- } catch (error) {
853
- logger.error("Failed to hold relation target subscription", { collectionName, error });
854
- }
855
- }
856
- function syncHeldSubscriptions(desired) {
857
- for (const target of [...heldTargetSubscriptions.keys()]) {
858
- if (!desired.has(target)) releaseHeldTarget(target);
859
- }
860
- for (const target of desired) holdTarget(target);
861
- }
862
- function realtimeSubscribeOptions() {
863
- const base = factoryOptions?.subscribeOptions?.();
864
- const expand = joinPaths([
865
- ...splitPaths(pendingSubscribeExpand()),
866
- ...splitPaths(base?.expand)
867
- ]);
868
- return expand ? { ...base, expand } : base;
869
- }
870
- const doStartSubscription = async () => {
871
- if (isSubscribed) return;
872
- if (!subscriptionPromise) {
873
- subscriptionPromise = new Promise((resolve) => {
874
- subscriptionResolve = resolve;
875
- });
876
- }
877
- const pendingExpand = pendingSubscribeExpand();
878
- try {
879
- unsubscribeFn = await pb.collection(collectionName).subscribe("*", handleRealtimeEvent, realtimeSubscribeOptions());
880
- isSubscribed = true;
881
- subscribedExpand = pendingExpand;
882
- syncHeldSubscriptions(activeExpandTargets());
883
- logger.debug("Subscription started", { collectionName });
884
- if (subscriptionResolve) {
885
- subscriptionResolve();
886
- }
887
- } catch (error) {
888
- logger.error("Failed to start subscription", { collectionName, error });
889
- }
890
- if (pendingSubscribeExpand() !== subscribedExpand) {
891
- enqueueSubscriptionWork(doRestartSubscription).catch(() => {
892
- });
893
- }
894
- };
895
- const doStopSubscription = async (releaseTargets = true) => {
896
- if (releaseTargets) {
897
- syncHeldSubscriptions(/* @__PURE__ */ new Set());
898
- invalidateAllMarkedSubsets();
899
- loadedSubsets.clear();
900
- }
901
- if (!isSubscribed || !unsubscribeFn) return;
902
- try {
903
- await unsubscribeFn();
904
- logger.debug("Subscription stopped", { collectionName });
905
- } catch (error) {
906
- logger.debug("Unsubscribe failed (expected if connection closed)", {
907
- collectionName,
908
- error
909
- });
910
- } finally {
911
- unsubscribeFn = null;
912
- isSubscribed = false;
913
- subscribedExpand = void 0;
914
- subscriptionPromise = null;
915
- subscriptionResolve = null;
916
- }
917
- };
918
- const doRestartSubscription = async () => {
919
- if (!isSubscribed) return;
920
- await doStopSubscription(false);
921
- await doStartSubscription();
922
- };
923
- const startSubscription = () => enqueueSubscriptionWork(doStartSubscription);
924
- const stopSubscription = () => enqueueSubscriptionWork(() => doStopSubscription());
925
- const restartSubscription = () => enqueueSubscriptionWork(doRestartSubscription);
926
- function noteViewSubscribed(paths) {
927
- let grew = false;
928
- for (const path of paths) {
929
- if (requestedExpand.has(path)) continue;
930
- requestedExpand.add(path);
931
- grew = true;
932
- }
933
- if (!grew) return;
934
- if (syncMode === "eager" && collection.status !== "idle" && collection.status !== "cleaned-up") {
935
- void collection.utils.refetch().catch(
936
- (error) => logger.error("Failed to refetch after widening expand", {
937
- collectionName,
938
- error
939
- })
940
- );
941
- }
942
- restartSubscription().catch(
943
- (error) => logger.error("Failed to restart subscription with wider expand", {
944
- collectionName,
945
- error
946
- })
947
- );
948
- }
949
- const waitForSubscription = async (timeout = 5e3) => {
950
- if (isSubscribed) return;
951
- if (!subscriptionPromise) {
952
- await new Promise((resolve) => {
953
- const checkInterval = setInterval(() => {
954
- if (subscriptionPromise) {
955
- clearInterval(checkInterval);
956
- resolve();
957
- }
958
- }, 10);
959
- setTimeout(() => {
960
- clearInterval(checkInterval);
961
- resolve();
962
- }, timeout);
963
- });
964
- }
965
- if (subscriptionPromise) {
966
- await Promise.race([
967
- subscriptionPromise,
968
- new Promise(
969
- (_, reject) => setTimeout(() => reject(new Error("Subscription timeout")), timeout)
970
- )
971
- ]);
972
- }
973
- };
974
- collection.on(
975
- "subscribers:change",
976
- (event) => {
977
- const newCount = event.subscriberCount;
978
- const previousCount = event.previousSubscriberCount;
979
- if (newCount > 0 && previousCount === 0) {
980
- startSubscription().catch(() => {
981
- });
982
- } else if (newCount === 0 && previousCount > 0) {
983
- stopSubscription().catch(() => {
984
- });
985
- }
986
- }
987
- );
988
- registerMarkInvalidationEvents(
989
- {
990
- onStatusChange: (callback) => collection.on("status:change", callback),
991
- onTruncate: (callback) => collection.on("truncate", callback)
992
- },
993
- () => loadedSubsets.clear()
994
- );
995
- Object.assign(collection, {
996
- collectionName,
997
- relationTargets,
998
- waitForSubscription,
999
- isSubscribed: () => isSubscribed,
1000
- heldRelationTargetCount: () => heldTargetSubscriptions.size,
1001
- markSubsetLoaded,
1002
- loadedSubsetCount,
1003
- fetchRelations
1004
- });
1005
- return collection;
1006
- }
1007
-
1008
- // src/collection.ts
1009
- function createCollection(pb, queryClient, factoryOptions) {
1010
- return (collectionName, options) => {
1011
- return buildCollection({
1012
- pb,
1013
- queryClient,
1014
- factoryOptions,
1015
- collectionName,
1016
- options
1017
- });
1018
- };
1019
- }
1020
-
1021
- // src/util.ts
1022
- function newRecordId() {
1023
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
1024
- let result = "";
1025
- for (let i = 0; i < 15; i++) {
1026
- result += chars.charAt(Math.floor(Math.random() * chars.length));
1027
- }
1028
- return result;
1029
- }
1030
-
1031
- export { createCollection, newRecordId, resetLogger, setLogger };
1032
- //# sourceMappingURL=chunk-I6UWXRAH.js.map
1033
- //# sourceMappingURL=chunk-I6UWXRAH.js.map