powersync-bridge 0.0.1-alpha.0 → 0.0.1-alpha.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/dist/index.cjs CHANGED
@@ -24,6 +24,68 @@ async function withTimeout(clock, p, ms, signal) {
24
24
  }
25
25
  }
26
26
 
27
+ // src/keyset.ts
28
+ function keyColumns(orderKey) {
29
+ if (typeof orderKey === "string") return [orderKey, "id"];
30
+ return [orderKey[0], orderKey[1]];
31
+ }
32
+ function idOf(row) {
33
+ const r = row;
34
+ if (r == null || r.id === void 0 || r.id === null) {
35
+ throw new Error("powersync-bridge: every row must have an `id`");
36
+ }
37
+ return String(r.id);
38
+ }
39
+ function compareValues(a, b) {
40
+ if (a === b) return 0;
41
+ if (a === null || a === void 0) return 1;
42
+ if (b === null || b === void 0) return -1;
43
+ if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
44
+ if (typeof a === "number" && typeof b === "number") return a - b;
45
+ if (typeof a === "boolean" && typeof b === "boolean") return Number(a) - Number(b);
46
+ const sa = String(a);
47
+ const sb = String(b);
48
+ return sa < sb ? -1 : sa > sb ? 1 : 0;
49
+ }
50
+ function rowComparator(orderKey, direction = "asc") {
51
+ const [k1, k2] = keyColumns(orderKey);
52
+ const sign = direction === "desc" ? -1 : 1;
53
+ return (a, b) => {
54
+ const c1 = compareValues(a[k1], b[k1]);
55
+ if (c1 !== 0) return c1 * sign;
56
+ return compareValues(a[k2], b[k2]) * sign;
57
+ };
58
+ }
59
+ function cursorOfRow(row, orderKey) {
60
+ const r = row;
61
+ if (typeof orderKey === "string") return r[orderKey];
62
+ return [r[orderKey[0]], r[orderKey[1]]];
63
+ }
64
+ function isAfterCursor(row, cursor, orderKey, direction = "asc") {
65
+ const [k1, k2] = keyColumns(orderKey);
66
+ const r = row;
67
+ const sign = direction === "desc" ? -1 : 1;
68
+ const [c1, c2] = typeof orderKey === "string" ? [cursor, void 0] : cursor;
69
+ const d1 = compareValues(r[k1], c1) * sign;
70
+ if (d1 !== 0) return d1 > 0;
71
+ if (c2 === void 0) return false;
72
+ return compareValues(r[k2], c2) * sign > 0;
73
+ }
74
+ function toBase64(s) {
75
+ if (typeof btoa === "function") return btoa(unescape(encodeURIComponent(s)));
76
+ return Buffer.from(s, "utf8").toString("base64");
77
+ }
78
+ function fromBase64(s) {
79
+ if (typeof atob === "function") return decodeURIComponent(escape(atob(s)));
80
+ return Buffer.from(s, "base64").toString("utf8");
81
+ }
82
+ function encodeCursor(cursor) {
83
+ return toBase64(JSON.stringify(cursor));
84
+ }
85
+ function decodeCursor(opaque) {
86
+ return JSON.parse(fromBase64(opaque));
87
+ }
88
+
27
89
  // src/removal.ts
28
90
  var PROBE_CHUNK = 500;
29
91
  async function probePresence(driver, table, ids) {
@@ -75,13 +137,14 @@ var SnapshotStore = class {
75
137
  constructor(initial) {
76
138
  this.snapshot = initial;
77
139
  }
78
- getSnapshot() {
140
+ // React invokes external-store callbacks as standalone functions.
141
+ getSnapshot = () => {
79
142
  return this.snapshot;
80
- }
81
- subscribe(listener) {
143
+ };
144
+ subscribe = (listener) => {
82
145
  this.listeners.add(listener);
83
146
  return () => this.listeners.delete(listener);
84
- }
147
+ };
85
148
  replace(next) {
86
149
  if (Object.is(next, this.snapshot)) return;
87
150
  this.snapshot = next;
@@ -112,6 +175,496 @@ function shallowEqualResult(a, b) {
112
175
  return true;
113
176
  }
114
177
 
178
+ // src/query.ts
179
+ var infiniteQueryKey = (options) => JSON.stringify([
180
+ options.queryKey,
181
+ options.orderBy,
182
+ options.direction ?? "desc",
183
+ options.pageSize ?? 25,
184
+ options.maxRows ?? 500,
185
+ options.enabled !== false
186
+ ]);
187
+ var OfflineError = class extends Error {
188
+ name = "OfflineError";
189
+ constructor() {
190
+ super("Additional records require a connection.");
191
+ }
192
+ };
193
+ var InfiniteQueryStore = class {
194
+ constructor(deps, options) {
195
+ this.deps = deps;
196
+ this.options = options;
197
+ this.pageSize = options.pageSize ?? 25;
198
+ this.maxRows = options.maxRows ?? 500;
199
+ if (!Number.isInteger(this.pageSize) || this.pageSize < 1 || !Number.isInteger(this.maxRows) || this.maxRows < this.pageSize)
200
+ throw new Error("powersync-bridge: pageSize must be positive and maxRows must be at least pageSize");
201
+ this.target = this.pageSize;
202
+ this.compare = rowComparator(options.orderBy, options.direction ?? "desc");
203
+ this.table = options.table;
204
+ this.offline = this.readOffline();
205
+ this.store = new SnapshotStore(this.result());
206
+ }
207
+ deps;
208
+ options;
209
+ store;
210
+ pageSize;
211
+ maxRows;
212
+ compare;
213
+ table;
214
+ target;
215
+ anchor;
216
+ revision = 0;
217
+ local = /* @__PURE__ */ new Map();
218
+ retained = /* @__PURE__ */ new Map();
219
+ tombstones = /* @__PURE__ */ new Set();
220
+ filtered = /* @__PURE__ */ new Set();
221
+ inSession = /* @__PURE__ */ new Set();
222
+ pages = [];
223
+ localPending = true;
224
+ localStamp = Number.NEGATIVE_INFINITY;
225
+ localError;
226
+ apiError;
227
+ apiPending = false;
228
+ apiFailed = false;
229
+ offline;
230
+ generation = 0;
231
+ watchGeneration = 0;
232
+ lastLocalWatch = 0;
233
+ refs = 0;
234
+ ready = false;
235
+ disposed = false;
236
+ unwatch;
237
+ stops = [];
238
+ request;
239
+ requestKey;
240
+ get isDisposed() {
241
+ return this.disposed || this.refs <= 0 && this.unwatch !== void 0;
242
+ }
243
+ retain() {
244
+ this.refs += 1;
245
+ if (this.refs !== 1 || this.unwatch || this.options.enabled === false) return;
246
+ this.stops = [
247
+ this.deps.crud.onChange(() => this.render()),
248
+ this.deps.freshness.onCompletion(() => void this.checkpoint()),
249
+ this.deps.driver.onStatusChanged(() => this.networkChanged()),
250
+ this.deps.connectivity.subscribe(() => this.networkChanged())
251
+ ];
252
+ this.watch();
253
+ void this.deps.crud.ready().then(() => {
254
+ this.ready = true;
255
+ this.pump();
256
+ }).catch((error) => {
257
+ if (this.disposed) return;
258
+ this.localPending = false;
259
+ this.localError = error instanceof Error ? error : new Error(String(error));
260
+ this.render();
261
+ });
262
+ }
263
+ release() {
264
+ this.refs -= 1;
265
+ if (this.refs <= 0)
266
+ queueMicrotask(() => {
267
+ if (this.refs <= 0) this.dispose();
268
+ });
269
+ }
270
+ swapRoot() {
271
+ this.disposed = true;
272
+ this.local.clear();
273
+ this.retained.clear();
274
+ this.clearPages();
275
+ this.render(true);
276
+ }
277
+ dispose() {
278
+ this.disposed = true;
279
+ this.generation += 1;
280
+ this.watchGeneration += 1;
281
+ this.cancelRequest();
282
+ this.unwatch?.();
283
+ this.unwatch = void 0;
284
+ for (const stop of this.stops) stop();
285
+ this.stops = [];
286
+ this.clearPages();
287
+ this.local.clear();
288
+ this.retained.clear();
289
+ this.tombstones.clear();
290
+ this.filtered.clear();
291
+ this.inSession.clear();
292
+ this.render(true);
293
+ }
294
+ readOffline() {
295
+ return (this.deps.connectivity.getSnapshot() ?? this.deps.driver.onlineHint()) === false;
296
+ }
297
+ cancelRequest() {
298
+ this.request?.abort();
299
+ if (this.requestKey) this.deps.snapshots.invalidate(this.requestKey);
300
+ this.requestKey = void 0;
301
+ }
302
+ networkChanged() {
303
+ if (this.disposed) return;
304
+ const offline = this.readOffline();
305
+ const reconnect = this.offline && !offline;
306
+ this.offline = offline;
307
+ if (offline && this.request) {
308
+ this.cancelRequest();
309
+ this.generation += 1;
310
+ this.apiPending = false;
311
+ this.request = void 0;
312
+ }
313
+ if (reconnect) {
314
+ this.apiFailed = false;
315
+ this.apiError = void 0;
316
+ this.pump();
317
+ }
318
+ this.render();
319
+ }
320
+ identity(row) {
321
+ if (this.options.getRowIdentity) return this.options.getRowIdentity(row);
322
+ if (!this.table)
323
+ throw new Error(
324
+ "powersync-bridge: provide table or getRowIdentity when the adapter cannot infer the business table"
325
+ );
326
+ return { table: this.table, id: idOf(row) };
327
+ }
328
+ pending(row) {
329
+ const { table, id } = this.identity(row);
330
+ return Boolean(
331
+ this.deps.crud.pendingOp(table, id) || this.pages.some((page) => this.deps.crud.pendingOp(table, id, page.window))
332
+ );
333
+ }
334
+ localBuilder() {
335
+ return this.options.query({ cursor: this.anchor, limit: this.target + 1 });
336
+ }
337
+ watch() {
338
+ this.unwatch?.();
339
+ const token = ++this.watchGeneration;
340
+ const generation = this.generation;
341
+ this.localPending = true;
342
+ let prepared;
343
+ try {
344
+ prepared = this.deps.adapter.prepare(this.localBuilder());
345
+ if (!this.table && prepared.tables.length === 1) this.table = prepared.tables[0];
346
+ if (!this.table && !this.options.getRowIdentity)
347
+ throw new Error(
348
+ "powersync-bridge: provide table or getRowIdentity when the adapter cannot infer the business table"
349
+ );
350
+ } catch (error) {
351
+ this.localPending = false;
352
+ this.localError = error instanceof Error ? error : new Error(String(error));
353
+ this.render();
354
+ return;
355
+ }
356
+ this.unwatch = this.deps.driver.diffs(prepared).subscribe(
357
+ (diff) => {
358
+ void this.acceptLocal(diff.all, token, generation).catch((error) => {
359
+ if (token !== this.watchGeneration || generation !== this.generation || this.disposed) return;
360
+ this.localPending = false;
361
+ this.localError = error instanceof Error ? error : new Error(String(error));
362
+ this.render();
363
+ });
364
+ },
365
+ (error) => {
366
+ if (token !== this.watchGeneration || generation !== this.generation || this.disposed) return;
367
+ this.localPending = false;
368
+ this.localError = error;
369
+ this.render();
370
+ }
371
+ );
372
+ this.render();
373
+ }
374
+ async acceptLocal(rows, token, generation) {
375
+ await this.deps.crud.resync();
376
+ if (token !== this.watchGeneration || generation !== this.generation || this.disposed) return;
377
+ const previous = this.local;
378
+ const firstEmission = this.lastLocalWatch !== token;
379
+ this.lastLocalWatch = token;
380
+ const next = new Map(rows.map((row) => [idOf(row), row]));
381
+ const removed = /* @__PURE__ */ new Map();
382
+ for (const [id, row] of previous)
383
+ if (!next.has(id)) {
384
+ removed.set(id, row);
385
+ this.tombstones.add(id);
386
+ }
387
+ for (const [id, row] of next) {
388
+ if (previous.size && (!firstEmission || previous.has(id)) && JSON.stringify(previous.get(id)) !== JSON.stringify(row) && !this.pending(row))
389
+ this.inSession.add(id);
390
+ this.tombstones.delete(id);
391
+ this.filtered.delete(id);
392
+ this.retained.delete(id);
393
+ }
394
+ this.local = next;
395
+ this.localPending = false;
396
+ this.localError = void 0;
397
+ this.localStamp = this.deps.freshness.freshAtFor(void 0);
398
+ this.render();
399
+ const groups = /* @__PURE__ */ new Map();
400
+ for (const row of removed.values()) {
401
+ const identity = this.identity(row);
402
+ const group = groups.get(identity.table) ?? /* @__PURE__ */ new Map();
403
+ group.set(identity.id, row);
404
+ groups.set(identity.table, group);
405
+ }
406
+ try {
407
+ for (const [table, group] of groups) {
408
+ const classes = await classifyRemovals(
409
+ {
410
+ driver: this.deps.driver,
411
+ clock: this.deps.clock,
412
+ table,
413
+ provenance: () => void 0,
414
+ isInWindow: this.options.isInWindow
415
+ },
416
+ group
417
+ );
418
+ if (token !== this.watchGeneration || generation !== this.generation || this.disposed) return;
419
+ for (const row of removed.values()) {
420
+ const identity = this.identity(row);
421
+ if (identity.table !== table || this.local.has(row.id)) continue;
422
+ const classification = classes.get(identity.id);
423
+ if (!classification) continue;
424
+ if (classification === "delete") this.tombstones.add(row.id);
425
+ else if (classification === "filtered") {
426
+ this.tombstones.delete(row.id);
427
+ this.filtered.add(row.id);
428
+ } else {
429
+ this.tombstones.delete(row.id);
430
+ this.retained.set(row.id, row);
431
+ }
432
+ }
433
+ }
434
+ } catch (error) {
435
+ if (token !== this.watchGeneration || generation !== this.generation || this.disposed) return;
436
+ this.localError = error instanceof Error ? error : new Error(String(error));
437
+ }
438
+ const retained = [...this.retained.values()].sort(this.compare).slice(0, this.maxRows);
439
+ this.retained.clear();
440
+ for (const row of retained) this.retained.set(row.id, row);
441
+ const known = /* @__PURE__ */ new Set([
442
+ ...this.local.keys(),
443
+ ...this.retained.keys(),
444
+ ...this.pages.flatMap((page) => page.rows.map((row) => row.id))
445
+ ]);
446
+ for (const set of [this.inSession, this.tombstones, this.filtered]) {
447
+ if (set.size > this.maxRows * 4) {
448
+ for (const id of set) if (!known.has(id)) set.delete(id);
449
+ }
450
+ }
451
+ this.render();
452
+ }
453
+ async checkpoint() {
454
+ if (this.disposed || !this.unwatch) return;
455
+ const token = this.watchGeneration;
456
+ const generation = this.generation;
457
+ try {
458
+ const rows = await this.deps.driver.query(this.deps.adapter.prepare(this.localBuilder()));
459
+ await this.acceptLocal(rows, token, generation);
460
+ } catch (error) {
461
+ if (token !== this.watchGeneration || generation !== this.generation || this.disposed) return;
462
+ this.localError = error instanceof Error ? error : new Error(String(error));
463
+ this.render();
464
+ }
465
+ }
466
+ clearPages() {
467
+ for (const page of this.pages) this.deps.crud.closeWindow(page.window);
468
+ this.pages = [];
469
+ }
470
+ apiEnd() {
471
+ return this.pages.at(-1)?.end === true;
472
+ }
473
+ apiCount() {
474
+ return this.pages.reduce((count, page) => count + page.rows.length, 0);
475
+ }
476
+ apiCursor() {
477
+ const last = this.pages.at(-1)?.rows.at(-1);
478
+ return last ? last : this.anchor;
479
+ }
480
+ cursorKey(row) {
481
+ if (!row) return void 0;
482
+ const [first, second] = keyColumns(this.options.orderBy);
483
+ return [row[first], row[second]];
484
+ }
485
+ pump() {
486
+ if (!this.ready || this.disposed || this.options.enabled === false || this.offline || this.apiPending || this.apiFailed || this.apiEnd())
487
+ return;
488
+ if (!this.table && !this.options.getRowIdentity) return;
489
+ if (this.pages.length >= Math.ceil(this.maxRows / this.pageSize) + 1) return;
490
+ if (this.apiCount() >= this.target && this.entries().length >= this.target) return;
491
+ const cursor = this.apiCursor();
492
+ const generation = this.generation;
493
+ const request = new AbortController();
494
+ this.request = request;
495
+ const window = this.deps.crud.openWindow();
496
+ this.apiPending = true;
497
+ this.render();
498
+ const key = [
499
+ "powersync-bridge.query",
500
+ this.deps.identityKey,
501
+ this.deps.epoch,
502
+ infiniteQueryKey(this.options),
503
+ this.cursorKey(this.anchor),
504
+ this.revision,
505
+ generation,
506
+ this.cursorKey(cursor)
507
+ ];
508
+ this.requestKey = key;
509
+ void this.deps.snapshots.fetch(key, this.options.staleTime ?? 6e4, async (signal) => {
510
+ const abort = () => request.abort();
511
+ signal.addEventListener("abort", abort, { once: true });
512
+ try {
513
+ return await withTimeout(
514
+ this.deps.clock,
515
+ this.options.fetchPage({ cursor, limit: this.pageSize + 1, signal: request.signal }).then((rows) => ({ rows })),
516
+ 3e4,
517
+ request.signal
518
+ );
519
+ } finally {
520
+ signal.removeEventListener("abort", abort);
521
+ }
522
+ }).then((record) => {
523
+ if (this.disposed || generation !== this.generation || request.signal.aborted) {
524
+ this.deps.crud.closeWindow(window);
525
+ return;
526
+ }
527
+ const rows = [...record.rows];
528
+ for (let index = 1; index < rows.length; index++) {
529
+ const before = rows[index - 1];
530
+ const row = rows[index];
531
+ if (before && row && this.compare(before, row) >= 0)
532
+ throw new Error("powersync-bridge: API rows must use a strict, stable order with an id tiebreaker");
533
+ }
534
+ if (cursor !== void 0 && rows.some((row) => this.compare(row, cursor) <= 0))
535
+ throw new Error("powersync-bridge: API page must advance beyond the supplied cursor");
536
+ this.pages.push({
537
+ cursor,
538
+ rows: rows.slice(0, this.pageSize),
539
+ end: rows.length <= this.pageSize,
540
+ fetchedAt: record.fetchedAt,
541
+ window
542
+ });
543
+ this.apiError = void 0;
544
+ }).catch((error) => {
545
+ this.deps.crud.closeWindow(window);
546
+ if (this.disposed || generation !== this.generation || request.signal.aborted) return;
547
+ this.apiFailed = true;
548
+ this.apiError = error instanceof Error ? error : new Error(String(error));
549
+ request.abort();
550
+ }).finally(() => {
551
+ if (generation !== this.generation || this.disposed) return;
552
+ this.apiPending = false;
553
+ this.request = void 0;
554
+ this.requestKey = void 0;
555
+ this.render();
556
+ this.pump();
557
+ });
558
+ }
559
+ entries() {
560
+ const api = /* @__PURE__ */ new Map();
561
+ for (const page of this.pages) for (const row of page.rows) api.set(row.id, { row, page });
562
+ const ids = /* @__PURE__ */ new Set([...this.local.keys(), ...this.retained.keys(), ...api.keys()]);
563
+ const entries = [];
564
+ for (const id of ids) {
565
+ if (this.tombstones.has(id) || this.filtered.has(id)) continue;
566
+ const local = this.local.get(id);
567
+ const remote = api.get(id);
568
+ const row = local ?? remote?.row ?? this.retained.get(id);
569
+ if (!row) continue;
570
+ if (this.pending(row)) {
571
+ if (local) entries.push({ row: local, origin: "local", presentLocally: true });
572
+ continue;
573
+ }
574
+ if (remote) {
575
+ entries.push(
576
+ local && this.localStamp > remote.page.fetchedAt ? { row: local, origin: "local", presentLocally: true } : { row: remote.row, origin: "api", presentLocally: Boolean(local) }
577
+ );
578
+ } else if (local) {
579
+ const covering = this.pages.find((page) => {
580
+ if (page.cursor !== void 0 && this.compare(local, page.cursor) <= 0) return false;
581
+ const last = page.rows.at(-1);
582
+ return page.end || Boolean(last && this.compare(local, last) <= 0);
583
+ });
584
+ if (!covering || this.inSession.has(id) || this.localStamp > covering.fetchedAt)
585
+ entries.push({ row: local, origin: "local", presentLocally: true });
586
+ } else entries.push({ row, origin: "local", presentLocally: false });
587
+ }
588
+ return entries.sort((a, b) => this.compare(a.row, b.row));
589
+ }
590
+ result(empty = false) {
591
+ const enabled = !empty && this.options.enabled !== false;
592
+ const entries = enabled ? this.entries() : [];
593
+ const items = entries.slice(0, this.target);
594
+ const more = entries.length > this.target || !this.apiEnd();
595
+ const missing = items.length < this.target;
596
+ const errors = {};
597
+ if (this.localError) errors.local = this.localError;
598
+ if (this.apiError) errors.api = this.apiError;
599
+ const offlineError = this.offline && missing && !this.localPending ? new OfflineError() : void 0;
600
+ const error = enabled && missing && !this.localPending && !this.apiPending ? this.localError ?? this.apiError ?? offlineError : void 0;
601
+ return {
602
+ data: items.map((item) => item.row),
603
+ items,
604
+ isLoading: enabled && items.length === 0 && !error && (this.localPending || this.apiPending),
605
+ isFetching: enabled && (this.localPending || this.apiPending),
606
+ isFetchingNextPage: enabled && this.target > this.pageSize && (this.localPending || this.apiPending),
607
+ hasNextPage: enabled && this.target < this.maxRows && more,
608
+ needsOlderWindow: enabled && items.length >= this.maxRows && more,
609
+ error,
610
+ errors,
611
+ isError: Boolean(error),
612
+ windowRevision: this.revision
613
+ };
614
+ }
615
+ render(empty = false) {
616
+ if (this.disposed && !empty) return;
617
+ const result = this.result(empty);
618
+ if (!shallowEqualResult(
619
+ this.store.getSnapshot(),
620
+ result
621
+ ))
622
+ this.store.replace(result);
623
+ }
624
+ fetchNextPage = () => {
625
+ if (this.disposed || this.options.enabled === false || this.target >= this.maxRows) return;
626
+ if (!this.store.getSnapshot().hasNextPage) return;
627
+ this.target = Math.min(this.target + this.pageSize, this.maxRows);
628
+ this.watch();
629
+ this.pump();
630
+ };
631
+ retry = () => {
632
+ if (this.disposed || this.options.enabled === false) return;
633
+ this.apiFailed = false;
634
+ this.apiError = void 0;
635
+ this.watch();
636
+ this.pump();
637
+ };
638
+ refresh = () => {
639
+ this.reset(this.anchor);
640
+ };
641
+ continueOlder = () => {
642
+ const last = this.store.getSnapshot().data.at(-1);
643
+ if (last) this.reset(last);
644
+ };
645
+ reset(anchor) {
646
+ if (this.disposed || this.options.enabled === false) return;
647
+ this.generation += 1;
648
+ this.cancelRequest();
649
+ this.request = void 0;
650
+ this.apiPending = false;
651
+ this.apiFailed = false;
652
+ this.apiError = void 0;
653
+ this.localError = void 0;
654
+ this.clearPages();
655
+ this.local.clear();
656
+ this.retained.clear();
657
+ this.inSession.clear();
658
+ this.tombstones.clear();
659
+ this.filtered.clear();
660
+ this.target = this.pageSize;
661
+ this.anchor = anchor;
662
+ this.revision += 1;
663
+ this.watch();
664
+ this.pump();
665
+ }
666
+ };
667
+
115
668
  // src/flagger.ts
116
669
  var AutoSyncFlagStore = class {
117
670
  constructor(driver, clock, config, ids) {
@@ -391,68 +944,6 @@ var IdentityTracker = class {
391
944
  }
392
945
  };
393
946
 
394
- // src/keyset.ts
395
- function keyColumns(orderKey) {
396
- if (typeof orderKey === "string") return [orderKey, "id"];
397
- return [orderKey[0], orderKey[1]];
398
- }
399
- function idOf(row) {
400
- const r = row;
401
- if (r == null || r.id === void 0 || r.id === null) {
402
- throw new Error("powersync-bridge: every row must have an `id`");
403
- }
404
- return String(r.id);
405
- }
406
- function compareValues(a, b) {
407
- if (a === b) return 0;
408
- if (a === null || a === void 0) return 1;
409
- if (b === null || b === void 0) return -1;
410
- if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
411
- if (typeof a === "number" && typeof b === "number") return a - b;
412
- if (typeof a === "boolean" && typeof b === "boolean") return Number(a) - Number(b);
413
- const sa = String(a);
414
- const sb = String(b);
415
- return sa < sb ? -1 : sa > sb ? 1 : 0;
416
- }
417
- function rowComparator(orderKey, direction = "asc") {
418
- const [k1, k2] = keyColumns(orderKey);
419
- const sign = direction === "desc" ? -1 : 1;
420
- return (a, b) => {
421
- const c1 = compareValues(a[k1], b[k1]);
422
- if (c1 !== 0) return c1 * sign;
423
- return compareValues(a[k2], b[k2]) * sign;
424
- };
425
- }
426
- function cursorOfRow(row, orderKey) {
427
- const r = row;
428
- if (typeof orderKey === "string") return r[orderKey];
429
- return [r[orderKey[0]], r[orderKey[1]]];
430
- }
431
- function isAfterCursor(row, cursor, orderKey, direction = "asc") {
432
- const [k1, k2] = keyColumns(orderKey);
433
- const r = row;
434
- const sign = direction === "desc" ? -1 : 1;
435
- const [c1, c2] = typeof orderKey === "string" ? [cursor, void 0] : cursor;
436
- const d1 = compareValues(r[k1], c1) * sign;
437
- if (d1 !== 0) return d1 > 0;
438
- if (c2 === void 0) return false;
439
- return compareValues(r[k2], c2) * sign > 0;
440
- }
441
- function toBase64(s) {
442
- if (typeof btoa === "function") return btoa(unescape(encodeURIComponent(s)));
443
- return Buffer.from(s, "utf8").toString("base64");
444
- }
445
- function fromBase64(s) {
446
- if (typeof atob === "function") return decodeURIComponent(escape(atob(s)));
447
- return Buffer.from(s, "base64").toString("utf8");
448
- }
449
- function encodeCursor(cursor) {
450
- return toBase64(JSON.stringify(cursor));
451
- }
452
- function decodeCursor(opaque) {
453
- return JSON.parse(fromBase64(opaque));
454
- }
455
-
456
947
  // src/infinite.ts
457
948
  var EMPTY = Object.freeze({
458
949
  items: Object.freeze([]),
@@ -503,6 +994,7 @@ var InfiniteListStore = class {
503
994
  reason;
504
995
  awaitingSync = false;
505
996
  loading = false;
997
+ localPagePending = false;
506
998
  errors = {};
507
999
  warnings = [];
508
1000
  errorCount = 0;
@@ -511,14 +1003,16 @@ var InfiniteListStore = class {
511
1003
  refs = 0;
512
1004
  retain() {
513
1005
  this.refs += 1;
514
- if (this.refs === 1) this.subscribeLocal();
1006
+ if (this.refs === 1 && this.unsubDiffs === null) this.subscribeLocal();
515
1007
  }
516
1008
  release() {
517
1009
  this.refs -= 1;
518
- if (this.refs <= 0) this.dispose();
1010
+ if (this.refs <= 0) queueMicrotask(() => {
1011
+ if (this.refs <= 0) this.dispose();
1012
+ });
519
1013
  }
520
1014
  get isDisposed() {
521
- return this.disposed;
1015
+ return this.disposed || this.refs <= 0 && this.unsubDiffs !== null;
522
1016
  }
523
1017
  swapRoot() {
524
1018
  this.store.replace(emptyInfiniteResult());
@@ -532,6 +1026,11 @@ var InfiniteListStore = class {
532
1026
  for (const h of this.subs.values()) h.unsubscribe();
533
1027
  this.subs.clear();
534
1028
  this.lru.length = 0;
1029
+ this.local.clear();
1030
+ this.apiRows.clear();
1031
+ this.retained.clear();
1032
+ this.tombstones.clear();
1033
+ this.store.replace(emptyInfiniteResult());
535
1034
  }
536
1035
  // ---------------------------------------------------------------------------
537
1036
  subscribeLocal() {
@@ -543,6 +1042,10 @@ var InfiniteListStore = class {
543
1042
  (diff) => void this.onDiff(diff),
544
1043
  (err) => {
545
1044
  this.localError = err;
1045
+ if (this.localPagePending) {
1046
+ this.localPagePending = false;
1047
+ this.loading = false;
1048
+ }
546
1049
  this.render();
547
1050
  }
548
1051
  );
@@ -562,6 +1065,11 @@ var InfiniteListStore = class {
562
1065
  this.apiRows.delete(id);
563
1066
  }
564
1067
  this.local = next;
1068
+ this.localError = void 0;
1069
+ if (this.localPagePending) {
1070
+ this.localPagePending = false;
1071
+ this.loading = false;
1072
+ }
565
1073
  this.emissionSeen = true;
566
1074
  const removed = /* @__PURE__ */ new Map();
567
1075
  for (const [id, row] of prev) if (!next.has(id)) removed.set(id, row);
@@ -616,6 +1124,8 @@ var InfiniteListStore = class {
616
1124
  async loadMore() {
617
1125
  if (this.disposed || this.loading || this.boundary === "end") return;
618
1126
  if (this.lookahead) {
1127
+ this.localPagePending = true;
1128
+ this.loading = true;
619
1129
  this.pages += 1;
620
1130
  this.boundary = "more";
621
1131
  this.reason = void 0;
@@ -770,6 +1280,8 @@ var InfiniteListStore = class {
770
1280
  }
771
1281
  }
772
1282
  absorbApi(rows) {
1283
+ const { api: _previousApiError, ...remainingErrors } = this.errors;
1284
+ this.errors = remainingErrors;
773
1285
  this.errorCount = 0;
774
1286
  this.nextRetryAt = 0;
775
1287
  for (const row of rows) {
@@ -777,6 +1289,11 @@ var InfiniteListStore = class {
777
1289
  if (this.local.has(id) || this.apiRows.has(id) || this.tombstones.has(id)) continue;
778
1290
  this.apiRows.set(id, row);
779
1291
  }
1292
+ const loadedPages = Math.max(this.pages, Math.ceil(this.mergedEntries().length / this.def.pageSize));
1293
+ if (loadedPages > this.pages) {
1294
+ this.pages = loadedPages;
1295
+ this.subscribeLocal();
1296
+ }
780
1297
  if (rows.length === 0) {
781
1298
  this.boundary = "end";
782
1299
  this.reason = void 0;
@@ -944,7 +1461,7 @@ var CrudMirror = class {
944
1461
  for (const r of refs) next.set(refKey(r.table, r.id), r);
945
1462
  const added = [];
946
1463
  const completed = [];
947
- for (const [k, r] of next) if (!this.live.has(k)) added.push(r);
1464
+ for (const [k, r] of next) if (this.live.get(k)?.op !== r.op) added.push(r);
948
1465
  for (const [k, r] of this.live) if (!next.has(k)) completed.push(r);
949
1466
  if (added.length || completed.length) this.apply({ added, completed });
950
1467
  }
@@ -1045,14 +1562,16 @@ var RacedListStore = class {
1045
1562
  apiMs = null;
1046
1563
  retain() {
1047
1564
  this.refs += 1;
1048
- if (this.refs === 1) this.start();
1565
+ if (this.refs === 1 && this.unsubDiffs === null) this.start();
1049
1566
  }
1050
1567
  release() {
1051
1568
  this.refs -= 1;
1052
- if (this.refs <= 0) this.dispose();
1569
+ if (this.refs <= 0) queueMicrotask(() => {
1570
+ if (this.refs <= 0) this.dispose();
1571
+ });
1053
1572
  }
1054
1573
  get isDisposed() {
1055
- return this.disposed;
1574
+ return this.disposed || this.refs <= 0 && this.unsubDiffs !== null;
1056
1575
  }
1057
1576
  /** Synchronous part of an identity swap: the snapshot becomes empty 'pending' at once (R15). */
1058
1577
  swapRoot() {
@@ -1067,6 +1586,14 @@ var RacedListStore = class {
1067
1586
  this.unsubFresh?.();
1068
1587
  if (this.snapWindow) this.deps.crud.closeWindow(this.snapWindow);
1069
1588
  this.snapWindow = null;
1589
+ this.local?.clear();
1590
+ this.local = null;
1591
+ this.snap = null;
1592
+ this.prepared = null;
1593
+ this.overlay.clear();
1594
+ this.inSession.clear();
1595
+ this.tombstones.clear();
1596
+ this.store.replace(emptyRacedResult());
1070
1597
  }
1071
1598
  // ---------------------------------------------------------------------------
1072
1599
  start() {
@@ -1360,6 +1887,17 @@ var SnapshotCache = class {
1360
1887
  if (existing && (existing.result === null || now - existing.result.fetchedAt < staleTime)) {
1361
1888
  return existing.promise;
1362
1889
  }
1890
+ for (const [entryKey, entry2] of this.entries) {
1891
+ if (entry2.result && now - entry2.result.fetchedAt >= staleTime) this.entries.delete(entryKey);
1892
+ }
1893
+ if (this.entries.size >= 64) {
1894
+ for (const [entryKey, entry2] of this.entries) {
1895
+ if (entry2.result) {
1896
+ this.entries.delete(entryKey);
1897
+ break;
1898
+ }
1899
+ }
1900
+ }
1363
1901
  const controller = new AbortController();
1364
1902
  const run = async (signal) => {
1365
1903
  const res = await fetcher(signal);
@@ -1379,6 +1917,12 @@ var SnapshotCache = class {
1379
1917
  return promise;
1380
1918
  }
1381
1919
  /** Cancel every in-flight fetch under a key prefix (identity swap). */
1920
+ invalidate(key) {
1921
+ const encoded = JSON.stringify(key);
1922
+ this.entries.get(encoded)?.controller.abort(new Error("invalidated"));
1923
+ this.entries.delete(encoded);
1924
+ }
1925
+ /** Cancel every in-flight fetch under a key prefix (identity swap). */
1382
1926
  abortAll(prefix) {
1383
1927
  const p = JSON.stringify(prefix).slice(0, -1);
1384
1928
  for (const [k, e] of this.entries) {
@@ -1409,6 +1953,7 @@ var Bridge = class {
1409
1953
  clock;
1410
1954
  /** Increments on every identity swap; hooks subscribe to re-acquire their stores. */
1411
1955
  epochStore;
1956
+ connectivity = new SnapshotStore(void 0);
1412
1957
  identity = new IdentityTracker();
1413
1958
  freshness;
1414
1959
  crud;
@@ -1420,6 +1965,7 @@ var Bridge = class {
1420
1965
  identityOpts;
1421
1966
  onEvicted;
1422
1967
  disposed = false;
1968
+ onNetwork = () => this.setOnline(globalThis.navigator?.onLine);
1423
1969
  constructor(opts) {
1424
1970
  this.driver = opts.driver;
1425
1971
  this.adapter = opts.adapter;
@@ -1438,6 +1984,8 @@ var Bridge = class {
1438
1984
  this.crud.start();
1439
1985
  this.freshness.onClear(() => this.identity.signalClear());
1440
1986
  this.identity.onChange(() => this.swapEpoch());
1987
+ globalThis.addEventListener?.("online", this.onNetwork);
1988
+ globalThis.addEventListener?.("offline", this.onNetwork);
1441
1989
  }
1442
1990
  get epoch() {
1443
1991
  return this.identity.epoch;
@@ -1445,6 +1993,25 @@ var Bridge = class {
1445
1993
  get identityKey() {
1446
1994
  return this.identity.currentKey;
1447
1995
  }
1996
+ setOnline(online) {
1997
+ this.connectivity.replace(online);
1998
+ }
1999
+ query(options) {
2000
+ const key = `query:${this.epoch}:${infiniteQueryKey(options)}`;
2001
+ return this.acquire(key, () => new InfiniteQueryStore({
2002
+ driver: this.driver,
2003
+ adapter: this.adapter,
2004
+ crud: this.crud,
2005
+ freshness: this.freshness,
2006
+ snapshots: this.snapshots,
2007
+ clock: this.clock,
2008
+ epoch: this.epoch,
2009
+ identityKey: this.identityKey,
2010
+ apiLegEnabled: true,
2011
+ measure: this.measure,
2012
+ connectivity: this.connectivity
2013
+ }, options));
2014
+ }
1448
2015
  /** Resolves once the initial upload-queue read has landed (tests). */
1449
2016
  ready() {
1450
2017
  return this.crud.ready();
@@ -1555,6 +2122,8 @@ var Bridge = class {
1555
2122
  dispose() {
1556
2123
  if (this.disposed) return;
1557
2124
  this.disposed = true;
2125
+ globalThis.removeEventListener?.("online", this.onNetwork);
2126
+ globalThis.removeEventListener?.("offline", this.onNetwork);
1558
2127
  for (const s of this.stores.values()) s.dispose();
1559
2128
  this.stores.clear();
1560
2129
  this.snapshots.clear();
@@ -1578,6 +2147,8 @@ exports.AutoSyncFlagStore = AutoSyncFlagStore;
1578
2147
  exports.Bridge = Bridge;
1579
2148
  exports.DEFAULT_VOLATILE_CLAIMS = DEFAULT_VOLATILE_CLAIMS;
1580
2149
  exports.InfiniteListStore = InfiniteListStore;
2150
+ exports.InfiniteQueryStore = InfiniteQueryStore;
2151
+ exports.OfflineError = OfflineError;
1581
2152
  exports.OnDemandStreamStore = OnDemandStreamStore;
1582
2153
  exports.RacedListStore = RacedListStore;
1583
2154
  exports.SnapshotStore = SnapshotStore;
@@ -1593,6 +2164,7 @@ exports.emptyRacedResult = emptyRacedResult;
1593
2164
  exports.encodeCursor = encodeCursor;
1594
2165
  exports.identityKeyFromClaims = identityKeyFromClaims;
1595
2166
  exports.identityKeyFromToken = identityKeyFromToken;
2167
+ exports.infiniteQueryKey = infiniteQueryKey;
1596
2168
  exports.keyColumns = keyColumns;
1597
2169
  exports.probePresence = probePresence;
1598
2170
  exports.realClock = realClock;