powersync-bridge 0.0.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,1601 @@
1
+ 'use strict';
2
+
3
+ // src/clock.ts
4
+ var realClock = {
5
+ now: () => Date.now(),
6
+ setTimeout: (fn, ms) => {
7
+ const h = setTimeout(fn, ms);
8
+ return () => clearTimeout(h);
9
+ }
10
+ };
11
+ var TimeoutError = class extends Error {
12
+ name = "TimeoutError";
13
+ };
14
+ async function withTimeout(clock, p, ms, signal) {
15
+ const holder = {};
16
+ const timeout = new Promise((_, reject) => {
17
+ holder.cancel = clock.setTimeout(() => reject(new TimeoutError(`timed out after ${ms}ms`)), ms);
18
+ signal?.addEventListener("abort", () => reject(signal.reason ?? new Error("aborted")), { once: true });
19
+ });
20
+ try {
21
+ return await Promise.race([p, timeout]);
22
+ } finally {
23
+ holder.cancel?.();
24
+ }
25
+ }
26
+
27
+ // src/removal.ts
28
+ var PROBE_CHUNK = 500;
29
+ async function probePresence(driver, table, ids) {
30
+ const present = /* @__PURE__ */ new Set();
31
+ for (let i = 0; i < ids.length; i += PROBE_CHUNK) {
32
+ const chunk = ids.slice(i, i + PROBE_CHUNK);
33
+ const found = await driver.presentIds(table, chunk);
34
+ for (const id of found) present.add(id);
35
+ }
36
+ return present;
37
+ }
38
+ async function classifyRemovals(ctx, removed) {
39
+ const out = /* @__PURE__ */ new Map();
40
+ const ids = Array.from(removed.keys());
41
+ if (ids.length === 0) return out;
42
+ const present = await probePresence(ctx.driver, ctx.table, ids);
43
+ const now = ctx.clock.now();
44
+ const status = ctx.driver.syncStatus();
45
+ for (const [id, row] of removed) {
46
+ if (present.has(id)) {
47
+ out.set(id, "filtered");
48
+ continue;
49
+ }
50
+ const stream = ctx.provenance(row);
51
+ if (stream) {
52
+ const st = status.forStream(stream);
53
+ const tracked = st !== void 0;
54
+ const beforeExpiry = st?.active === true || st?.expiresAt === null || st?.expiresAt !== void 0 && st.expiresAt !== null && now < st.expiresAt.getTime();
55
+ out.set(id, tracked && beforeExpiry ? "delete" : "eviction");
56
+ continue;
57
+ }
58
+ if (ctx.isInWindow) {
59
+ try {
60
+ out.set(id, ctx.isInWindow(row) ? "delete" : "eviction");
61
+ } catch {
62
+ out.set(id, "retain");
63
+ }
64
+ continue;
65
+ }
66
+ out.set(id, "delete");
67
+ }
68
+ return out;
69
+ }
70
+
71
+ // src/store.ts
72
+ var SnapshotStore = class {
73
+ snapshot;
74
+ listeners = /* @__PURE__ */ new Set();
75
+ constructor(initial) {
76
+ this.snapshot = initial;
77
+ }
78
+ getSnapshot() {
79
+ return this.snapshot;
80
+ }
81
+ subscribe(listener) {
82
+ this.listeners.add(listener);
83
+ return () => this.listeners.delete(listener);
84
+ }
85
+ replace(next) {
86
+ if (Object.is(next, this.snapshot)) return;
87
+ this.snapshot = next;
88
+ for (const l of Array.from(this.listeners)) l();
89
+ }
90
+ get listenerCount() {
91
+ return this.listeners.size;
92
+ }
93
+ };
94
+ function shallowEqualResult(a, b) {
95
+ const ka = Object.keys(a);
96
+ const kb = Object.keys(b);
97
+ if (ka.length !== kb.length) return false;
98
+ for (const k of ka) {
99
+ const va = a[k];
100
+ const vb = b[k];
101
+ if (Array.isArray(va) && Array.isArray(vb)) {
102
+ if (va.length !== vb.length) return false;
103
+ for (let i = 0; i < va.length; i++) if (!Object.is(va[i], vb[i])) return false;
104
+ continue;
105
+ }
106
+ if (va !== null && typeof va === "object" && vb !== null && typeof vb === "object") {
107
+ if (!shallowEqualResult(va, vb)) return false;
108
+ continue;
109
+ }
110
+ if (!Object.is(va, vb)) return false;
111
+ }
112
+ return true;
113
+ }
114
+
115
+ // src/flagger.ts
116
+ var AutoSyncFlagStore = class {
117
+ constructor(driver, clock, config, ids) {
118
+ this.driver = driver;
119
+ this.clock = clock;
120
+ this.config = config;
121
+ this.ids = ids;
122
+ this.store.replace({ state: "idle" });
123
+ }
124
+ driver;
125
+ clock;
126
+ config;
127
+ ids;
128
+ store = new SnapshotStore({ status: "idle" });
129
+ handle = null;
130
+ unsubStatus = null;
131
+ cancelTimeout = null;
132
+ disposed = false;
133
+ refs = 0;
134
+ retain() {
135
+ this.refs += 1;
136
+ if (this.refs === 1) void this.start();
137
+ }
138
+ release() {
139
+ this.refs -= 1;
140
+ if (this.refs <= 0) this.dispose();
141
+ }
142
+ get isDisposed() {
143
+ return this.disposed;
144
+ }
145
+ swapRoot() {
146
+ this.store.replace({ state: "idle" });
147
+ this.disposed = true;
148
+ }
149
+ dispose() {
150
+ this.disposed = true;
151
+ this.unsubStatus?.();
152
+ this.cancelTimeout?.();
153
+ this.handle?.unsubscribe();
154
+ this.handle = null;
155
+ }
156
+ async start() {
157
+ this.store.replace({ state: "flagging" });
158
+ try {
159
+ await this.config.requestFlag(this.ids);
160
+ if (this.disposed) return;
161
+ this.handle = await this.driver.syncStream(this.config.stream, null, {});
162
+ this.store.replace({ state: "syncing" });
163
+ this.cancelTimeout = this.clock.setTimeout(() => {
164
+ if (!this.disposed && this.store.getSnapshot().state === "syncing") this.store.replace({ state: "timeout" });
165
+ }, this.config.timeoutMs ?? 15e3);
166
+ const check = async () => {
167
+ if (this.disposed) return;
168
+ const present = await probePresence(this.driver, this.config.table, this.ids);
169
+ if (!this.disposed && this.ids.every((id) => present.has(id))) {
170
+ this.cancelTimeout?.();
171
+ this.store.replace({ state: "synced" });
172
+ this.unsubStatus?.();
173
+ }
174
+ };
175
+ this.unsubStatus = this.driver.onStatusChanged(() => void check());
176
+ await check();
177
+ } catch (e) {
178
+ if (!this.disposed) this.store.replace({ state: "error", error: e instanceof Error ? e : new Error(String(e)) });
179
+ }
180
+ }
181
+ };
182
+
183
+ // src/freshness.ts
184
+ function streamKey(desc) {
185
+ return `${desc.name}:${desc.params ? JSON.stringify(desc.params) : ""}`;
186
+ }
187
+ var FreshnessTracker = class {
188
+ constructor(driver, clock) {
189
+ this.driver = driver;
190
+ this.clock = clock;
191
+ }
192
+ driver;
193
+ clock;
194
+ sessionConnectAt = null;
195
+ localFreshAt = Number.NEGATIVE_INFINITY;
196
+ prevCompletionAt = null;
197
+ lastDownloadStart = null;
198
+ lastSeenSyncedAt = null;
199
+ lastDownloading = false;
200
+ lastConnected = false;
201
+ lastHasSynced = void 0;
202
+ streamFreshAt = /* @__PURE__ */ new Map();
203
+ streamSeenSyncedAt = /* @__PURE__ */ new Map();
204
+ completions = [];
205
+ completionListeners = /* @__PURE__ */ new Set();
206
+ clearListeners = /* @__PURE__ */ new Set();
207
+ unsubscribe = null;
208
+ /** Call once at engine init and again after every identity swap. */
209
+ start(epochStartedAt) {
210
+ this.stop();
211
+ this.reset();
212
+ const status = this.driver.syncStatus();
213
+ this.lastConnected = status.connected;
214
+ this.lastDownloading = status.downloading;
215
+ this.lastHasSynced = status.hasSynced;
216
+ this.lastSeenSyncedAt = status.lastSyncedAt?.getTime() ?? null;
217
+ if (status.connected) this.sessionConnectAt = epochStartedAt;
218
+ if (status.downloading) {
219
+ this.prevCompletionAt = Math.max(epochStartedAt, this.lastSeenSyncedAt ?? Number.NEGATIVE_INFINITY);
220
+ }
221
+ for (const s of status.syncStreams) {
222
+ if (s.lastSyncedAt) this.streamSeenSyncedAt.set(streamKey(s), s.lastSyncedAt.getTime());
223
+ }
224
+ this.unsubscribe = this.driver.onStatusChanged(() => this.onStatus(this.driver.syncStatus()));
225
+ }
226
+ stop() {
227
+ this.unsubscribe?.();
228
+ this.unsubscribe = null;
229
+ }
230
+ reset() {
231
+ this.sessionConnectAt = null;
232
+ this.localFreshAt = Number.NEGATIVE_INFINITY;
233
+ this.prevCompletionAt = null;
234
+ this.lastDownloadStart = null;
235
+ this.lastSeenSyncedAt = null;
236
+ this.streamFreshAt.clear();
237
+ this.streamSeenSyncedAt.clear();
238
+ this.completions.length = 0;
239
+ }
240
+ get sessionConnectAtMs() {
241
+ return this.sessionConnectAt;
242
+ }
243
+ get localFreshAtMs() {
244
+ return this.localFreshAt;
245
+ }
246
+ streamFreshAtMs(desc) {
247
+ return this.streamFreshAt.get(streamKey(desc)) ?? Number.NEGATIVE_INFINITY;
248
+ }
249
+ /** Freshness for a list: the owning stream's stamp when declared, else the global stamp. */
250
+ freshAtFor(stream) {
251
+ if (!stream) return this.localFreshAt;
252
+ return Math.max(this.streamFreshAtMs(stream), this.localFreshAt);
253
+ }
254
+ /** "Answered" = in-session freshness evidence (R5). */
255
+ isFresh(stream) {
256
+ if (this.sessionConnectAt === null) return false;
257
+ return this.freshAtFor(stream) >= this.sessionConnectAt;
258
+ }
259
+ onCompletion(l) {
260
+ this.completionListeners.add(l);
261
+ return () => this.completionListeners.delete(l);
262
+ }
263
+ onClear(l) {
264
+ this.clearListeners.add(l);
265
+ return () => this.clearListeners.delete(l);
266
+ }
267
+ onStatus(status) {
268
+ const now = this.clock();
269
+ if (status.connected && !this.lastConnected && this.sessionConnectAt === null) {
270
+ this.sessionConnectAt = now;
271
+ }
272
+ this.lastConnected = status.connected;
273
+ if (status.downloading && !this.lastDownloading) this.lastDownloadStart = now;
274
+ this.lastDownloading = status.downloading;
275
+ const syncedAt = status.lastSyncedAt?.getTime() ?? null;
276
+ if (syncedAt !== null && syncedAt !== this.lastSeenSyncedAt) {
277
+ this.recordCompletion(now, null);
278
+ }
279
+ if (syncedAt !== null) this.lastSeenSyncedAt = syncedAt;
280
+ for (const s of status.syncStreams) {
281
+ const key = streamKey(s);
282
+ const at = s.lastSyncedAt?.getTime() ?? null;
283
+ if (at !== null && at !== this.streamSeenSyncedAt.get(key)) {
284
+ this.streamSeenSyncedAt.set(key, at);
285
+ const stamp = this.stampFor(now);
286
+ const prev = this.streamFreshAt.get(key) ?? Number.NEGATIVE_INFINITY;
287
+ this.streamFreshAt.set(key, Math.max(prev, stamp));
288
+ const c = { stamp: Math.max(prev, stamp), completedAt: now, stream: key };
289
+ for (const l of this.completionListeners) l(c);
290
+ }
291
+ }
292
+ if (this.lastHasSynced === true && status.hasSynced !== true) {
293
+ for (const l of this.clearListeners) l();
294
+ }
295
+ this.lastHasSynced = status.hasSynced;
296
+ }
297
+ stampFor(now) {
298
+ const candidates = [];
299
+ if (this.lastDownloadStart !== null && this.lastDownloadStart <= now) candidates.push(this.lastDownloadStart);
300
+ if (this.prevCompletionAt !== null) candidates.push(this.prevCompletionAt);
301
+ if (candidates.length === 0 && this.sessionConnectAt !== null) candidates.push(this.sessionConnectAt);
302
+ if (candidates.length === 0) return Number.NEGATIVE_INFINITY;
303
+ return Math.max(...candidates);
304
+ }
305
+ recordCompletion(now, stream) {
306
+ const stamp = this.stampFor(now);
307
+ this.localFreshAt = Math.max(this.localFreshAt, stamp);
308
+ this.prevCompletionAt = now;
309
+ this.lastDownloadStart = null;
310
+ const c = { stamp: this.localFreshAt, completedAt: now, stream };
311
+ this.completions.push(c);
312
+ for (const l of this.completionListeners) l(c);
313
+ }
314
+ };
315
+
316
+ // src/identity.ts
317
+ var DEFAULT_VOLATILE_CLAIMS = [
318
+ // OIDC volatile set
319
+ "exp",
320
+ "iat",
321
+ "nbf",
322
+ "jti",
323
+ "auth_time",
324
+ "at_hash",
325
+ "nonce",
326
+ "azp",
327
+ // vendor session claims that rotate on refresh
328
+ "sid",
329
+ "session_id",
330
+ "rat"
331
+ ];
332
+ function base64UrlToString(input) {
333
+ const b64 = input.replace(/-/g, "+").replace(/_/g, "/");
334
+ const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
335
+ if (typeof atob === "function") return decodeURIComponent(escape(atob(padded)));
336
+ return Buffer.from(padded, "base64").toString("utf8");
337
+ }
338
+ function decodeJwtPayload(token) {
339
+ const parts = token.split(".");
340
+ if (parts.length < 2 || !parts[1]) throw new Error("powersync-bridge: token is not a JWT");
341
+ const parsed = JSON.parse(base64UrlToString(parts[1]));
342
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
343
+ throw new Error("powersync-bridge: JWT payload is not an object");
344
+ }
345
+ return parsed;
346
+ }
347
+ function canonical(value) {
348
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
349
+ if (value !== null && typeof value === "object") {
350
+ const o = value;
351
+ return `{${Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonical(o[k])}`).join(",")}}`;
352
+ }
353
+ return JSON.stringify(value);
354
+ }
355
+ function identityKeyFromClaims(claims, opts = {}) {
356
+ const deny = new Set(opts.denyList ?? DEFAULT_VOLATILE_CLAIMS);
357
+ const stable = {};
358
+ for (const k of Object.keys(claims).sort()) {
359
+ if (!deny.has(k)) stable[k] = claims[k];
360
+ }
361
+ return canonical({ claims: stable, params: opts.connectionParams ?? {} });
362
+ }
363
+ function identityKeyFromToken(token, opts = {}) {
364
+ return identityKeyFromClaims(decodeJwtPayload(token), opts);
365
+ }
366
+ var IdentityTracker = class {
367
+ key = null;
368
+ epochCounter = 0;
369
+ listeners = /* @__PURE__ */ new Set();
370
+ get epoch() {
371
+ return this.epochCounter;
372
+ }
373
+ get currentKey() {
374
+ return this.key;
375
+ }
376
+ observe(key) {
377
+ if (key === this.key) return false;
378
+ this.key = key;
379
+ this.epochCounter += 1;
380
+ for (const l of this.listeners) l(this.epochCounter, key);
381
+ return true;
382
+ }
383
+ /** A clear (hasSynced true → false) is an epoch signal even if the token is unchanged. */
384
+ signalClear() {
385
+ this.epochCounter += 1;
386
+ for (const l of this.listeners) l(this.epochCounter, this.key);
387
+ }
388
+ onChange(l) {
389
+ this.listeners.add(l);
390
+ return () => this.listeners.delete(l);
391
+ }
392
+ };
393
+
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
+ // src/infinite.ts
457
+ var EMPTY = Object.freeze({
458
+ items: Object.freeze([]),
459
+ boundary: "more",
460
+ awaitingSync: false,
461
+ loading: false,
462
+ errors: Object.freeze({}),
463
+ warnings: Object.freeze([])
464
+ });
465
+ function emptyInfiniteResult() {
466
+ return EMPTY;
467
+ }
468
+ var DEFAULT_TTL = 300;
469
+ var DEFAULT_CAP = 3;
470
+ var DEFAULT_TIMEOUT = 15e3;
471
+ var DEFAULT_KEEP = 3;
472
+ var InfiniteListStore = class {
473
+ constructor(deps, def, scope) {
474
+ this.deps = deps;
475
+ this.def = def;
476
+ this.scope = scope;
477
+ this.compare = rowComparator(def.orderKey, def.direction ?? "asc");
478
+ this.table = def.table ?? def.id;
479
+ }
480
+ deps;
481
+ def;
482
+ scope;
483
+ store = new SnapshotStore(emptyInfiniteResult());
484
+ compare;
485
+ table;
486
+ abort = new AbortController();
487
+ pages = 1;
488
+ local = /* @__PURE__ */ new Map();
489
+ lookahead = false;
490
+ emissionSeen = false;
491
+ localError;
492
+ unsubDiffs = null;
493
+ apiRows = /* @__PURE__ */ new Map();
494
+ retained = /* @__PURE__ */ new Map();
495
+ tombstones = /* @__PURE__ */ new Set();
496
+ subs = /* @__PURE__ */ new Map();
497
+ lru = [];
498
+ probeCursor = null;
499
+ serverFloor = void 0;
500
+ // undefined = not fetched yet
501
+ floorRevalidated = false;
502
+ boundary = "more";
503
+ reason;
504
+ awaitingSync = false;
505
+ loading = false;
506
+ errors = {};
507
+ warnings = [];
508
+ errorCount = 0;
509
+ nextRetryAt = 0;
510
+ disposed = false;
511
+ refs = 0;
512
+ retain() {
513
+ this.refs += 1;
514
+ if (this.refs === 1) this.subscribeLocal();
515
+ }
516
+ release() {
517
+ this.refs -= 1;
518
+ if (this.refs <= 0) this.dispose();
519
+ }
520
+ get isDisposed() {
521
+ return this.disposed;
522
+ }
523
+ swapRoot() {
524
+ this.store.replace(emptyInfiniteResult());
525
+ this.disposed = true;
526
+ }
527
+ dispose() {
528
+ this.disposed = true;
529
+ this.abort.abort(new Error("disposed"));
530
+ this.unsubDiffs?.();
531
+ this.unsubDiffs = null;
532
+ for (const h of this.subs.values()) h.unsubscribe();
533
+ this.subs.clear();
534
+ this.lru.length = 0;
535
+ }
536
+ // ---------------------------------------------------------------------------
537
+ subscribeLocal() {
538
+ this.unsubDiffs?.();
539
+ const limit = this.pages * this.def.pageSize + 1;
540
+ const builder = this.def.query({ cursor: this.def.initialCursor, limit }, this.scope);
541
+ const prepared = this.deps.adapter.prepare(builder);
542
+ this.unsubDiffs = this.deps.driver.diffs(prepared).subscribe(
543
+ (diff) => void this.onDiff(diff),
544
+ (err) => {
545
+ this.localError = err;
546
+ this.render();
547
+ }
548
+ );
549
+ }
550
+ async onDiff(diff) {
551
+ if (this.disposed) return;
552
+ const prev = this.local;
553
+ const next = /* @__PURE__ */ new Map();
554
+ const window = this.pages * this.def.pageSize;
555
+ const sorted = [...diff.all].sort(this.compare);
556
+ this.lookahead = sorted.length > window;
557
+ for (const row of sorted.slice(0, window)) {
558
+ const id = idOf(row);
559
+ next.set(id, row);
560
+ this.tombstones.delete(id);
561
+ this.retained.delete(id);
562
+ this.apiRows.delete(id);
563
+ }
564
+ this.local = next;
565
+ this.emissionSeen = true;
566
+ const removed = /* @__PURE__ */ new Map();
567
+ for (const [id, row] of prev) if (!next.has(id)) removed.set(id, row);
568
+ if (removed.size > 0) await this.applyRemovals(removed);
569
+ this.render();
570
+ }
571
+ provenance(row) {
572
+ const pol = this.def.escalate;
573
+ if (pol.kind !== "partition") return void 0;
574
+ const p = pol.partitionOf(cursorOfRow(row, this.def.orderKey));
575
+ if (p === null) return void 0;
576
+ return { name: pol.stream, params: pol.partitionParams(p) };
577
+ }
578
+ async applyRemovals(removed) {
579
+ const classes = await classifyRemovals(
580
+ { driver: this.deps.driver, clock: this.deps.clock, table: this.table, provenance: (r) => this.provenance(r), isInWindow: this.def.isInWindow },
581
+ removed
582
+ );
583
+ if (this.disposed) return;
584
+ const evicted = [];
585
+ for (const [id, cls] of classes) {
586
+ const row = removed.get(id);
587
+ if (!row) continue;
588
+ switch (cls) {
589
+ case "filtered":
590
+ break;
591
+ case "delete":
592
+ this.tombstones.add(id);
593
+ break;
594
+ case "eviction":
595
+ case "retain":
596
+ this.retained.set(id, row);
597
+ evicted.push(id);
598
+ break;
599
+ }
600
+ }
601
+ if (evicted.length > 0) this.deps.onEvicted?.(evicted);
602
+ }
603
+ // ---------------------------------------------------------------------------
604
+ lastCursor() {
605
+ const all = this.mergedEntries();
606
+ const last = all[all.length - 1];
607
+ if (!last) return this.def.initialCursor;
608
+ return cursorOfRow(last.row, this.def.orderKey);
609
+ }
610
+ /** Explicit user retry clears the backoff (R16). */
611
+ retry() {
612
+ this.errorCount = 0;
613
+ this.nextRetryAt = 0;
614
+ return this.loadMore();
615
+ }
616
+ async loadMore() {
617
+ if (this.disposed || this.loading || this.boundary === "end") return;
618
+ if (this.lookahead) {
619
+ this.pages += 1;
620
+ this.boundary = "more";
621
+ this.reason = void 0;
622
+ this.subscribeLocal();
623
+ this.render();
624
+ return;
625
+ }
626
+ this.loading = true;
627
+ this.render();
628
+ try {
629
+ const pol = this.def.escalate;
630
+ if (pol.kind === "partition") await this.escalatePartition(pol);
631
+ else if (pol.kind === "api") await this.escalateApi(pol.fetchPage);
632
+ else await this.escalateOffset(pol.fetchPage);
633
+ } finally {
634
+ this.loading = false;
635
+ this.awaitingSync = false;
636
+ if (!this.disposed) this.render();
637
+ }
638
+ }
639
+ setUnknown(reason, err) {
640
+ this.boundary = "unknown";
641
+ this.reason = reason;
642
+ if (err) this.errors = { ...this.errors, stream: err };
643
+ }
644
+ async escalatePartition(pol) {
645
+ if (!this.deps.driver.syncStatus().connected) return this.setUnknown("offline");
646
+ const isBefore = pol.isBefore ?? ((a, b) => a < b);
647
+ if (this.serverFloor === void 0 && pol.serverFloor) {
648
+ try {
649
+ this.serverFloor = await pol.serverFloor();
650
+ } catch {
651
+ this.serverFloor = null;
652
+ }
653
+ }
654
+ const floor = this.serverFloor ?? pol.floor;
655
+ const cap = pol.partitionCap ?? DEFAULT_CAP;
656
+ const timeoutMs = pol.timeoutMs ?? DEFAULT_TIMEOUT;
657
+ const cursor = this.lastCursor();
658
+ let p;
659
+ if (this.probeCursor !== null) {
660
+ p = pol.nextPartition(this.probeCursor);
661
+ if (p === null) return this.finishEnd(pol);
662
+ } else {
663
+ p = pol.partitionOf(cursor);
664
+ }
665
+ for (let probes = 0; ; probes++) {
666
+ if (p === null || isBefore(p, floor)) return this.finishEnd(pol);
667
+ if (probes === cap) return this.setUnknown("partition-cap");
668
+ const params = pol.partitionParams(p);
669
+ const key = streamKey({ name: pol.stream, params });
670
+ let handle = this.subs.get(key);
671
+ if (!handle) {
672
+ handle = await this.deps.driver.syncStream(pol.stream, params, { ttl: pol.ttl ?? DEFAULT_TTL });
673
+ this.track(key, handle);
674
+ }
675
+ this.awaitingSync = true;
676
+ this.render();
677
+ try {
678
+ await withTimeout(this.deps.clock, handle.waitForFirstSync(this.abort.signal), timeoutMs, this.abort.signal);
679
+ } catch (e) {
680
+ if (this.disposed) return;
681
+ this.untrack(key);
682
+ return this.setUnknown(e instanceof TimeoutError ? "timeout" : "error", e instanceof Error ? e : void 0);
683
+ }
684
+ if (this.disposed) return;
685
+ const builder = this.def.query({ cursor, limit: this.def.pageSize + 1 }, this.scope);
686
+ const rows = await this.deps.driver.query(this.deps.adapter.prepare(builder), this.abort.signal);
687
+ if (rows.length > 0) {
688
+ this.probeCursor = null;
689
+ this.pages += 1;
690
+ this.boundary = "more";
691
+ this.reason = void 0;
692
+ this.subscribeLocal();
693
+ return;
694
+ }
695
+ this.probeCursor = p;
696
+ this.untrack(key);
697
+ p = pol.nextPartition(p);
698
+ }
699
+ }
700
+ async finishEnd(pol) {
701
+ if (!this.floorRevalidated && pol.serverFloor) {
702
+ this.floorRevalidated = true;
703
+ try {
704
+ const fresh = await pol.serverFloor();
705
+ if (fresh !== null && fresh !== this.serverFloor) {
706
+ this.serverFloor = fresh;
707
+ this.probeCursor = null;
708
+ return this.escalatePartition(pol);
709
+ }
710
+ } catch {
711
+ }
712
+ }
713
+ this.boundary = "end";
714
+ this.reason = void 0;
715
+ }
716
+ track(key, handle) {
717
+ this.subs.set(key, handle);
718
+ this.lru.push(key);
719
+ const keep = this.def.keepPagesAlive ?? DEFAULT_KEEP;
720
+ while (this.lru.length > keep) {
721
+ const old = this.lru.shift();
722
+ if (old && old !== key) this.untrack(old);
723
+ }
724
+ }
725
+ untrack(key) {
726
+ const h = this.subs.get(key);
727
+ if (h) h.unsubscribe();
728
+ this.subs.delete(key);
729
+ const i = this.lru.indexOf(key);
730
+ if (i >= 0) this.lru.splice(i, 1);
731
+ }
732
+ apiGate() {
733
+ const now = this.deps.clock.now();
734
+ if (now < this.nextRetryAt) {
735
+ this.setUnknown("offline");
736
+ return false;
737
+ }
738
+ return true;
739
+ }
740
+ apiFailure(err) {
741
+ this.errorCount += 1;
742
+ const backoff = Math.min(3e4, 1e3 * 2 ** (this.errorCount - 1));
743
+ this.nextRetryAt = this.deps.clock.now() + backoff;
744
+ const e = err instanceof Error ? err : new Error(String(err));
745
+ this.errors = { ...this.errors, api: e };
746
+ this.setUnknown(this.deps.driver.onlineHint() === false ? "offline" : "error");
747
+ }
748
+ async escalateApi(fetchPage) {
749
+ if (!this.apiGate()) return;
750
+ try {
751
+ const rows = await withTimeout(this.deps.clock, fetchPage(this.lastCursor(), this.abort.signal), this.deps.driver.onlineHint() === false ? 3e3 : 3e4, this.abort.signal);
752
+ this.absorbApi(rows);
753
+ } catch (e) {
754
+ if (!this.disposed) this.apiFailure(e);
755
+ }
756
+ }
757
+ async escalateOffset(fetchPage) {
758
+ if (!this.apiGate()) return;
759
+ const offset = this.mergedEntries().length;
760
+ try {
761
+ const rows = await withTimeout(this.deps.clock, fetchPage(offset, this.abort.signal), 3e4, this.abort.signal);
762
+ const cursor = this.lastCursor();
763
+ const shifted = rows.some(
764
+ (r) => this.local.has(idOf(r)) || this.apiRows.has(idOf(r)) || cursor !== void 0 && !isAfterCursor(r, cursor, this.def.orderKey, this.def.direction ?? "asc")
765
+ );
766
+ if (shifted && !this.warnings.includes("offset-shift")) this.warnings = [...this.warnings, "offset-shift"];
767
+ this.absorbApi(rows);
768
+ } catch (e) {
769
+ if (!this.disposed) this.apiFailure(e);
770
+ }
771
+ }
772
+ absorbApi(rows) {
773
+ this.errorCount = 0;
774
+ this.nextRetryAt = 0;
775
+ for (const row of rows) {
776
+ const id = idOf(row);
777
+ if (this.local.has(id) || this.apiRows.has(id) || this.tombstones.has(id)) continue;
778
+ this.apiRows.set(id, row);
779
+ }
780
+ if (rows.length === 0) {
781
+ this.boundary = "end";
782
+ this.reason = void 0;
783
+ } else {
784
+ this.boundary = "more";
785
+ this.reason = void 0;
786
+ }
787
+ }
788
+ // ---------------------------------------------------------------------------
789
+ mergedEntries() {
790
+ const entries = [];
791
+ for (const row of this.local.values()) entries.push({ row, origin: "local", presentLocally: true });
792
+ for (const [id, row] of this.retained) if (!this.local.has(id)) entries.push({ row, origin: "local", presentLocally: false });
793
+ for (const [id, row] of this.apiRows) if (!this.local.has(id) && !this.retained.has(id)) entries.push({ row, origin: "api", presentLocally: false });
794
+ entries.sort((a, b) => this.compare(a.row, b.row));
795
+ return entries;
796
+ }
797
+ render() {
798
+ if (this.disposed) return;
799
+ const items = this.mergedEntries().map((e) => ({ row: e.row, origin: e.origin, presentLocally: e.presentLocally }));
800
+ const errors = { ...this.errors };
801
+ if (this.localError) errors.local = this.localError;
802
+ const result = {
803
+ items,
804
+ boundary: this.boundary,
805
+ ...this.reason !== void 0 ? { boundaryReason: this.reason } : {},
806
+ awaitingSync: this.awaitingSync,
807
+ loading: this.loading,
808
+ errors,
809
+ warnings: this.warnings
810
+ };
811
+ const prev = this.store.getSnapshot();
812
+ if (shallowEqualResult(prev, result)) return;
813
+ this.store.replace(result);
814
+ }
815
+ };
816
+
817
+ // src/ondemand.ts
818
+ var OnDemandStreamStore = class {
819
+ constructor(driver, clock, name, params, opts) {
820
+ this.driver = driver;
821
+ this.clock = clock;
822
+ this.name = name;
823
+ this.params = params;
824
+ this.opts = opts;
825
+ }
826
+ driver;
827
+ clock;
828
+ name;
829
+ params;
830
+ opts;
831
+ store = new SnapshotStore({ status: "subscribing" });
832
+ handle = null;
833
+ abort = new AbortController();
834
+ refs = 0;
835
+ disposed = false;
836
+ retain() {
837
+ this.refs += 1;
838
+ if (this.refs === 1) void this.start();
839
+ }
840
+ release() {
841
+ this.refs -= 1;
842
+ if (this.refs <= 0) this.dispose();
843
+ }
844
+ get isDisposed() {
845
+ return this.disposed;
846
+ }
847
+ swapRoot() {
848
+ this.store.replace({ status: "subscribing" });
849
+ this.disposed = true;
850
+ }
851
+ dispose() {
852
+ this.disposed = true;
853
+ this.abort.abort(new Error("disposed"));
854
+ this.handle?.unsubscribe();
855
+ this.handle = null;
856
+ }
857
+ async start() {
858
+ if (!this.driver.syncStatus().connected) {
859
+ this.store.replace({ status: "offline" });
860
+ }
861
+ try {
862
+ const opts = {};
863
+ if (this.opts.ttl !== void 0) opts.ttl = this.opts.ttl;
864
+ if (this.opts.priority !== void 0) opts.priority = this.opts.priority;
865
+ this.handle = await this.driver.syncStream(this.name, this.params, opts);
866
+ if (this.disposed) {
867
+ this.handle.unsubscribe();
868
+ return;
869
+ }
870
+ if (this.driver.syncStatus().connected) this.store.replace({ status: "syncing" });
871
+ await withTimeout(this.clock, this.handle.waitForFirstSync(this.abort.signal), this.opts.timeoutMs ?? 15e3, this.abort.signal);
872
+ if (!this.disposed) this.store.replace({ status: "synced" });
873
+ } catch (e) {
874
+ if (this.disposed) return;
875
+ if (e instanceof TimeoutError) this.store.replace({ status: this.driver.syncStatus().connected ? "timeout" : "offline" });
876
+ else this.store.replace({ status: "error", error: e instanceof Error ? e : new Error(String(e)) });
877
+ }
878
+ }
879
+ };
880
+
881
+ // src/pending.ts
882
+ function refKey(table, id) {
883
+ return `${table}\0${id}`;
884
+ }
885
+ var PendingWindow = class {
886
+ refs = /* @__PURE__ */ new Map();
887
+ open = true;
888
+ constructor(seed) {
889
+ for (const r of seed) this.refs.set(refKey(r.table, r.id), r);
890
+ }
891
+ observe(delta) {
892
+ if (!this.open) return;
893
+ for (const r of delta.added) this.refs.set(refKey(r.table, r.id), r);
894
+ for (const r of delta.completed) this.refs.set(refKey(r.table, r.id), r);
895
+ }
896
+ get(table, id) {
897
+ return this.refs.get(refKey(table, id));
898
+ }
899
+ close() {
900
+ this.open = false;
901
+ this.refs.clear();
902
+ }
903
+ };
904
+ var CrudMirror = class {
905
+ constructor(driver, freshness, clock) {
906
+ this.driver = driver;
907
+ this.freshness = freshness;
908
+ this.clock = clock;
909
+ }
910
+ driver;
911
+ freshness;
912
+ clock;
913
+ live = /* @__PURE__ */ new Map();
914
+ recentlyAcked = /* @__PURE__ */ new Map();
915
+ windows = /* @__PURE__ */ new Set();
916
+ listeners = /* @__PURE__ */ new Set();
917
+ unsubCrud = null;
918
+ unsubCompletion = null;
919
+ seeded = null;
920
+ start() {
921
+ this.stop();
922
+ this.live.clear();
923
+ this.recentlyAcked.clear();
924
+ for (const w of this.windows) w.close();
925
+ this.windows.clear();
926
+ this.unsubCrud = this.driver.onCrudChange((delta) => this.apply(delta));
927
+ this.unsubCompletion = this.freshness.onCompletion((c) => this.retireAcked(c));
928
+ this.seeded = this.resync();
929
+ }
930
+ stop() {
931
+ this.unsubCrud?.();
932
+ this.unsubCompletion?.();
933
+ this.unsubCrud = null;
934
+ this.unsubCompletion = null;
935
+ }
936
+ /** Resolves once the initial live set has been read. */
937
+ ready() {
938
+ return this.seeded ?? Promise.resolve();
939
+ }
940
+ /** Re-read the queue and apply the difference as a delta (fallback trigger: every local diff emission). */
941
+ async resync() {
942
+ const refs = await this.driver.pendingUploadIds();
943
+ const next = /* @__PURE__ */ new Map();
944
+ for (const r of refs) next.set(refKey(r.table, r.id), r);
945
+ const added = [];
946
+ const completed = [];
947
+ for (const [k, r] of next) if (!this.live.has(k)) added.push(r);
948
+ for (const [k, r] of this.live) if (!next.has(k)) completed.push(r);
949
+ if (added.length || completed.length) this.apply({ added, completed });
950
+ }
951
+ apply(delta) {
952
+ const now = this.clock();
953
+ for (const r of delta.added) this.live.set(refKey(r.table, r.id), r);
954
+ for (const r of delta.completed) {
955
+ const k = refKey(r.table, r.id);
956
+ this.live.delete(k);
957
+ this.recentlyAcked.set(k, { ref: r, ackedAt: now });
958
+ }
959
+ for (const w of this.windows) w.observe(delta);
960
+ for (const l of this.listeners) l();
961
+ }
962
+ /** A ref retires only when a checkpoint whose STAMP ≥ ackTime completes (F5-001). */
963
+ retireAcked(c) {
964
+ if (c.stream !== null) return;
965
+ let changed = false;
966
+ for (const [k, v] of this.recentlyAcked) {
967
+ if (c.stamp >= v.ackedAt) {
968
+ this.recentlyAcked.delete(k);
969
+ changed = true;
970
+ }
971
+ }
972
+ if (changed) for (const l of this.listeners) l();
973
+ }
974
+ openWindow() {
975
+ const w = new PendingWindow(this.live.values());
976
+ this.windows.add(w);
977
+ return w;
978
+ }
979
+ closeWindow(w) {
980
+ w.close();
981
+ this.windows.delete(w);
982
+ }
983
+ /** The three-set union (R1). */
984
+ pendingOp(table, id, window) {
985
+ const k = refKey(table, id);
986
+ return this.live.get(k) ?? window?.get(table, id) ?? this.recentlyAcked.get(k)?.ref;
987
+ }
988
+ isLivePending(table, id) {
989
+ return this.live.has(refKey(table, id));
990
+ }
991
+ onChange(l) {
992
+ this.listeners.add(l);
993
+ return () => this.listeners.delete(l);
994
+ }
995
+ };
996
+
997
+ // src/raced.ts
998
+ var EMPTY_RESULT = Object.freeze({
999
+ items: Object.freeze([]),
1000
+ state: "pending",
1001
+ awaiting: Object.freeze({ local: true, api: true }),
1002
+ errors: Object.freeze({})
1003
+ });
1004
+ function emptyRacedResult() {
1005
+ return EMPTY_RESULT;
1006
+ }
1007
+ var RacedListStore = class {
1008
+ constructor(deps, def, scope, scopeKey) {
1009
+ this.deps = deps;
1010
+ this.def = def;
1011
+ this.scope = scope;
1012
+ this.scopeKey = scopeKey;
1013
+ this.compare = rowComparator(def.orderKey, def.direction ?? "asc");
1014
+ this.table = def.table ?? def.id;
1015
+ this.startedAt = deps.clock.now();
1016
+ }
1017
+ deps;
1018
+ def;
1019
+ scope;
1020
+ scopeKey;
1021
+ store = new SnapshotStore(emptyRacedResult());
1022
+ compare;
1023
+ table;
1024
+ abort = new AbortController();
1025
+ startedAt;
1026
+ snap = null;
1027
+ snapPending = false;
1028
+ snapError;
1029
+ snapWindow = null;
1030
+ local = null;
1031
+ localError;
1032
+ emissionSeen = false;
1033
+ inSession = /* @__PURE__ */ new Set();
1034
+ unsubDiffs = null;
1035
+ unsubCrud = null;
1036
+ unsubFresh = null;
1037
+ prepared = null;
1038
+ owned = false;
1039
+ overlay = /* @__PURE__ */ new Map();
1040
+ tombstones = /* @__PURE__ */ new Set();
1041
+ disposed = false;
1042
+ refs = 0;
1043
+ measured = false;
1044
+ localMs = null;
1045
+ apiMs = null;
1046
+ retain() {
1047
+ this.refs += 1;
1048
+ if (this.refs === 1) this.start();
1049
+ }
1050
+ release() {
1051
+ this.refs -= 1;
1052
+ if (this.refs <= 0) this.dispose();
1053
+ }
1054
+ get isDisposed() {
1055
+ return this.disposed;
1056
+ }
1057
+ /** Synchronous part of an identity swap: the snapshot becomes empty 'pending' at once (R15). */
1058
+ swapRoot() {
1059
+ this.store.replace(emptyRacedResult());
1060
+ this.disposed = true;
1061
+ }
1062
+ dispose() {
1063
+ this.disposed = true;
1064
+ this.abort.abort(new Error("disposed"));
1065
+ this.unsubDiffs?.();
1066
+ this.unsubCrud?.();
1067
+ this.unsubFresh?.();
1068
+ if (this.snapWindow) this.deps.crud.closeWindow(this.snapWindow);
1069
+ this.snapWindow = null;
1070
+ }
1071
+ // ---------------------------------------------------------------------------
1072
+ start() {
1073
+ const { driver, adapter, crud, freshness } = this.deps;
1074
+ const prepared = adapter.prepare(this.def.query(this.scope));
1075
+ this.prepared = prepared;
1076
+ this.unsubDiffs = driver.diffs(prepared).subscribe(
1077
+ (diff) => void this.onDiff(diff),
1078
+ (err) => {
1079
+ this.localError = err;
1080
+ this.reconcile();
1081
+ }
1082
+ );
1083
+ this.unsubCrud = crud.onChange(() => this.reconcile());
1084
+ this.unsubFresh = freshness.onCompletion(() => void this.onCompletion());
1085
+ if (this.deps.apiLegEnabled) this.startApiLeg();
1086
+ this.reconcile();
1087
+ }
1088
+ startApiLeg() {
1089
+ const { snapshots, crud } = this.deps;
1090
+ const staleTime = this.def.staleTime ?? 10 * 6e4;
1091
+ this.snapPending = true;
1092
+ const window = crud.openWindow();
1093
+ this.snapWindow = window;
1094
+ const key = ["powersync-bridge", this.deps.identityKey, this.deps.epoch, this.def.id, this.scopeKey];
1095
+ snapshots.fetch(key, staleTime, (signal) => {
1096
+ const combined = new AbortController();
1097
+ const onAbort = () => combined.abort();
1098
+ signal.addEventListener("abort", onAbort, { once: true });
1099
+ this.abort.signal.addEventListener("abort", onAbort, { once: true });
1100
+ return this.def.fetchSnapshot(this.scope, combined.signal);
1101
+ }).then(
1102
+ (record) => {
1103
+ if (this.disposed) return;
1104
+ this.snapPending = false;
1105
+ this.apiMs = this.deps.clock.now() - this.startedAt;
1106
+ const rows = /* @__PURE__ */ new Map();
1107
+ for (const raw of record.rows) {
1108
+ const item = this.def.mapApi(raw);
1109
+ rows.set(idOf(item), item);
1110
+ }
1111
+ this.snap = { rows, record, window };
1112
+ this.reconcile();
1113
+ },
1114
+ (err) => {
1115
+ if (this.disposed) return;
1116
+ this.snapPending = false;
1117
+ this.snapError = err instanceof Error ? err : new Error(String(err));
1118
+ crud.closeWindow(window);
1119
+ this.snapWindow = null;
1120
+ this.reconcile();
1121
+ }
1122
+ );
1123
+ }
1124
+ /**
1125
+ * A checkpoint completed. Status events and diff emissions are separate SDK events with no
1126
+ * ordering guarantee; taking ownership on the stale local map would render the pre-checkpoint
1127
+ * value for one frame (fresh→old→fresh). Pre-ownership we re-query once, then reconcile.
1128
+ */
1129
+ async onCompletion() {
1130
+ if (this.disposed || this.owned || !this.prepared || !this.emissionSeen) {
1131
+ this.reconcile();
1132
+ return;
1133
+ }
1134
+ try {
1135
+ const rows = await this.deps.driver.query(this.prepared, this.abort.signal);
1136
+ if (this.disposed) return;
1137
+ const prev = this.local ?? /* @__PURE__ */ new Map();
1138
+ const next = /* @__PURE__ */ new Map();
1139
+ for (const row of rows) {
1140
+ const id = idOf(row);
1141
+ next.set(id, row);
1142
+ const before = prev.get(id);
1143
+ if ((before === void 0 || JSON.stringify(before) !== JSON.stringify(row)) && !this.deps.crud.isLivePending(this.table, id)) {
1144
+ this.inSession.add(id);
1145
+ this.tombstones.delete(id);
1146
+ }
1147
+ }
1148
+ this.local = next;
1149
+ } catch {
1150
+ }
1151
+ this.reconcile();
1152
+ }
1153
+ async onDiff(diff) {
1154
+ if (this.disposed) return;
1155
+ const { crud } = this.deps;
1156
+ const prev = this.local;
1157
+ const next = /* @__PURE__ */ new Map();
1158
+ for (const row of diff.all) next.set(idOf(row), row);
1159
+ if (this.emissionSeen) {
1160
+ for (const row of [...diff.added, ...diff.updated]) {
1161
+ const id = idOf(row);
1162
+ if (!crud.isLivePending(this.table, id)) this.inSession.add(id);
1163
+ this.tombstones.delete(id);
1164
+ }
1165
+ }
1166
+ this.local = next;
1167
+ if (!this.emissionSeen) {
1168
+ this.emissionSeen = true;
1169
+ this.localMs = this.deps.clock.now() - this.startedAt;
1170
+ }
1171
+ void crud.resync();
1172
+ if (this.owned && prev) {
1173
+ 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);
1179
+ }
1180
+ this.reconcile();
1181
+ }
1182
+ async applyRemovals(removed) {
1183
+ const classes = await classifyRemovals(
1184
+ {
1185
+ driver: this.deps.driver,
1186
+ clock: this.deps.clock,
1187
+ table: this.table,
1188
+ provenance: () => this.def.stream,
1189
+ isInWindow: this.def.isInWindow
1190
+ },
1191
+ removed
1192
+ );
1193
+ if (this.disposed) return;
1194
+ for (const [id, cls] of classes) {
1195
+ const entry = this.overlay.get(id);
1196
+ switch (cls) {
1197
+ case "filtered":
1198
+ this.overlay.delete(id);
1199
+ break;
1200
+ case "delete":
1201
+ this.overlay.delete(id);
1202
+ this.tombstones.add(id);
1203
+ break;
1204
+ case "eviction":
1205
+ case "retain":
1206
+ if (entry) this.overlay.set(id, { ...entry, presentLocally: false });
1207
+ break;
1208
+ }
1209
+ }
1210
+ }
1211
+ // ---------------------------------------------------------------------------
1212
+ reconcile() {
1213
+ if (this.disposed) return;
1214
+ const result = this.owned ? this.renderOwned() : this.renderRace();
1215
+ const prev = this.store.getSnapshot();
1216
+ if (shallowEqualResult(prev, result)) return;
1217
+ this.store.replace(result);
1218
+ this.report(result);
1219
+ }
1220
+ freshAt() {
1221
+ return this.deps.freshness.freshAtFor(this.def.stream);
1222
+ }
1223
+ localFresh() {
1224
+ return this.emissionSeen && this.deps.freshness.isFresh(this.def.stream);
1225
+ }
1226
+ tryTakeOwnership() {
1227
+ if (this.owned) return true;
1228
+ const apiFreshAt = this.snap?.record.fetchedAt ?? Number.NEGATIVE_INFINITY;
1229
+ if (!(this.localFresh() && this.freshAt() > apiFreshAt)) return false;
1230
+ this.owned = true;
1231
+ const local = this.local ?? /* @__PURE__ */ new Map();
1232
+ const overlay = /* @__PURE__ */ new Map();
1233
+ for (const [id, row] of local) overlay.set(id, { row, origin: "local", presentLocally: true });
1234
+ if (this.snap) {
1235
+ if (this.def.apiScopeSupersetOfLocal) {
1236
+ 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 });
1238
+ }
1239
+ }
1240
+ this.deps.crud.closeWindow(this.snap.window);
1241
+ this.snapWindow = null;
1242
+ this.snap = null;
1243
+ }
1244
+ this.overlay = overlay;
1245
+ return true;
1246
+ }
1247
+ renderOwned() {
1248
+ const local = this.local ?? /* @__PURE__ */ new Map();
1249
+ for (const [id, row] of local) {
1250
+ const e = this.overlay.get(id);
1251
+ if (e) {
1252
+ e.row = row;
1253
+ e.origin = "local";
1254
+ e.presentLocally = true;
1255
+ } else if (!this.tombstones.has(id)) {
1256
+ this.overlay.set(id, { row, origin: "local", presentLocally: true });
1257
+ }
1258
+ }
1259
+ const items = this.finalize(Array.from(this.overlay.values()));
1260
+ return this.build(items, "converged", { local: false, api: false });
1261
+ }
1262
+ renderRace() {
1263
+ if (this.tryTakeOwnership()) return this.renderOwned();
1264
+ const { crud } = this.deps;
1265
+ const S = this.snap;
1266
+ const local = this.local;
1267
+ const apiFreshAt = S?.record.fetchedAt ?? Number.NEGATIVE_INFINITY;
1268
+ const localFreshAt = this.freshAt();
1269
+ const apiFresher = apiFreshAt > localFreshAt;
1270
+ if (!S && !local) {
1271
+ return this.build([], "pending", { local: !this.emissionSeen, api: this.snapPending });
1272
+ }
1273
+ if (!S && local && this.def.holdPending === "pending" && this.snapPending) {
1274
+ return this.build([], "pending", { local: false, api: true });
1275
+ }
1276
+ const entries = [];
1277
+ const stale = !S;
1278
+ const ids = /* @__PURE__ */ new Set([...S?.rows.keys() ?? [], ...local?.keys() ?? []]);
1279
+ for (const id of ids) {
1280
+ const inLocal = local?.get(id);
1281
+ const inS = S?.rows.get(id);
1282
+ const pending = crud.pendingOp(this.table, id, S?.window);
1283
+ if (pending) {
1284
+ if (inLocal !== void 0) entries.push({ row: inLocal, origin: "local", presentLocally: true });
1285
+ continue;
1286
+ }
1287
+ if (inLocal !== void 0 && inS !== void 0) {
1288
+ entries.push(apiFresher ? { row: inS, origin: "api", presentLocally: true } : { row: inLocal, origin: "local", presentLocally: true });
1289
+ continue;
1290
+ }
1291
+ if (inLocal !== void 0) {
1292
+ if (!S) {
1293
+ entries.push({ row: inLocal, origin: "local", presentLocally: true });
1294
+ } else if (this.inSession.has(id)) {
1295
+ entries.push({ row: inLocal, origin: "local", presentLocally: true });
1296
+ } else if (apiFresher) ; else {
1297
+ entries.push({ row: inLocal, origin: "local", presentLocally: true });
1298
+ }
1299
+ continue;
1300
+ }
1301
+ if (inS !== void 0 && !this.tombstones.has(id)) {
1302
+ entries.push({ row: inS, origin: "api", presentLocally: false });
1303
+ }
1304
+ }
1305
+ const items = this.finalize(entries, stale);
1306
+ const state = !S ? "local" : !local ? "api" : apiFresher ? "api" : "local";
1307
+ return this.build(items, state, { local: !this.emissionSeen, api: this.snapPending });
1308
+ }
1309
+ finalize(entries, stale = false) {
1310
+ entries.sort((a, b) => this.compare(a.row, b.row));
1311
+ 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 });
1313
+ }
1314
+ build(items, state, awaiting) {
1315
+ const errors = {};
1316
+ if (this.localError) errors.local = this.localError;
1317
+ if (this.snapError) errors.api = this.snapError;
1318
+ return { items, state, awaiting, errors };
1319
+ }
1320
+ report(result) {
1321
+ const sink = this.deps.measure;
1322
+ if (!sink) return;
1323
+ if (this.measured && result.state !== "converged") return;
1324
+ if (result.state === "pending") return;
1325
+ this.measured = true;
1326
+ const connectAt = this.deps.freshness.sessionConnectAtMs;
1327
+ const freshAt = this.deps.freshness.freshAtFor(this.def.stream);
1328
+ const m = {
1329
+ list: this.def.id,
1330
+ winner: this.localMs !== null && (this.apiMs === null || this.localMs <= this.apiMs) ? "local" : this.apiMs !== null ? "api" : "none",
1331
+ localMs: this.localMs,
1332
+ apiMs: this.apiMs,
1333
+ sessionFreshnessMs: connectAt !== null && Number.isFinite(freshAt) && freshAt >= connectAt ? this.deps.clock.now() - connectAt : null,
1334
+ rowsRendered: result.items.length,
1335
+ source: result.state
1336
+ };
1337
+ sink(m);
1338
+ }
1339
+ /** The cursor of the last rendered row — used by raced × infinite composition (R13). */
1340
+ lastCursor() {
1341
+ const items = this.store.getSnapshot().items;
1342
+ const last = items[items.length - 1];
1343
+ return last ? cursorOfRow(last.row, this.def.orderKey) : void 0;
1344
+ }
1345
+ };
1346
+
1347
+ // src/snapshots.ts
1348
+ var SnapshotCache = class {
1349
+ constructor(clock, queryClient) {
1350
+ this.clock = clock;
1351
+ this.queryClient = queryClient;
1352
+ }
1353
+ clock;
1354
+ queryClient;
1355
+ entries = /* @__PURE__ */ new Map();
1356
+ fetch(key, staleTime, fetcher) {
1357
+ const k = JSON.stringify(key);
1358
+ const now = this.clock.now();
1359
+ const existing = this.entries.get(k);
1360
+ if (existing && (existing.result === null || now - existing.result.fetchedAt < staleTime)) {
1361
+ return existing.promise;
1362
+ }
1363
+ const controller = new AbortController();
1364
+ const run = async (signal) => {
1365
+ const res = await fetcher(signal);
1366
+ return { rows: res.rows, asOf: res.asOf, requestedAt: now, fetchedAt: this.clock.now() };
1367
+ };
1368
+ const promise = this.queryClient ? this.queryClient.fetchQuery({ queryKey: key, queryFn: ({ signal }) => run(signal), staleTime }) : run(controller.signal);
1369
+ const entry = { requestedAt: now, promise, result: null, controller };
1370
+ this.entries.set(k, entry);
1371
+ promise.then(
1372
+ (r) => {
1373
+ entry.result = r;
1374
+ },
1375
+ () => {
1376
+ if (this.entries.get(k) === entry) this.entries.delete(k);
1377
+ }
1378
+ );
1379
+ return promise;
1380
+ }
1381
+ /** Cancel every in-flight fetch under a key prefix (identity swap). */
1382
+ abortAll(prefix) {
1383
+ const p = JSON.stringify(prefix).slice(0, -1);
1384
+ for (const [k, e] of this.entries) {
1385
+ if (k.startsWith(p)) {
1386
+ e.controller.abort(new Error("identity changed"));
1387
+ this.entries.delete(k);
1388
+ }
1389
+ }
1390
+ }
1391
+ clear() {
1392
+ for (const e of this.entries.values()) e.controller.abort(new Error("cleared"));
1393
+ this.entries.clear();
1394
+ }
1395
+ };
1396
+
1397
+ // src/bridge.ts
1398
+ function detectPlatform() {
1399
+ const nav = globalThis.navigator;
1400
+ return nav?.product === "ReactNative" ? "native" : "web";
1401
+ }
1402
+ function scopeKeyOf(scope) {
1403
+ return JSON.stringify(scope ?? null);
1404
+ }
1405
+ var Bridge = class {
1406
+ driver;
1407
+ adapter;
1408
+ platform;
1409
+ clock;
1410
+ /** Increments on every identity swap; hooks subscribe to re-acquire their stores. */
1411
+ epochStore;
1412
+ identity = new IdentityTracker();
1413
+ freshness;
1414
+ crud;
1415
+ snapshots;
1416
+ epochSnapshot;
1417
+ stores = /* @__PURE__ */ new Map();
1418
+ gates;
1419
+ measure;
1420
+ identityOpts;
1421
+ onEvicted;
1422
+ disposed = false;
1423
+ constructor(opts) {
1424
+ this.driver = opts.driver;
1425
+ this.adapter = opts.adapter;
1426
+ this.clock = opts.clock ?? realClock;
1427
+ this.platform = opts.platform ?? detectPlatform();
1428
+ this.gates = { native: opts.platformGates?.native ?? "local-first", web: opts.platformGates?.web ?? "race" };
1429
+ this.measure = opts.measure?.sink;
1430
+ this.identityOpts = opts.identity ?? {};
1431
+ this.onEvicted = opts.onEvicted;
1432
+ this.epochSnapshot = new SnapshotStore(0);
1433
+ this.epochStore = this.epochSnapshot;
1434
+ this.freshness = new FreshnessTracker(this.driver, () => this.clock.now());
1435
+ this.crud = new CrudMirror(this.driver, this.freshness, () => this.clock.now());
1436
+ this.snapshots = new SnapshotCache(this.clock, opts.queryClient);
1437
+ this.freshness.start(this.clock.now());
1438
+ this.crud.start();
1439
+ this.freshness.onClear(() => this.identity.signalClear());
1440
+ this.identity.onChange(() => this.swapEpoch());
1441
+ }
1442
+ get epoch() {
1443
+ return this.identity.epoch;
1444
+ }
1445
+ get identityKey() {
1446
+ return this.identity.currentKey;
1447
+ }
1448
+ /** Resolves once the initial upload-queue read has landed (tests). */
1449
+ ready() {
1450
+ return this.crud.ready();
1451
+ }
1452
+ // ---------------------------------------------------------------------------
1453
+ // Identity (R15, [DX])
1454
+ // ---------------------------------------------------------------------------
1455
+ /** Connect THROUGH the bridge so identity is derived from every token the connector produces. */
1456
+ wrapConnector(connector) {
1457
+ const wrapped = Object.create(connector);
1458
+ Object.defineProperty(wrapped, "fetchCredentials", {
1459
+ value: async () => {
1460
+ const creds = await connector.fetchCredentials();
1461
+ if (creds?.token) this.identity.observe(identityKeyFromToken(creds.token, this.identityOpts));
1462
+ return creds;
1463
+ },
1464
+ configurable: true,
1465
+ writable: true
1466
+ });
1467
+ Object.defineProperty(wrapped, "uploadData", {
1468
+ value: (db) => connector.uploadData(db),
1469
+ configurable: true,
1470
+ writable: true
1471
+ });
1472
+ return wrapped;
1473
+ }
1474
+ /** Escape hatch for scope that is not in the token. */
1475
+ setIdentity(scope) {
1476
+ this.identity.observe(scope === null ? null : identityKeyFromClaims(scope, this.identityOpts));
1477
+ }
1478
+ /**
1479
+ * Identity swap (R15): bump the epoch AND swap every store's root to empty 'pending' in ONE
1480
+ * synchronous operation, then tear the rest down asynchronously.
1481
+ */
1482
+ swapEpoch() {
1483
+ const outgoing = Array.from(this.stores.values());
1484
+ this.stores.clear();
1485
+ for (const s of outgoing) s.swapRoot();
1486
+ this.epochSnapshot.replace(this.identity.epoch);
1487
+ queueMicrotask(() => {
1488
+ this.snapshots.clear();
1489
+ for (const s of outgoing) s.dispose();
1490
+ this.freshness.start(this.clock.now());
1491
+ this.crud.start();
1492
+ });
1493
+ }
1494
+ // ---------------------------------------------------------------------------
1495
+ // Store acquisition (ref-counted; shared across components with the same key)
1496
+ // ---------------------------------------------------------------------------
1497
+ acquire(key, make) {
1498
+ const existing = this.stores.get(key);
1499
+ if (existing && !existing.isDisposed) return existing;
1500
+ const s = make();
1501
+ this.stores.set(key, s);
1502
+ return s;
1503
+ }
1504
+ apiLegEnabled() {
1505
+ return this.gates[this.platform] === "race";
1506
+ }
1507
+ raced(def, scope) {
1508
+ const scopeKey = scopeKeyOf(scope);
1509
+ const key = `raced:${this.epoch}:${def.id}:${scopeKey}`;
1510
+ return this.acquire(
1511
+ key,
1512
+ () => new RacedListStore(
1513
+ {
1514
+ driver: this.driver,
1515
+ adapter: this.adapter,
1516
+ crud: this.crud,
1517
+ freshness: this.freshness,
1518
+ snapshots: this.snapshots,
1519
+ clock: this.clock,
1520
+ epoch: this.epoch,
1521
+ identityKey: this.identityKey,
1522
+ apiLegEnabled: this.apiLegEnabled(),
1523
+ measure: this.measure
1524
+ },
1525
+ def,
1526
+ scope,
1527
+ scopeKey
1528
+ )
1529
+ );
1530
+ }
1531
+ infinite(def, scope) {
1532
+ const key = `infinite:${this.epoch}:${def.id}:${scopeKeyOf(scope)}`;
1533
+ return this.acquire(
1534
+ key,
1535
+ () => new InfiniteListStore(
1536
+ {
1537
+ driver: this.driver,
1538
+ adapter: this.adapter,
1539
+ clock: this.clock,
1540
+ onEvicted: this.onEvicted ? (ids) => this.onEvicted?.(def.id, ids) : void 0
1541
+ },
1542
+ def,
1543
+ scope
1544
+ )
1545
+ );
1546
+ }
1547
+ onDemand(name, params, opts = {}) {
1548
+ const key = `ondemand:${this.epoch}:${name}:${JSON.stringify(params)}`;
1549
+ return this.acquire(key, () => new OnDemandStreamStore(this.driver, this.clock, name, params, opts));
1550
+ }
1551
+ autoSync(config, ids) {
1552
+ const key = `flag:${this.epoch}:${config.stream}:${JSON.stringify([...ids].sort())}`;
1553
+ return this.acquire(key, () => new AutoSyncFlagStore(this.driver, this.clock, config, ids));
1554
+ }
1555
+ dispose() {
1556
+ if (this.disposed) return;
1557
+ this.disposed = true;
1558
+ for (const s of this.stores.values()) s.dispose();
1559
+ this.stores.clear();
1560
+ this.snapshots.clear();
1561
+ this.freshness.stop();
1562
+ this.crud.stop();
1563
+ }
1564
+ };
1565
+ function createBridge(opts) {
1566
+ return new Bridge(opts);
1567
+ }
1568
+
1569
+ // src/define.ts
1570
+ function defineRacedList(input) {
1571
+ return { kind: "raced", ...input };
1572
+ }
1573
+ function defineInfiniteList(input) {
1574
+ return { kind: "infinite", ...input };
1575
+ }
1576
+
1577
+ exports.AutoSyncFlagStore = AutoSyncFlagStore;
1578
+ exports.Bridge = Bridge;
1579
+ exports.DEFAULT_VOLATILE_CLAIMS = DEFAULT_VOLATILE_CLAIMS;
1580
+ exports.InfiniteListStore = InfiniteListStore;
1581
+ exports.OnDemandStreamStore = OnDemandStreamStore;
1582
+ exports.RacedListStore = RacedListStore;
1583
+ exports.SnapshotStore = SnapshotStore;
1584
+ exports.classifyRemovals = classifyRemovals;
1585
+ exports.createBridge = createBridge;
1586
+ exports.cursorOfRow = cursorOfRow;
1587
+ exports.decodeCursor = decodeCursor;
1588
+ exports.decodeJwtPayload = decodeJwtPayload;
1589
+ exports.defineInfiniteList = defineInfiniteList;
1590
+ exports.defineRacedList = defineRacedList;
1591
+ exports.emptyInfiniteResult = emptyInfiniteResult;
1592
+ exports.emptyRacedResult = emptyRacedResult;
1593
+ exports.encodeCursor = encodeCursor;
1594
+ exports.identityKeyFromClaims = identityKeyFromClaims;
1595
+ exports.identityKeyFromToken = identityKeyFromToken;
1596
+ exports.keyColumns = keyColumns;
1597
+ exports.probePresence = probePresence;
1598
+ exports.realClock = realClock;
1599
+ exports.rowComparator = rowComparator;
1600
+ //# sourceMappingURL=index.cjs.map
1601
+ //# sourceMappingURL=index.cjs.map