powersync-bridge 0.0.1-alpha.0 → 0.0.1-alpha.2

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) {
@@ -68,6 +130,89 @@ async function classifyRemovals(ctx, removed) {
68
130
  return out;
69
131
  }
70
132
 
133
+ // src/refresh.ts
134
+ var CoalescedRefresh = class {
135
+ constructor(read, settled) {
136
+ this.read = read;
137
+ this.settled = settled;
138
+ }
139
+ read;
140
+ settled;
141
+ revision = 0;
142
+ lifecycle = 0;
143
+ scheduled = false;
144
+ running = false;
145
+ get pending() {
146
+ return this.scheduled || this.running;
147
+ }
148
+ request() {
149
+ this.revision += 1;
150
+ if (this.pending) return;
151
+ this.scheduled = true;
152
+ const lifecycle = this.lifecycle;
153
+ queueMicrotask(() => {
154
+ if (lifecycle !== this.lifecycle) return;
155
+ this.scheduled = false;
156
+ void this.drain(lifecycle);
157
+ });
158
+ }
159
+ /** Fence queued/late work without retaining results or preventing a later resume. */
160
+ cancel() {
161
+ this.lifecycle += 1;
162
+ this.scheduled = false;
163
+ this.running = false;
164
+ }
165
+ async drain(lifecycle) {
166
+ this.running = true;
167
+ let revision;
168
+ try {
169
+ do {
170
+ revision = this.revision;
171
+ await this.read(() => lifecycle === this.lifecycle && revision === this.revision);
172
+ } while (lifecycle === this.lifecycle && revision !== this.revision);
173
+ } finally {
174
+ if (lifecycle === this.lifecycle) {
175
+ this.running = false;
176
+ this.settled();
177
+ }
178
+ }
179
+ }
180
+ };
181
+ var BackgroundReads = class {
182
+ running = false;
183
+ queue = /* @__PURE__ */ new Set();
184
+ run(read, signal) {
185
+ if (signal.aborted) return Promise.reject(signal.reason);
186
+ return new Promise((resolve, reject) => {
187
+ const entry = {
188
+ cancel: () => {
189
+ if (!this.queue.delete(entry)) return;
190
+ signal.removeEventListener("abort", entry.cancel);
191
+ reject(signal.reason);
192
+ },
193
+ run: () => {
194
+ signal.removeEventListener("abort", entry.cancel);
195
+ this.running = true;
196
+ void Promise.resolve().then(read).then(resolve, reject).finally(() => {
197
+ this.running = false;
198
+ this.drain();
199
+ });
200
+ }
201
+ };
202
+ signal.addEventListener("abort", entry.cancel, { once: true });
203
+ this.queue.add(entry);
204
+ this.drain();
205
+ });
206
+ }
207
+ drain() {
208
+ if (this.running) return;
209
+ const next = this.queue.values().next().value;
210
+ if (!next) return;
211
+ this.queue.delete(next);
212
+ next.run();
213
+ }
214
+ };
215
+
71
216
  // src/store.ts
72
217
  var SnapshotStore = class {
73
218
  snapshot;
@@ -75,13 +220,14 @@ var SnapshotStore = class {
75
220
  constructor(initial) {
76
221
  this.snapshot = initial;
77
222
  }
78
- getSnapshot() {
223
+ // React invokes external-store callbacks as standalone functions.
224
+ getSnapshot = () => {
79
225
  return this.snapshot;
80
- }
81
- subscribe(listener) {
226
+ };
227
+ subscribe = (listener) => {
82
228
  this.listeners.add(listener);
83
229
  return () => this.listeners.delete(listener);
84
- }
230
+ };
85
231
  replace(next) {
86
232
  if (Object.is(next, this.snapshot)) return;
87
233
  this.snapshot = next;
@@ -112,6 +258,538 @@ function shallowEqualResult(a, b) {
112
258
  return true;
113
259
  }
114
260
 
261
+ // src/query.ts
262
+ var infiniteQueryKey = (options) => JSON.stringify([
263
+ options.queryKey,
264
+ options.orderBy,
265
+ options.direction ?? "desc",
266
+ options.pageSize ?? 25,
267
+ options.maxRows ?? 500,
268
+ options.enabled !== false
269
+ ]);
270
+ var OfflineError = class extends Error {
271
+ name = "OfflineError";
272
+ constructor() {
273
+ super("Additional records require a connection.");
274
+ }
275
+ };
276
+ var InfiniteQueryStore = class {
277
+ constructor(deps, options) {
278
+ this.deps = deps;
279
+ this.options = options;
280
+ this.pageSize = options.pageSize ?? 25;
281
+ this.maxRows = options.maxRows ?? 500;
282
+ if (!Number.isInteger(this.pageSize) || this.pageSize < 1 || !Number.isInteger(this.maxRows) || this.maxRows < this.pageSize)
283
+ throw new Error(
284
+ "powersync-bridge: pageSize must be positive and maxRows must be at least pageSize"
285
+ );
286
+ this.target = this.pageSize;
287
+ this.compare = rowComparator(options.orderBy, options.direction ?? "desc");
288
+ this.table = options.table;
289
+ this.offline = this.readOffline();
290
+ this.store = new SnapshotStore(this.result());
291
+ this.checkpointRefresh = new CoalescedRefresh(
292
+ (isCurrent) => this.checkpoint(isCurrent),
293
+ () => this.render()
294
+ );
295
+ }
296
+ deps;
297
+ options;
298
+ store;
299
+ pageSize;
300
+ maxRows;
301
+ compare;
302
+ table;
303
+ target;
304
+ anchor;
305
+ revision = 0;
306
+ local = /* @__PURE__ */ new Map();
307
+ retained = /* @__PURE__ */ new Map();
308
+ tombstones = /* @__PURE__ */ new Set();
309
+ filtered = /* @__PURE__ */ new Set();
310
+ inSession = /* @__PURE__ */ new Set();
311
+ pages = [];
312
+ localPending = true;
313
+ localStamp = Number.NEGATIVE_INFINITY;
314
+ localError;
315
+ apiError;
316
+ apiPending = false;
317
+ apiFailed = false;
318
+ offline;
319
+ generation = 0;
320
+ watchGeneration = 0;
321
+ lastLocalWatch = 0;
322
+ refs = 0;
323
+ activeRefs = 0;
324
+ checkpointRefresh;
325
+ localAbort = new AbortController();
326
+ ready = false;
327
+ disposed = false;
328
+ unwatch;
329
+ stops = [];
330
+ request;
331
+ requestKey;
332
+ get isDisposed() {
333
+ return this.disposed || this.refs <= 0 && this.unwatch !== void 0;
334
+ }
335
+ retain(active = true) {
336
+ this.refs += 1;
337
+ if (active) this.activeRefs += 1;
338
+ if (this.activeRefs <= 0 || this.unwatch || this.options.enabled === false || this.disposed)
339
+ return;
340
+ this.offline = this.readOffline();
341
+ this.apiFailed = false;
342
+ this.apiError = void 0;
343
+ this.stops = [
344
+ this.deps.crud.onChange(() => this.render()),
345
+ this.deps.freshness.onCompletion((completion) => {
346
+ if (completion.stream === null) this.checkpointRefresh.request();
347
+ }),
348
+ this.deps.driver.onStatusChanged(() => this.networkChanged()),
349
+ this.deps.connectivity.subscribe(() => this.networkChanged())
350
+ ];
351
+ this.watch();
352
+ void this.deps.crud.ready().then(() => {
353
+ this.ready = true;
354
+ this.pump();
355
+ }).catch((error) => {
356
+ if (this.disposed) return;
357
+ this.localPending = false;
358
+ this.localError = error instanceof Error ? error : new Error(String(error));
359
+ this.render();
360
+ });
361
+ }
362
+ release(active = true) {
363
+ this.refs -= 1;
364
+ if (active) this.activeRefs -= 1;
365
+ queueMicrotask(() => {
366
+ if (this.refs <= 0) this.dispose();
367
+ else if (this.activeRefs <= 0) this.pause();
368
+ });
369
+ }
370
+ swapRoot() {
371
+ this.disposed = true;
372
+ this.local.clear();
373
+ this.retained.clear();
374
+ this.clearPages();
375
+ this.render(true);
376
+ }
377
+ dispose() {
378
+ this.disposed = true;
379
+ this.pause();
380
+ this.clearPages();
381
+ this.local.clear();
382
+ this.retained.clear();
383
+ this.tombstones.clear();
384
+ this.filtered.clear();
385
+ this.inSession.clear();
386
+ this.render(true);
387
+ }
388
+ pause() {
389
+ this.generation += 1;
390
+ this.watchGeneration += 1;
391
+ this.checkpointRefresh.cancel();
392
+ this.localAbort.abort();
393
+ this.cancelRequest();
394
+ this.request = void 0;
395
+ this.apiPending = false;
396
+ this.unwatch?.();
397
+ this.unwatch = void 0;
398
+ for (const stop of this.stops) stop();
399
+ this.stops = [];
400
+ }
401
+ readOffline() {
402
+ return (this.deps.connectivity.getSnapshot() ?? this.deps.driver.onlineHint()) === false;
403
+ }
404
+ cancelRequest() {
405
+ this.request?.abort();
406
+ if (this.requestKey) this.deps.snapshots.invalidate(this.requestKey);
407
+ this.requestKey = void 0;
408
+ }
409
+ networkChanged() {
410
+ if (this.disposed) return;
411
+ const offline = this.readOffline();
412
+ const reconnect = this.offline && !offline;
413
+ this.offline = offline;
414
+ if (offline && this.request) {
415
+ this.cancelRequest();
416
+ this.generation += 1;
417
+ this.apiPending = false;
418
+ this.request = void 0;
419
+ this.watch();
420
+ }
421
+ if (reconnect) {
422
+ this.apiFailed = false;
423
+ this.apiError = void 0;
424
+ this.pump();
425
+ }
426
+ this.render();
427
+ }
428
+ identity(row) {
429
+ if (this.options.getRowIdentity) return this.options.getRowIdentity(row);
430
+ if (!this.table)
431
+ throw new Error(
432
+ "powersync-bridge: provide table or getRowIdentity when the adapter cannot infer the business table"
433
+ );
434
+ return { table: this.table, id: idOf(row) };
435
+ }
436
+ pending(row) {
437
+ const { table, id } = this.identity(row);
438
+ return Boolean(
439
+ this.deps.crud.pendingOp(table, id) || this.pages.some((page) => this.deps.crud.pendingOp(table, id, page.window))
440
+ );
441
+ }
442
+ localBuilder() {
443
+ return this.options.query({ cursor: this.anchor, limit: this.target + 1 });
444
+ }
445
+ watch() {
446
+ if (this.activeRefs <= 0 || this.disposed) return;
447
+ this.unwatch?.();
448
+ this.localAbort.abort();
449
+ this.localAbort = new AbortController();
450
+ this.checkpointRefresh.cancel();
451
+ const token = ++this.watchGeneration;
452
+ const generation = this.generation;
453
+ this.localPending = true;
454
+ let prepared;
455
+ try {
456
+ prepared = this.deps.adapter.prepare(this.localBuilder());
457
+ if (!this.table && prepared.tables.length === 1) this.table = prepared.tables[0];
458
+ if (!this.table && !this.options.getRowIdentity)
459
+ throw new Error(
460
+ "powersync-bridge: provide table or getRowIdentity when the adapter cannot infer the business table"
461
+ );
462
+ } catch (error) {
463
+ this.localPending = false;
464
+ this.localError = error instanceof Error ? error : new Error(String(error));
465
+ this.render();
466
+ return;
467
+ }
468
+ this.unwatch = this.deps.driver.diffs(prepared).subscribe(
469
+ (diff) => {
470
+ void this.acceptLocal(diff.all, token, generation).catch((error) => {
471
+ if (token !== this.watchGeneration || generation !== this.generation || this.disposed)
472
+ return;
473
+ this.localPending = false;
474
+ this.localError = error instanceof Error ? error : new Error(String(error));
475
+ this.render();
476
+ });
477
+ },
478
+ (error) => {
479
+ if (token !== this.watchGeneration || generation !== this.generation || this.disposed)
480
+ return;
481
+ this.localPending = false;
482
+ this.localError = error;
483
+ this.render();
484
+ }
485
+ );
486
+ this.render();
487
+ }
488
+ async acceptLocal(rows, token, generation, stamp = this.deps.freshness.freshAtFor(void 0), isCurrent = () => true) {
489
+ await this.deps.crud.resync();
490
+ if (token !== this.watchGeneration || generation !== this.generation || this.disposed || !isCurrent())
491
+ return;
492
+ const previous = this.local;
493
+ const firstEmission = this.lastLocalWatch !== token;
494
+ this.lastLocalWatch = token;
495
+ const next = new Map(rows.map((row) => [idOf(row), row]));
496
+ const removed = /* @__PURE__ */ new Map();
497
+ for (const [id, row] of previous)
498
+ if (!next.has(id)) {
499
+ removed.set(id, row);
500
+ this.tombstones.add(id);
501
+ }
502
+ for (const [id, row] of next) {
503
+ if (previous.size && (!firstEmission || previous.has(id)) && JSON.stringify(previous.get(id)) !== JSON.stringify(row) && !this.pending(row))
504
+ this.inSession.add(id);
505
+ this.tombstones.delete(id);
506
+ this.filtered.delete(id);
507
+ this.retained.delete(id);
508
+ }
509
+ this.local = next;
510
+ this.localPending = false;
511
+ this.localError = void 0;
512
+ this.localStamp = stamp;
513
+ this.render();
514
+ const groups = /* @__PURE__ */ new Map();
515
+ for (const row of removed.values()) {
516
+ const identity = this.identity(row);
517
+ const group = groups.get(identity.table) ?? /* @__PURE__ */ new Map();
518
+ group.set(identity.id, row);
519
+ groups.set(identity.table, group);
520
+ }
521
+ try {
522
+ for (const [table, group] of groups) {
523
+ const classes = await classifyRemovals(
524
+ {
525
+ driver: this.deps.driver,
526
+ clock: this.deps.clock,
527
+ table,
528
+ provenance: () => void 0,
529
+ isInWindow: this.options.isInWindow
530
+ },
531
+ group
532
+ );
533
+ if (token !== this.watchGeneration || generation !== this.generation || this.disposed)
534
+ return;
535
+ for (const row of removed.values()) {
536
+ const identity = this.identity(row);
537
+ if (identity.table !== table || this.local.has(row.id)) continue;
538
+ const classification = classes.get(identity.id);
539
+ if (!classification) continue;
540
+ if (classification === "delete") this.tombstones.add(row.id);
541
+ else if (classification === "filtered") {
542
+ this.tombstones.delete(row.id);
543
+ this.filtered.add(row.id);
544
+ } else {
545
+ this.tombstones.delete(row.id);
546
+ this.retained.set(row.id, row);
547
+ }
548
+ }
549
+ }
550
+ } catch (error) {
551
+ if (token !== this.watchGeneration || generation !== this.generation || this.disposed) return;
552
+ this.localError = error instanceof Error ? error : new Error(String(error));
553
+ }
554
+ const retained = [...this.retained.values()].sort(this.compare).slice(0, this.maxRows);
555
+ this.retained.clear();
556
+ for (const row of retained) this.retained.set(row.id, row);
557
+ const known = /* @__PURE__ */ new Set([
558
+ ...this.local.keys(),
559
+ ...this.retained.keys(),
560
+ ...this.pages.flatMap((page) => page.rows.map((row) => row.id))
561
+ ]);
562
+ for (const set of [this.inSession, this.tombstones, this.filtered]) {
563
+ if (set.size > this.maxRows * 4) {
564
+ for (const id of set) if (!known.has(id)) set.delete(id);
565
+ }
566
+ }
567
+ this.render();
568
+ }
569
+ async checkpoint(isCurrent) {
570
+ if (this.disposed || !this.unwatch || this.activeRefs <= 0) return;
571
+ const token = this.watchGeneration;
572
+ const generation = this.generation;
573
+ const stamp = this.deps.freshness.freshAtFor(void 0);
574
+ try {
575
+ const signal = this.localAbort.signal;
576
+ const read = () => isCurrent() ? this.deps.driver.query(this.deps.adapter.prepare(this.localBuilder()), signal) : Promise.resolve(void 0);
577
+ const rows = await (this.deps.backgroundReads ? this.deps.backgroundReads.run(read, signal) : read());
578
+ if (!rows) return;
579
+ if (!isCurrent()) return;
580
+ await this.acceptLocal(rows, token, generation, stamp, isCurrent);
581
+ } catch (error) {
582
+ if (token !== this.watchGeneration || generation !== this.generation || this.disposed || !isCurrent())
583
+ return;
584
+ this.localError = error instanceof Error ? error : new Error(String(error));
585
+ this.render();
586
+ }
587
+ }
588
+ clearPages() {
589
+ for (const page of this.pages) this.deps.crud.closeWindow(page.window);
590
+ this.pages = [];
591
+ }
592
+ apiEnd() {
593
+ return this.pages.at(-1)?.end === true;
594
+ }
595
+ apiCount() {
596
+ return this.pages.reduce((count, page) => count + page.rows.length, 0);
597
+ }
598
+ apiCursor() {
599
+ const last = this.pages.at(-1)?.rows.at(-1);
600
+ return last ? last : this.anchor;
601
+ }
602
+ cursorKey(row) {
603
+ if (!row) return void 0;
604
+ const [first, second] = keyColumns(this.options.orderBy);
605
+ return [row[first], row[second]];
606
+ }
607
+ pump() {
608
+ if (!this.ready || this.activeRefs <= 0 || this.disposed || this.options.enabled === false || this.offline || this.apiPending || this.apiFailed || this.apiEnd())
609
+ return;
610
+ if (!this.table && !this.options.getRowIdentity) return;
611
+ if (this.pages.length >= Math.ceil(this.maxRows / this.pageSize) + 1) return;
612
+ if (this.apiCount() >= this.target && this.entries().length >= this.target) return;
613
+ const cursor = this.apiCursor();
614
+ const generation = this.generation;
615
+ const request = new AbortController();
616
+ this.request = request;
617
+ const window = this.deps.crud.openWindow();
618
+ this.apiPending = true;
619
+ this.render();
620
+ const key = [
621
+ "powersync-bridge.query",
622
+ this.deps.identityKey,
623
+ this.deps.epoch,
624
+ infiniteQueryKey(this.options),
625
+ this.cursorKey(this.anchor),
626
+ this.revision,
627
+ generation,
628
+ this.cursorKey(cursor)
629
+ ];
630
+ this.requestKey = key;
631
+ void this.deps.snapshots.fetch(key, this.options.staleTime ?? 6e4, async (signal) => {
632
+ const abort = () => request.abort();
633
+ signal.addEventListener("abort", abort, { once: true });
634
+ try {
635
+ return await withTimeout(
636
+ this.deps.clock,
637
+ this.options.fetchPage({ cursor, limit: this.pageSize + 1, signal: request.signal }).then((rows) => ({ rows })),
638
+ 3e4,
639
+ request.signal
640
+ );
641
+ } finally {
642
+ signal.removeEventListener("abort", abort);
643
+ }
644
+ }).then((record) => {
645
+ if (this.disposed || generation !== this.generation || request.signal.aborted) {
646
+ this.deps.crud.closeWindow(window);
647
+ return;
648
+ }
649
+ const rows = [...record.rows];
650
+ for (let index = 1; index < rows.length; index++) {
651
+ const before = rows[index - 1];
652
+ const row = rows[index];
653
+ if (before && row && this.compare(before, row) >= 0)
654
+ throw new Error(
655
+ "powersync-bridge: API rows must use a strict, stable order with an id tiebreaker"
656
+ );
657
+ }
658
+ if (cursor !== void 0 && rows.some((row) => this.compare(row, cursor) <= 0))
659
+ throw new Error("powersync-bridge: API page must advance beyond the supplied cursor");
660
+ this.pages.push({
661
+ cursor,
662
+ rows: rows.slice(0, this.pageSize),
663
+ end: rows.length <= this.pageSize,
664
+ fetchedAt: record.fetchedAt,
665
+ window
666
+ });
667
+ this.apiError = void 0;
668
+ }).catch((error) => {
669
+ this.deps.crud.closeWindow(window);
670
+ if (this.disposed || generation !== this.generation || request.signal.aborted) return;
671
+ this.apiFailed = true;
672
+ this.apiError = error instanceof Error ? error : new Error(String(error));
673
+ request.abort();
674
+ }).finally(() => {
675
+ if (generation !== this.generation || this.disposed) return;
676
+ this.apiPending = false;
677
+ this.request = void 0;
678
+ this.requestKey = void 0;
679
+ this.render();
680
+ this.pump();
681
+ });
682
+ }
683
+ entries() {
684
+ const api = /* @__PURE__ */ new Map();
685
+ for (const page of this.pages) for (const row of page.rows) api.set(row.id, { row, page });
686
+ const ids = /* @__PURE__ */ new Set([...this.local.keys(), ...this.retained.keys(), ...api.keys()]);
687
+ const entries = [];
688
+ for (const id of ids) {
689
+ if (this.tombstones.has(id) || this.filtered.has(id)) continue;
690
+ const local = this.local.get(id);
691
+ const remote = api.get(id);
692
+ const row = local ?? remote?.row ?? this.retained.get(id);
693
+ if (!row) continue;
694
+ if (this.pending(row)) {
695
+ if (local) entries.push({ row: local, origin: "local", presentLocally: true });
696
+ continue;
697
+ }
698
+ if (remote) {
699
+ entries.push(
700
+ local && this.localStamp > remote.page.fetchedAt ? { row: local, origin: "local", presentLocally: true } : { row: remote.row, origin: "api", presentLocally: Boolean(local) }
701
+ );
702
+ } else if (local) {
703
+ const covering = this.pages.find((page) => {
704
+ if (page.cursor !== void 0 && this.compare(local, page.cursor) <= 0) return false;
705
+ const last = page.rows.at(-1);
706
+ return page.end || Boolean(last && this.compare(local, last) <= 0);
707
+ });
708
+ if (!covering || this.inSession.has(id) || this.localStamp > covering.fetchedAt)
709
+ entries.push({ row: local, origin: "local", presentLocally: true });
710
+ } else entries.push({ row, origin: "local", presentLocally: false });
711
+ }
712
+ return entries.sort((a, b) => this.compare(a.row, b.row));
713
+ }
714
+ result(empty = false) {
715
+ const enabled = !empty && this.options.enabled !== false;
716
+ const entries = enabled ? this.entries() : [];
717
+ const items = entries.slice(0, this.target);
718
+ const more = entries.length > this.target || !this.apiEnd();
719
+ const missing = items.length < this.target;
720
+ const errors = {};
721
+ if (this.localError) errors.local = this.localError;
722
+ if (this.apiError) errors.api = this.apiError;
723
+ const offlineError = this.offline && missing && !this.localPending ? new OfflineError() : void 0;
724
+ const error = enabled && missing && !this.localPending && !this.apiPending ? this.localError ?? this.apiError ?? offlineError : void 0;
725
+ return {
726
+ data: items.map((item) => item.row),
727
+ items,
728
+ isLoading: enabled && items.length === 0 && !error && (this.localPending || this.apiPending),
729
+ isFetching: enabled && (this.localPending || this.apiPending),
730
+ isFetchingNextPage: enabled && this.target > this.pageSize && (this.localPending || this.apiPending),
731
+ hasNextPage: enabled && this.target < this.maxRows && more,
732
+ needsOlderWindow: enabled && items.length >= this.maxRows && more,
733
+ error,
734
+ errors,
735
+ isError: Boolean(error),
736
+ windowRevision: this.revision
737
+ };
738
+ }
739
+ render(empty = false) {
740
+ if (this.disposed && !empty) return;
741
+ const result = this.result(empty);
742
+ if (!shallowEqualResult(
743
+ this.store.getSnapshot(),
744
+ result
745
+ ))
746
+ this.store.replace(result);
747
+ }
748
+ fetchNextPage = () => {
749
+ if (this.disposed || this.activeRefs <= 0 || this.options.enabled === false || this.target >= this.maxRows)
750
+ return;
751
+ if (!this.store.getSnapshot().hasNextPage) return;
752
+ this.target = Math.min(this.target + this.pageSize, this.maxRows);
753
+ this.watch();
754
+ this.pump();
755
+ };
756
+ retry = () => {
757
+ if (this.disposed || this.activeRefs <= 0 || this.options.enabled === false) return;
758
+ this.apiFailed = false;
759
+ this.apiError = void 0;
760
+ this.watch();
761
+ this.pump();
762
+ };
763
+ refresh = () => {
764
+ this.reset(this.anchor);
765
+ };
766
+ continueOlder = () => {
767
+ const last = this.store.getSnapshot().data.at(-1);
768
+ if (last) this.reset(last);
769
+ };
770
+ reset(anchor) {
771
+ if (this.disposed || this.activeRefs <= 0 || this.options.enabled === false) return;
772
+ this.generation += 1;
773
+ this.cancelRequest();
774
+ this.request = void 0;
775
+ this.apiPending = false;
776
+ this.apiFailed = false;
777
+ this.apiError = void 0;
778
+ this.localError = void 0;
779
+ this.clearPages();
780
+ this.local.clear();
781
+ this.retained.clear();
782
+ this.inSession.clear();
783
+ this.tombstones.clear();
784
+ this.filtered.clear();
785
+ this.target = this.pageSize;
786
+ this.anchor = anchor;
787
+ this.revision += 1;
788
+ this.watch();
789
+ this.pump();
790
+ }
791
+ };
792
+
115
793
  // src/flagger.ts
116
794
  var AutoSyncFlagStore = class {
117
795
  constructor(driver, clock, config, ids) {
@@ -201,7 +879,6 @@ var FreshnessTracker = class {
201
879
  lastHasSynced = void 0;
202
880
  streamFreshAt = /* @__PURE__ */ new Map();
203
881
  streamSeenSyncedAt = /* @__PURE__ */ new Map();
204
- completions = [];
205
882
  completionListeners = /* @__PURE__ */ new Set();
206
883
  clearListeners = /* @__PURE__ */ new Set();
207
884
  unsubscribe = null;
@@ -235,7 +912,6 @@ var FreshnessTracker = class {
235
912
  this.lastSeenSyncedAt = null;
236
913
  this.streamFreshAt.clear();
237
914
  this.streamSeenSyncedAt.clear();
238
- this.completions.length = 0;
239
915
  }
240
916
  get sessionConnectAtMs() {
241
917
  return this.sessionConnectAt;
@@ -308,7 +984,6 @@ var FreshnessTracker = class {
308
984
  this.prevCompletionAt = now;
309
985
  this.lastDownloadStart = null;
310
986
  const c = { stamp: this.localFreshAt, completedAt: now, stream };
311
- this.completions.push(c);
312
987
  for (const l of this.completionListeners) l(c);
313
988
  }
314
989
  };
@@ -391,68 +1066,6 @@ var IdentityTracker = class {
391
1066
  }
392
1067
  };
393
1068
 
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
1069
  // src/infinite.ts
457
1070
  var EMPTY = Object.freeze({
458
1071
  items: Object.freeze([]),
@@ -503,6 +1116,7 @@ var InfiniteListStore = class {
503
1116
  reason;
504
1117
  awaitingSync = false;
505
1118
  loading = false;
1119
+ localPagePending = false;
506
1120
  errors = {};
507
1121
  warnings = [];
508
1122
  errorCount = 0;
@@ -511,14 +1125,16 @@ var InfiniteListStore = class {
511
1125
  refs = 0;
512
1126
  retain() {
513
1127
  this.refs += 1;
514
- if (this.refs === 1) this.subscribeLocal();
1128
+ if (this.refs === 1 && this.unsubDiffs === null) this.subscribeLocal();
515
1129
  }
516
1130
  release() {
517
1131
  this.refs -= 1;
518
- if (this.refs <= 0) this.dispose();
1132
+ if (this.refs <= 0) queueMicrotask(() => {
1133
+ if (this.refs <= 0) this.dispose();
1134
+ });
519
1135
  }
520
1136
  get isDisposed() {
521
- return this.disposed;
1137
+ return this.disposed || this.refs <= 0 && this.unsubDiffs !== null;
522
1138
  }
523
1139
  swapRoot() {
524
1140
  this.store.replace(emptyInfiniteResult());
@@ -532,6 +1148,11 @@ var InfiniteListStore = class {
532
1148
  for (const h of this.subs.values()) h.unsubscribe();
533
1149
  this.subs.clear();
534
1150
  this.lru.length = 0;
1151
+ this.local.clear();
1152
+ this.apiRows.clear();
1153
+ this.retained.clear();
1154
+ this.tombstones.clear();
1155
+ this.store.replace(emptyInfiniteResult());
535
1156
  }
536
1157
  // ---------------------------------------------------------------------------
537
1158
  subscribeLocal() {
@@ -543,6 +1164,10 @@ var InfiniteListStore = class {
543
1164
  (diff) => void this.onDiff(diff),
544
1165
  (err) => {
545
1166
  this.localError = err;
1167
+ if (this.localPagePending) {
1168
+ this.localPagePending = false;
1169
+ this.loading = false;
1170
+ }
546
1171
  this.render();
547
1172
  }
548
1173
  );
@@ -562,6 +1187,11 @@ var InfiniteListStore = class {
562
1187
  this.apiRows.delete(id);
563
1188
  }
564
1189
  this.local = next;
1190
+ this.localError = void 0;
1191
+ if (this.localPagePending) {
1192
+ this.localPagePending = false;
1193
+ this.loading = false;
1194
+ }
565
1195
  this.emissionSeen = true;
566
1196
  const removed = /* @__PURE__ */ new Map();
567
1197
  for (const [id, row] of prev) if (!next.has(id)) removed.set(id, row);
@@ -616,6 +1246,8 @@ var InfiniteListStore = class {
616
1246
  async loadMore() {
617
1247
  if (this.disposed || this.loading || this.boundary === "end") return;
618
1248
  if (this.lookahead) {
1249
+ this.localPagePending = true;
1250
+ this.loading = true;
619
1251
  this.pages += 1;
620
1252
  this.boundary = "more";
621
1253
  this.reason = void 0;
@@ -770,6 +1402,8 @@ var InfiniteListStore = class {
770
1402
  }
771
1403
  }
772
1404
  absorbApi(rows) {
1405
+ const { api: _previousApiError, ...remainingErrors } = this.errors;
1406
+ this.errors = remainingErrors;
773
1407
  this.errorCount = 0;
774
1408
  this.nextRetryAt = 0;
775
1409
  for (const row of rows) {
@@ -777,6 +1411,11 @@ var InfiniteListStore = class {
777
1411
  if (this.local.has(id) || this.apiRows.has(id) || this.tombstones.has(id)) continue;
778
1412
  this.apiRows.set(id, row);
779
1413
  }
1414
+ const loadedPages = Math.max(this.pages, Math.ceil(this.mergedEntries().length / this.def.pageSize));
1415
+ if (loadedPages > this.pages) {
1416
+ this.pages = loadedPages;
1417
+ this.subscribeLocal();
1418
+ }
780
1419
  if (rows.length === 0) {
781
1420
  this.boundary = "end";
782
1421
  this.reason = void 0;
@@ -944,7 +1583,7 @@ var CrudMirror = class {
944
1583
  for (const r of refs) next.set(refKey(r.table, r.id), r);
945
1584
  const added = [];
946
1585
  const completed = [];
947
- for (const [k, r] of next) if (!this.live.has(k)) added.push(r);
1586
+ for (const [k, r] of next) if (this.live.get(k)?.op !== r.op) added.push(r);
948
1587
  for (const [k, r] of this.live) if (!next.has(k)) completed.push(r);
949
1588
  if (added.length || completed.length) this.apply({ added, completed });
950
1589
  }
@@ -1013,6 +1652,10 @@ var RacedListStore = class {
1013
1652
  this.compare = rowComparator(def.orderKey, def.direction ?? "asc");
1014
1653
  this.table = def.table ?? def.id;
1015
1654
  this.startedAt = deps.clock.now();
1655
+ this.checkpointRefresh = new CoalescedRefresh(
1656
+ (isCurrent) => this.onCompletion(isCurrent),
1657
+ () => this.reconcile()
1658
+ );
1016
1659
  }
1017
1660
  deps;
1018
1661
  def;
@@ -1022,6 +1665,9 @@ var RacedListStore = class {
1022
1665
  compare;
1023
1666
  table;
1024
1667
  abort = new AbortController();
1668
+ observation = 0;
1669
+ checkpointRefresh;
1670
+ localFreshAt = Number.NEGATIVE_INFINITY;
1025
1671
  startedAt;
1026
1672
  snap = null;
1027
1673
  snapPending = false;
@@ -1040,19 +1686,25 @@ var RacedListStore = class {
1040
1686
  tombstones = /* @__PURE__ */ new Set();
1041
1687
  disposed = false;
1042
1688
  refs = 0;
1689
+ activeRefs = 0;
1043
1690
  measured = false;
1044
1691
  localMs = null;
1045
1692
  apiMs = null;
1046
- retain() {
1693
+ retain(active = true) {
1047
1694
  this.refs += 1;
1048
- if (this.refs === 1) this.start();
1695
+ if (active) this.activeRefs += 1;
1696
+ if (this.activeRefs > 0 && this.unsubDiffs === null) this.start();
1049
1697
  }
1050
- release() {
1698
+ release(active = true) {
1051
1699
  this.refs -= 1;
1052
- if (this.refs <= 0) this.dispose();
1700
+ if (active) this.activeRefs -= 1;
1701
+ queueMicrotask(() => {
1702
+ if (this.refs <= 0) this.dispose();
1703
+ else if (this.activeRefs <= 0) this.pause();
1704
+ });
1053
1705
  }
1054
1706
  get isDisposed() {
1055
- return this.disposed;
1707
+ return this.disposed || this.refs <= 0 && this.unsubDiffs !== null;
1056
1708
  }
1057
1709
  /** Synchronous part of an identity swap: the snapshot becomes empty 'pending' at once (R15). */
1058
1710
  swapRoot() {
@@ -1061,46 +1713,83 @@ var RacedListStore = class {
1061
1713
  }
1062
1714
  dispose() {
1063
1715
  this.disposed = true;
1064
- this.abort.abort(new Error("disposed"));
1716
+ this.pause();
1717
+ if (this.snapWindow) this.deps.crud.closeWindow(this.snapWindow);
1718
+ this.snapWindow = null;
1719
+ this.local?.clear();
1720
+ this.local = null;
1721
+ this.snap = null;
1722
+ this.prepared = null;
1723
+ this.overlay.clear();
1724
+ this.inSession.clear();
1725
+ this.tombstones.clear();
1726
+ this.store.replace(emptyRacedResult());
1727
+ }
1728
+ pause() {
1729
+ this.observation += 1;
1730
+ this.checkpointRefresh.cancel();
1731
+ this.abort.abort(new Error("observer stopped"));
1065
1732
  this.unsubDiffs?.();
1066
1733
  this.unsubCrud?.();
1067
1734
  this.unsubFresh?.();
1068
- if (this.snapWindow) this.deps.crud.closeWindow(this.snapWindow);
1069
- this.snapWindow = null;
1735
+ this.unsubDiffs = null;
1736
+ this.unsubCrud = null;
1737
+ this.unsubFresh = null;
1738
+ if (this.snapPending) {
1739
+ this.deps.snapshots.invalidate(this.snapshotKey());
1740
+ if (this.snapWindow) this.deps.crud.closeWindow(this.snapWindow);
1741
+ this.snapWindow = this.snap?.window ?? null;
1742
+ this.snapPending = false;
1743
+ }
1070
1744
  }
1071
1745
  // ---------------------------------------------------------------------------
1072
1746
  start() {
1747
+ if (this.disposed) return;
1748
+ this.abort = new AbortController();
1749
+ const observation = ++this.observation;
1073
1750
  const { driver, adapter, crud, freshness } = this.deps;
1074
1751
  const prepared = adapter.prepare(this.def.query(this.scope));
1075
1752
  this.prepared = prepared;
1076
1753
  this.unsubDiffs = driver.diffs(prepared).subscribe(
1077
- (diff) => void this.onDiff(diff),
1754
+ (diff) => {
1755
+ if (observation === this.observation) void this.onDiff(diff);
1756
+ },
1078
1757
  (err) => {
1758
+ if (observation !== this.observation) return;
1079
1759
  this.localError = err;
1080
1760
  this.reconcile();
1081
1761
  }
1082
1762
  );
1083
1763
  this.unsubCrud = crud.onChange(() => this.reconcile());
1084
- this.unsubFresh = freshness.onCompletion(() => void this.onCompletion());
1085
- if (this.deps.apiLegEnabled) this.startApiLeg();
1764
+ this.unsubFresh = freshness.onCompletion((completion) => {
1765
+ if (completion.stream === null || this.def.stream && completion.stream === streamKey(this.def.stream)) {
1766
+ if (!this.owned) this.checkpointRefresh.request();
1767
+ }
1768
+ });
1769
+ if (this.deps.apiLegEnabled && !this.owned) this.startApiLeg();
1086
1770
  this.reconcile();
1087
1771
  }
1088
1772
  startApiLeg() {
1773
+ const observation = this.observation;
1774
+ const abort = this.abort;
1089
1775
  const { snapshots, crud } = this.deps;
1090
1776
  const staleTime = this.def.staleTime ?? 10 * 6e4;
1091
1777
  this.snapPending = true;
1092
1778
  const window = crud.openWindow();
1093
1779
  this.snapWindow = window;
1094
- const key = ["powersync-bridge", this.deps.identityKey, this.deps.epoch, this.def.id, this.scopeKey];
1780
+ const key = this.snapshotKey();
1095
1781
  snapshots.fetch(key, staleTime, (signal) => {
1096
1782
  const combined = new AbortController();
1097
1783
  const onAbort = () => combined.abort();
1098
1784
  signal.addEventListener("abort", onAbort, { once: true });
1099
- this.abort.signal.addEventListener("abort", onAbort, { once: true });
1100
- return this.def.fetchSnapshot(this.scope, combined.signal);
1785
+ abort.signal.addEventListener("abort", onAbort, { once: true });
1786
+ return this.def.fetchSnapshot(this.scope, combined.signal).finally(() => {
1787
+ signal.removeEventListener("abort", onAbort);
1788
+ abort.signal.removeEventListener("abort", onAbort);
1789
+ });
1101
1790
  }).then(
1102
1791
  (record) => {
1103
- if (this.disposed) return;
1792
+ if (this.disposed || observation !== this.observation) return;
1104
1793
  this.snapPending = false;
1105
1794
  this.apiMs = this.deps.clock.now() - this.startedAt;
1106
1795
  const rows = /* @__PURE__ */ new Map();
@@ -1108,32 +1797,38 @@ var RacedListStore = class {
1108
1797
  const item = this.def.mapApi(raw);
1109
1798
  rows.set(idOf(item), item);
1110
1799
  }
1800
+ if (this.snap) crud.closeWindow(this.snap.window);
1111
1801
  this.snap = { rows, record, window };
1112
1802
  this.reconcile();
1113
1803
  },
1114
1804
  (err) => {
1115
- if (this.disposed) return;
1805
+ if (this.disposed || observation !== this.observation) return;
1116
1806
  this.snapPending = false;
1117
1807
  this.snapError = err instanceof Error ? err : new Error(String(err));
1118
1808
  crud.closeWindow(window);
1119
- this.snapWindow = null;
1809
+ this.snapWindow = this.snap?.window ?? null;
1120
1810
  this.reconcile();
1121
1811
  }
1122
1812
  );
1123
1813
  }
1814
+ snapshotKey() {
1815
+ return ["powersync-bridge", this.deps.identityKey, this.deps.epoch, this.def.id, this.scopeKey];
1816
+ }
1124
1817
  /**
1125
1818
  * A checkpoint completed. Status events and diff emissions are separate SDK events with no
1126
1819
  * ordering guarantee; taking ownership on the stale local map would render the pre-checkpoint
1127
1820
  * value for one frame (fresh→old→fresh). Pre-ownership we re-query once, then reconcile.
1128
1821
  */
1129
- async onCompletion() {
1130
- if (this.disposed || this.owned || !this.prepared || !this.emissionSeen) {
1131
- this.reconcile();
1132
- return;
1133
- }
1822
+ async onCompletion(isCurrent) {
1823
+ if (this.disposed || this.owned || !this.prepared || this.activeRefs <= 0) return;
1824
+ const freshAt = this.deps.freshness.freshAtFor(this.def.stream);
1134
1825
  try {
1135
- const rows = await this.deps.driver.query(this.prepared, this.abort.signal);
1136
- if (this.disposed) return;
1826
+ const prepared = this.prepared;
1827
+ const signal = this.abort.signal;
1828
+ const read = () => isCurrent() ? this.deps.driver.query(prepared, signal) : Promise.resolve(void 0);
1829
+ const rows = await (this.deps.backgroundReads ? this.deps.backgroundReads.run(read, signal) : read());
1830
+ if (!rows) return;
1831
+ if (this.disposed || !isCurrent()) return;
1137
1832
  const prev = this.local ?? /* @__PURE__ */ new Map();
1138
1833
  const next = /* @__PURE__ */ new Map();
1139
1834
  for (const row of rows) {
@@ -1146,12 +1841,16 @@ var RacedListStore = class {
1146
1841
  }
1147
1842
  }
1148
1843
  this.local = next;
1844
+ this.localFreshAt = Math.max(this.localFreshAt, freshAt);
1845
+ this.emissionSeen = true;
1846
+ this.localError = void 0;
1149
1847
  } catch {
1150
1848
  }
1151
1849
  this.reconcile();
1152
1850
  }
1153
1851
  async onDiff(diff) {
1154
- if (this.disposed) return;
1852
+ if (this.disposed || this.activeRefs <= 0) return;
1853
+ const observation = this.observation;
1155
1854
  const { crud } = this.deps;
1156
1855
  const prev = this.local;
1157
1856
  const next = /* @__PURE__ */ new Map();
@@ -1164,6 +1863,8 @@ var RacedListStore = class {
1164
1863
  }
1165
1864
  }
1166
1865
  this.local = next;
1866
+ if (!this.checkpointRefresh.pending)
1867
+ this.localFreshAt = this.deps.freshness.freshAtFor(this.def.stream);
1167
1868
  if (!this.emissionSeen) {
1168
1869
  this.emissionSeen = true;
1169
1870
  this.localMs = this.deps.clock.now() - this.startedAt;
@@ -1171,15 +1872,13 @@ var RacedListStore = class {
1171
1872
  void crud.resync();
1172
1873
  if (this.owned && prev) {
1173
1874
  const removed = /* @__PURE__ */ new Map();
1174
- for (const id of diff.removed) {
1175
- const row = prev.get(id);
1176
- if (row) removed.set(id, row);
1177
- }
1178
- if (removed.size > 0) await this.applyRemovals(removed);
1875
+ for (const [id, row] of prev) if (!next.has(id)) removed.set(id, row);
1876
+ if (removed.size > 0) await this.applyRemovals(removed, observation);
1179
1877
  }
1878
+ if (observation !== this.observation) return;
1180
1879
  this.reconcile();
1181
1880
  }
1182
- async applyRemovals(removed) {
1881
+ async applyRemovals(removed, observation) {
1183
1882
  const classes = await classifyRemovals(
1184
1883
  {
1185
1884
  driver: this.deps.driver,
@@ -1190,7 +1889,7 @@ var RacedListStore = class {
1190
1889
  },
1191
1890
  removed
1192
1891
  );
1193
- if (this.disposed) return;
1892
+ if (this.disposed || observation !== this.observation) return;
1194
1893
  for (const [id, cls] of classes) {
1195
1894
  const entry = this.overlay.get(id);
1196
1895
  switch (cls) {
@@ -1213,18 +1912,24 @@ var RacedListStore = class {
1213
1912
  if (this.disposed) return;
1214
1913
  const result = this.owned ? this.renderOwned() : this.renderRace();
1215
1914
  const prev = this.store.getSnapshot();
1216
- if (shallowEqualResult(prev, result)) return;
1915
+ if (shallowEqualResult(
1916
+ prev,
1917
+ result
1918
+ ))
1919
+ return;
1217
1920
  this.store.replace(result);
1218
1921
  this.report(result);
1219
1922
  }
1220
1923
  freshAt() {
1221
- return this.deps.freshness.freshAtFor(this.def.stream);
1924
+ return this.localFreshAt;
1222
1925
  }
1223
1926
  localFresh() {
1224
- return this.emissionSeen && this.deps.freshness.isFresh(this.def.stream);
1927
+ const connectedAt = this.deps.freshness.sessionConnectAtMs;
1928
+ return this.emissionSeen && connectedAt !== null && this.localFreshAt >= connectedAt;
1225
1929
  }
1226
1930
  tryTakeOwnership() {
1227
1931
  if (this.owned) return true;
1932
+ if (this.checkpointRefresh.pending) return false;
1228
1933
  const apiFreshAt = this.snap?.record.fetchedAt ?? Number.NEGATIVE_INFINITY;
1229
1934
  if (!(this.localFresh() && this.freshAt() > apiFreshAt)) return false;
1230
1935
  this.owned = true;
@@ -1234,7 +1939,8 @@ var RacedListStore = class {
1234
1939
  if (this.snap) {
1235
1940
  if (this.def.apiScopeSupersetOfLocal) {
1236
1941
  for (const [id, row] of this.snap.rows) {
1237
- if (!overlay.has(id) && !this.tombstones.has(id)) overlay.set(id, { row, origin: "api", presentLocally: false });
1942
+ if (!overlay.has(id) && !this.tombstones.has(id))
1943
+ overlay.set(id, { row, origin: "api", presentLocally: false });
1238
1944
  }
1239
1945
  }
1240
1946
  this.deps.crud.closeWindow(this.snap.window);
@@ -1281,11 +1987,14 @@ var RacedListStore = class {
1281
1987
  const inS = S?.rows.get(id);
1282
1988
  const pending = crud.pendingOp(this.table, id, S?.window);
1283
1989
  if (pending) {
1284
- if (inLocal !== void 0) entries.push({ row: inLocal, origin: "local", presentLocally: true });
1990
+ if (inLocal !== void 0)
1991
+ entries.push({ row: inLocal, origin: "local", presentLocally: true });
1285
1992
  continue;
1286
1993
  }
1287
1994
  if (inLocal !== void 0 && inS !== void 0) {
1288
- entries.push(apiFresher ? { row: inS, origin: "api", presentLocally: true } : { row: inLocal, origin: "local", presentLocally: true });
1995
+ entries.push(
1996
+ apiFresher ? { row: inS, origin: "api", presentLocally: true } : { row: inLocal, origin: "local", presentLocally: true }
1997
+ );
1289
1998
  continue;
1290
1999
  }
1291
2000
  if (inLocal !== void 0) {
@@ -1309,7 +2018,9 @@ var RacedListStore = class {
1309
2018
  finalize(entries, stale = false) {
1310
2019
  entries.sort((a, b) => this.compare(a.row, b.row));
1311
2020
  const limited = entries.slice(0, this.def.limit);
1312
- return limited.map((e) => stale ? { row: e.row, origin: e.origin, presentLocally: e.presentLocally, stale: true } : { row: e.row, origin: e.origin, presentLocally: e.presentLocally });
2021
+ return limited.map(
2022
+ (e) => stale ? { row: e.row, origin: e.origin, presentLocally: e.presentLocally, stale: true } : { row: e.row, origin: e.origin, presentLocally: e.presentLocally }
2023
+ );
1313
2024
  }
1314
2025
  build(items, state, awaiting) {
1315
2026
  const errors = {};
@@ -1360,12 +2071,27 @@ var SnapshotCache = class {
1360
2071
  if (existing && (existing.result === null || now - existing.result.fetchedAt < staleTime)) {
1361
2072
  return existing.promise;
1362
2073
  }
2074
+ for (const [entryKey, entry2] of this.entries) {
2075
+ if (entry2.result && now - entry2.result.fetchedAt >= staleTime) this.entries.delete(entryKey);
2076
+ }
2077
+ if (this.entries.size >= 64) {
2078
+ for (const [entryKey, entry2] of this.entries) {
2079
+ if (entry2.result) {
2080
+ this.entries.delete(entryKey);
2081
+ break;
2082
+ }
2083
+ }
2084
+ }
1363
2085
  const controller = new AbortController();
1364
2086
  const run = async (signal) => {
1365
2087
  const res = await fetcher(signal);
1366
2088
  return { rows: res.rows, asOf: res.asOf, requestedAt: now, fetchedAt: this.clock.now() };
1367
2089
  };
1368
- const promise = this.queryClient ? this.queryClient.fetchQuery({ queryKey: key, queryFn: ({ signal }) => run(signal), staleTime }) : run(controller.signal);
2090
+ const promise = this.queryClient ? this.queryClient.fetchQuery({
2091
+ queryKey: key,
2092
+ queryFn: ({ signal }) => run(signal),
2093
+ staleTime
2094
+ }) : run(controller.signal);
1369
2095
  const entry = { requestedAt: now, promise, result: null, controller };
1370
2096
  this.entries.set(k, entry);
1371
2097
  promise.then(
@@ -1379,6 +2105,14 @@ var SnapshotCache = class {
1379
2105
  return promise;
1380
2106
  }
1381
2107
  /** Cancel every in-flight fetch under a key prefix (identity swap). */
2108
+ invalidate(key) {
2109
+ const encoded = JSON.stringify(key);
2110
+ this.entries.get(encoded)?.controller.abort(new Error("invalidated"));
2111
+ void this.queryClient?.cancelQueries?.({ queryKey: key, exact: true }).catch(() => {
2112
+ });
2113
+ this.entries.delete(encoded);
2114
+ }
2115
+ /** Cancel every in-flight fetch under a key prefix (identity swap). */
1382
2116
  abortAll(prefix) {
1383
2117
  const p = JSON.stringify(prefix).slice(0, -1);
1384
2118
  for (const [k, e] of this.entries) {
@@ -1409,10 +2143,12 @@ var Bridge = class {
1409
2143
  clock;
1410
2144
  /** Increments on every identity swap; hooks subscribe to re-acquire their stores. */
1411
2145
  epochStore;
2146
+ connectivity = new SnapshotStore(void 0);
1412
2147
  identity = new IdentityTracker();
1413
2148
  freshness;
1414
2149
  crud;
1415
2150
  snapshots;
2151
+ backgroundReads = new BackgroundReads();
1416
2152
  epochSnapshot;
1417
2153
  stores = /* @__PURE__ */ new Map();
1418
2154
  gates;
@@ -1420,12 +2156,16 @@ var Bridge = class {
1420
2156
  identityOpts;
1421
2157
  onEvicted;
1422
2158
  disposed = false;
2159
+ onNetwork = () => this.setOnline(globalThis.navigator?.onLine);
1423
2160
  constructor(opts) {
1424
2161
  this.driver = opts.driver;
1425
2162
  this.adapter = opts.adapter;
1426
2163
  this.clock = opts.clock ?? realClock;
1427
2164
  this.platform = opts.platform ?? detectPlatform();
1428
- this.gates = { native: opts.platformGates?.native ?? "local-first", web: opts.platformGates?.web ?? "race" };
2165
+ this.gates = {
2166
+ native: opts.platformGates?.native ?? "local-first",
2167
+ web: opts.platformGates?.web ?? "race"
2168
+ };
1429
2169
  this.measure = opts.measure?.sink;
1430
2170
  this.identityOpts = opts.identity ?? {};
1431
2171
  this.onEvicted = opts.onEvicted;
@@ -1438,6 +2178,8 @@ var Bridge = class {
1438
2178
  this.crud.start();
1439
2179
  this.freshness.onClear(() => this.identity.signalClear());
1440
2180
  this.identity.onChange(() => this.swapEpoch());
2181
+ globalThis.addEventListener?.("online", this.onNetwork);
2182
+ globalThis.addEventListener?.("offline", this.onNetwork);
1441
2183
  }
1442
2184
  get epoch() {
1443
2185
  return this.identity.epoch;
@@ -1445,6 +2187,32 @@ var Bridge = class {
1445
2187
  get identityKey() {
1446
2188
  return this.identity.currentKey;
1447
2189
  }
2190
+ setOnline(online) {
2191
+ this.connectivity.replace(online);
2192
+ }
2193
+ query(options) {
2194
+ const key = `query:${this.epoch}:${infiniteQueryKey(options)}`;
2195
+ return this.acquire(
2196
+ key,
2197
+ () => new InfiniteQueryStore(
2198
+ {
2199
+ driver: this.driver,
2200
+ adapter: this.adapter,
2201
+ crud: this.crud,
2202
+ freshness: this.freshness,
2203
+ snapshots: this.snapshots,
2204
+ clock: this.clock,
2205
+ epoch: this.epoch,
2206
+ identityKey: this.identityKey,
2207
+ apiLegEnabled: true,
2208
+ measure: this.measure,
2209
+ connectivity: this.connectivity,
2210
+ backgroundReads: this.backgroundReads
2211
+ },
2212
+ options
2213
+ )
2214
+ );
2215
+ }
1448
2216
  /** Resolves once the initial upload-queue read has landed (tests). */
1449
2217
  ready() {
1450
2218
  return this.crud.ready();
@@ -1458,7 +2226,8 @@ var Bridge = class {
1458
2226
  Object.defineProperty(wrapped, "fetchCredentials", {
1459
2227
  value: async () => {
1460
2228
  const creds = await connector.fetchCredentials();
1461
- if (creds?.token) this.identity.observe(identityKeyFromToken(creds.token, this.identityOpts));
2229
+ if (creds?.token)
2230
+ this.identity.observe(identityKeyFromToken(creds.token, this.identityOpts));
1462
2231
  return creds;
1463
2232
  },
1464
2233
  configurable: true,
@@ -1520,7 +2289,8 @@ var Bridge = class {
1520
2289
  epoch: this.epoch,
1521
2290
  identityKey: this.identityKey,
1522
2291
  apiLegEnabled: this.apiLegEnabled(),
1523
- measure: this.measure
2292
+ measure: this.measure,
2293
+ backgroundReads: this.backgroundReads
1524
2294
  },
1525
2295
  def,
1526
2296
  scope,
@@ -1546,7 +2316,10 @@ var Bridge = class {
1546
2316
  }
1547
2317
  onDemand(name, params, opts = {}) {
1548
2318
  const key = `ondemand:${this.epoch}:${name}:${JSON.stringify(params)}`;
1549
- return this.acquire(key, () => new OnDemandStreamStore(this.driver, this.clock, name, params, opts));
2319
+ return this.acquire(
2320
+ key,
2321
+ () => new OnDemandStreamStore(this.driver, this.clock, name, params, opts)
2322
+ );
1550
2323
  }
1551
2324
  autoSync(config, ids) {
1552
2325
  const key = `flag:${this.epoch}:${config.stream}:${JSON.stringify([...ids].sort())}`;
@@ -1555,6 +2328,8 @@ var Bridge = class {
1555
2328
  dispose() {
1556
2329
  if (this.disposed) return;
1557
2330
  this.disposed = true;
2331
+ globalThis.removeEventListener?.("online", this.onNetwork);
2332
+ globalThis.removeEventListener?.("offline", this.onNetwork);
1558
2333
  for (const s of this.stores.values()) s.dispose();
1559
2334
  this.stores.clear();
1560
2335
  this.snapshots.clear();
@@ -1578,6 +2353,8 @@ exports.AutoSyncFlagStore = AutoSyncFlagStore;
1578
2353
  exports.Bridge = Bridge;
1579
2354
  exports.DEFAULT_VOLATILE_CLAIMS = DEFAULT_VOLATILE_CLAIMS;
1580
2355
  exports.InfiniteListStore = InfiniteListStore;
2356
+ exports.InfiniteQueryStore = InfiniteQueryStore;
2357
+ exports.OfflineError = OfflineError;
1581
2358
  exports.OnDemandStreamStore = OnDemandStreamStore;
1582
2359
  exports.RacedListStore = RacedListStore;
1583
2360
  exports.SnapshotStore = SnapshotStore;
@@ -1593,6 +2370,7 @@ exports.emptyRacedResult = emptyRacedResult;
1593
2370
  exports.encodeCursor = encodeCursor;
1594
2371
  exports.identityKeyFromClaims = identityKeyFromClaims;
1595
2372
  exports.identityKeyFromToken = identityKeyFromToken;
2373
+ exports.infiniteQueryKey = infiniteQueryKey;
1596
2374
  exports.keyColumns = keyColumns;
1597
2375
  exports.probePresence = probePresence;
1598
2376
  exports.realClock = realClock;