@rebasepro/client 0.10.0 → 0.10.1-canary.0a881d4

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.es.js CHANGED
@@ -1,4 +1,4 @@
1
- import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, Vector, isPublicStoragePath } from "@rebasepro/types";
1
+ import { DEFAULT_STORAGE_SOURCE_KEY, EntityReference, EntityRelation, GeoPoint, PUBLIC_STORAGE_PREFIX, RebaseApiError, RebaseApiError as RebaseApiError$1, RebaseClientError, Vector, isPublicStoragePath, toCanonicalOp } from "@rebasepro/types";
2
2
  import { COMPOSITE_ID_SEPARATOR, QueryBuilder, and, buildCompositeId, cond, or, serializeFilter, serializeLogicalCondition, serializeOrderBy } from "@rebasepro/common";
3
3
  import { toSnakeCase } from "@rebasepro/utils";
4
4
  //#region src/reviver.ts
@@ -170,6 +170,9 @@ function createTransport(config) {
170
170
  get apiPath() {
171
171
  return apiPath;
172
172
  },
173
+ get storageUrlOrigin() {
174
+ return config.storageUrlOrigin?.replace(/\/$/, "") || void 0;
175
+ },
173
176
  get fetchFn() {
174
177
  return fetchFn;
175
178
  },
@@ -295,19 +298,67 @@ function createAuth(transport, options) {
295
298
  */
296
299
  function isFatalRefreshError(err) {
297
300
  if (!(err instanceof RebaseApiError)) return false;
301
+ if (err.code === "TOKEN_ALREADY_USED") return false;
298
302
  if (err.code === "INVALID_TOKEN" || err.code === "TOKEN_EXPIRED") return true;
299
303
  return err.status === 401 || err.status === 403;
300
304
  }
305
+ /**
306
+ * Drop this client's session without telling the server.
307
+ *
308
+ * `signOut()` is a user action: it POSTs /logout, which revokes the whole
309
+ * sign-in. That is the wrong hammer for a refresh that failed. Our token
310
+ * may be stale precisely because a sibling tab holds a live one, and
311
+ * logging out on its behalf would turn one tab's bad luck into everybody
312
+ * being signed out — the exact failure this work exists to remove.
313
+ */
314
+ function abandonSessionLocally() {
315
+ currentSession = null;
316
+ clearStoredSession();
317
+ if (refreshTimeout) {
318
+ clearTimeout(refreshTimeout);
319
+ refreshTimeout = null;
320
+ }
321
+ transport.setToken(null);
322
+ emit("SIGNED_OUT", null);
323
+ }
324
+ /**
325
+ * Recover from a 401 on an ordinary API request.
326
+ *
327
+ * Returns `true` when the caller should retry — we minted a fresh access
328
+ * token. When the refresh is rejected *fatally* (the refresh token itself
329
+ * is invalid, expired or revoked) this client can no longer act as the
330
+ * user at all, so we drop the session and emit `SIGNED_OUT`. UIs gate on
331
+ * that event, so they show their login screen instead of leaving the user
332
+ * staring at "Invalid or expired token" on every view.
333
+ *
334
+ * Transient failures (offline, 5xx, backend restarting) keep the session:
335
+ * the scheduled refresh backs off and retries, and the token is very
336
+ * likely still good once the backend answers again.
337
+ */
338
+ async function handleUnauthorized() {
339
+ if (!currentSession) return false;
340
+ if (authFlowMode !== "cookie" && !currentSession.refreshToken) {
341
+ abandonSessionLocally();
342
+ return false;
343
+ }
344
+ try {
345
+ await refreshSession();
346
+ return true;
347
+ } catch (err) {
348
+ if (isFatalRefreshError(err)) abandonSessionLocally();
349
+ return false;
350
+ }
351
+ }
301
352
  async function attemptScheduledRefresh(attempt) {
302
353
  try {
303
354
  await refreshSession();
304
355
  } catch (err) {
305
356
  if (isFatalRefreshError(err)) {
306
- signOut();
357
+ abandonSessionLocally();
307
358
  return;
308
359
  }
309
360
  if (attempt >= MAX_REFRESH_RETRIES) {
310
- signOut();
361
+ abandonSessionLocally();
311
362
  return;
312
363
  }
313
364
  const backoff = Math.min(REFRESH_RETRY_BASE_MS * 2 ** attempt, REFRESH_RETRY_MAX_MS);
@@ -528,9 +579,42 @@ function createAuth(transport, options) {
528
579
  transport.setToken(null);
529
580
  emit("SIGNED_OUT", null);
530
581
  }
582
+ /**
583
+ * Serialise refreshes across TABS, not just within one.
584
+ *
585
+ * The in-flight promise below covers callers inside a single JavaScript
586
+ * context. It does nothing about the far more common case: two tabs of the
587
+ * same app booting together, each firing its own /refresh with the same
588
+ * cookie. The server tolerates that now (superseded tokens stay usable for
589
+ * a grace window), but tolerating a stampede is not the same as avoiding
590
+ * one, and every extra rotation is another chance to end up holding a
591
+ * token whose response never arrived.
592
+ *
593
+ * Web Locks are best-effort on purpose. supabase-js shipped this and then
594
+ * spent a year fielding deadlock reports — a lock held by a crashed or
595
+ * frozen tab must never be able to wedge sign-in — so a lock we cannot
596
+ * take within the timeout is simply not taken, and the refresh proceeds
597
+ * unserialised, exactly as it did before.
598
+ */
599
+ const REFRESH_LOCK_NAME = "rebase-auth-refresh";
600
+ const REFRESH_LOCK_TIMEOUT_MS = 5e3;
601
+ async function withRefreshLock(fn) {
602
+ const locks = globalThis.navigator?.locks;
603
+ if (!locks?.request) return fn();
604
+ const controller = new AbortController();
605
+ const giveUp = setTimeout(() => controller.abort(), REFRESH_LOCK_TIMEOUT_MS);
606
+ try {
607
+ return await locks.request(REFRESH_LOCK_NAME, { signal: controller.signal }, async () => fn());
608
+ } catch (e) {
609
+ if (e?.name !== "AbortError") throw e;
610
+ return fn();
611
+ } finally {
612
+ clearTimeout(giveUp);
613
+ }
614
+ }
531
615
  function refreshSession() {
532
616
  if (inFlightRefresh) return inFlightRefresh;
533
- inFlightRefresh = doRefreshSession().finally(() => {
617
+ inFlightRefresh = withRefreshLock(() => doRefreshSession()).finally(() => {
534
618
  inFlightRefresh = null;
535
619
  });
536
620
  return inFlightRefresh;
@@ -766,6 +850,7 @@ function createAuth(transport, options) {
766
850
  signInWithSpotify,
767
851
  signOut,
768
852
  refreshSession,
853
+ handleUnauthorized,
769
854
  getUser,
770
855
  findUserByEmail,
771
856
  updateUser,
@@ -1145,6 +1230,44 @@ function createCollectionClient(transport, slug, ws) {
1145
1230
  });
1146
1231
  return (await transport.request(basePath + "/count" + qs, { method: "GET" })).count ?? 0;
1147
1232
  },
1233
+ observe(params, onResult, onError, options) {
1234
+ let closed = false;
1235
+ const emit = (result) => {
1236
+ if (closed) return;
1237
+ onResult({
1238
+ ...result,
1239
+ fromCache: false,
1240
+ hasPendingWrites: false,
1241
+ partial: false
1242
+ });
1243
+ };
1244
+ client.find(params).then(emit).catch((error) => {
1245
+ if (!closed) onError?.(error);
1246
+ });
1247
+ const live = options?.realtime !== false && client.listen ? client.listen(params, emit, onError) : void 0;
1248
+ return () => {
1249
+ closed = true;
1250
+ live?.();
1251
+ };
1252
+ },
1253
+ observeById(id, onResult, onError, options) {
1254
+ let closed = false;
1255
+ const emit = (row) => {
1256
+ if (closed) return;
1257
+ onResult(row, {
1258
+ fromCache: false,
1259
+ hasPendingWrites: false
1260
+ });
1261
+ };
1262
+ client.findById(id).then(emit).catch((error) => {
1263
+ if (!closed) onError?.(error);
1264
+ });
1265
+ const live = options?.realtime !== false && client.listenById ? client.listenById(id, emit, onError) : void 0;
1266
+ return () => {
1267
+ closed = true;
1268
+ live?.();
1269
+ };
1270
+ },
1148
1271
  where(columnOrCondition, operator, value) {
1149
1272
  const builder = new SDKQueryBuilder(client);
1150
1273
  if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
@@ -1269,6 +1392,12 @@ function createFunctionsClient(transport) {
1269
1392
  */
1270
1393
  function createStorage(transport, storageId) {
1271
1394
  const urlsCache = /* @__PURE__ */ new Map();
1395
+ /**
1396
+ * Base for URLs the *browser* will fetch on its own (file downloads,
1397
+ * previews). API requests keep going to `baseUrl`; see
1398
+ * {@link RebaseClientConfig.storageUrlOrigin} for why these can differ.
1399
+ */
1400
+ const fileUrlBase = () => `${transport.storageUrlOrigin ?? transport.baseUrl}${transport.apiPath}`;
1272
1401
  /** Append ?storageId=... to a path when multi-backend routing is active. */
1273
1402
  const withStorageId = (path) => {
1274
1403
  if (!storageId) return path;
@@ -1306,7 +1435,7 @@ function createStorage(transport, storageId) {
1306
1435
  fileNotFound: true
1307
1436
  };
1308
1437
  if (isPublicStoragePath(filePath)) {
1309
- const publicConfig = { url: withStorageId(`${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}`) };
1438
+ const publicConfig = { url: withStorageId(`${fileUrlBase()}/storage/file/${filePath}`) };
1310
1439
  urlsCache.set(cacheKey, { config: publicConfig });
1311
1440
  return publicConfig;
1312
1441
  }
@@ -1314,7 +1443,7 @@ function createStorage(transport, storageId) {
1314
1443
  const result = await transport.request(withStorageId(`/storage/metadata/${filePath}`));
1315
1444
  if (result.data.public) {
1316
1445
  const publicConfig = {
1317
- url: withStorageId(`${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}`),
1446
+ url: withStorageId(`${fileUrlBase()}/storage/file/${filePath}`),
1318
1447
  metadata: result.data
1319
1448
  };
1320
1449
  urlsCache.set(cacheKey, { config: publicConfig });
@@ -1323,7 +1452,7 @@ function createStorage(transport, storageId) {
1323
1452
  const scopedToken = result.data.token;
1324
1453
  const tokenQuery = scopedToken ? `?token=${scopedToken}` : "";
1325
1454
  const downloadConfig = {
1326
- url: withStorageId(`${transport.baseUrl}${transport.apiPath}/storage/file/${filePath}${tokenQuery}`),
1455
+ url: withStorageId(`${fileUrlBase()}/storage/file/${filePath}${tokenQuery}`),
1327
1456
  metadata: result.data
1328
1457
  };
1329
1458
  const expiresAt = result.data.tokenExpiresIn ? Date.now() + (result.data.tokenExpiresIn - 10) * 1e3 : void 0;
@@ -2955,6 +3084,1929 @@ var RebaseRealtimeChannel = class {
2955
3084
  }
2956
3085
  };
2957
3086
  //#endregion
3087
+ //#region src/offline-codec.ts
3088
+ /**
3089
+ * Lossless round-tripping of rows through the offline store.
3090
+ *
3091
+ * Both persistence backends move values by structured clone, which keeps
3092
+ * `Date` but flattens every class instance to a plain object. For
3093
+ * `EntityReference`/`EntityRelation` that is harmless — they carry their own
3094
+ * `__type` discriminator, so the JSON reviver can rebuild them — but
3095
+ * `GeoPoint` and `Vector` do not, and would come back out of the cache as
3096
+ * anonymous `{ latitude, longitude }` / `{ value }` bags. A row read from the
3097
+ * cache must be indistinguishable from the same row read from the network, so
3098
+ * those two are tagged on the way in and revived on the way out.
3099
+ *
3100
+ * Type tests here are structural rather than `instanceof`, because a structured
3101
+ * clone can arrive from another realm — an iframe, a worker, or the polyfill
3102
+ * the tests run against — where the constructor identity differs but the value
3103
+ * is the real thing. Only *plain* objects are walked; anything else is passed
3104
+ * through whole, so a class instance is never quietly reduced to `{}`.
3105
+ */
3106
+ function isDate(value) {
3107
+ return Object.prototype.toString.call(value) === "[object Date]";
3108
+ }
3109
+ /** An object literal — not a Date, RegExp, Map, or any class instance. */
3110
+ function isPlainObject(value) {
3111
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
3112
+ const proto = Object.getPrototypeOf(value);
3113
+ if (proto === null || proto === Object.prototype) return true;
3114
+ return proto.constructor?.name === "Object";
3115
+ }
3116
+ function dehydrateValue(value) {
3117
+ if (value === null || value === void 0) return value;
3118
+ if (value instanceof GeoPoint) return {
3119
+ __type: "GeoPoint",
3120
+ latitude: value.latitude,
3121
+ longitude: value.longitude
3122
+ };
3123
+ if (value instanceof Vector) return {
3124
+ __type: "Vector",
3125
+ value: [...value.value]
3126
+ };
3127
+ if (value instanceof EntityReference || value instanceof EntityRelation) return value;
3128
+ if (Array.isArray(value)) return value.map(dehydrateValue);
3129
+ if (isPlainObject(value)) {
3130
+ const out = {};
3131
+ for (const [key, inner] of Object.entries(value)) out[key] = dehydrateValue(inner);
3132
+ return out;
3133
+ }
3134
+ return value;
3135
+ }
3136
+ function hydrateValue(value) {
3137
+ if (value === null || value === void 0 || isDate(value)) return value;
3138
+ if (Array.isArray(value)) return value.map(hydrateValue);
3139
+ if (typeof value === "object") {
3140
+ const revived = rebaseReviver("", value);
3141
+ if (revived !== value) return revived;
3142
+ if (!isPlainObject(value)) return value;
3143
+ const out = {};
3144
+ for (const [key, inner] of Object.entries(value)) out[key] = hydrateValue(inner);
3145
+ return out;
3146
+ }
3147
+ return value;
3148
+ }
3149
+ /** Prepare a row for the store. */
3150
+ function dehydrateRow(row) {
3151
+ return dehydrateValue(row);
3152
+ }
3153
+ /** Restore a row read back from the store. */
3154
+ function hydrateRow(row) {
3155
+ return hydrateValue(row);
3156
+ }
3157
+ //#endregion
3158
+ //#region src/offline-connectivity.ts
3159
+ /**
3160
+ * Whether the network is worth trying, and when to try again after it wasn't.
3161
+ *
3162
+ * `navigator.onLine` is necessary but not sufficient: it reports the state of
3163
+ * the network interface, so it stays `true` behind a captive portal, on a
3164
+ * connection that resolves DNS but reaches nothing, and while the API itself
3165
+ * is down. This tracks what actually happened to requests as well, so the
3166
+ * first failure is the only one an app pays for — everything after it inside
3167
+ * the backoff window skips the doomed round trip and answers from the local
3168
+ * store immediately, which is the difference between an app that freezes when
3169
+ * the wifi drops and one that does not.
3170
+ */
3171
+ /** The request never reached the server, so nothing was decided by it. */
3172
+ function isNetworkError(error) {
3173
+ if (error instanceof RebaseApiError) return error.status === 0;
3174
+ if (error instanceof TypeError) return true;
3175
+ const name = error?.name;
3176
+ return name === "AbortError" || name === "TimeoutError" || name === "NetworkError";
3177
+ }
3178
+ /**
3179
+ * Statuses that mean "not now" rather than "not ever": a queued write that
3180
+ * gets one of these is worth replaying, while a 400 or a 403 never will be.
3181
+ * 500 is deliberately absent — an unhandled server error is far more often a
3182
+ * bug the same payload will hit again than a blip, and retrying it forever
3183
+ * jams every write behind it.
3184
+ */
3185
+ var RETRYABLE_STATUSES = new Set([
3186
+ 408,
3187
+ 425,
3188
+ 429,
3189
+ 502,
3190
+ 503,
3191
+ 504
3192
+ ]);
3193
+ /** Is this failure worth another attempt later? */
3194
+ function isRetryableError(error) {
3195
+ if (isNetworkError(error)) return true;
3196
+ if (error instanceof RebaseApiError) return error.status !== void 0 && RETRYABLE_STATUSES.has(error.status);
3197
+ return false;
3198
+ }
3199
+ var ConnectivityMonitor = class {
3200
+ state = "online";
3201
+ backoffMs;
3202
+ initialBackoffMs;
3203
+ maxBackoffMs;
3204
+ retryAt = 0;
3205
+ timer;
3206
+ listeners = /* @__PURE__ */ new Set();
3207
+ respectBackoff;
3208
+ now;
3209
+ setTimer;
3210
+ clearTimer;
3211
+ /** Called when the backoff window expires, to drive an automatic retry. */
3212
+ onRetryDue;
3213
+ handleOnline = () => {
3214
+ this.retryAt = 0;
3215
+ this.backoffMs = this.initialBackoffMs;
3216
+ this.clearPendingTimer();
3217
+ this.setState("online");
3218
+ this.onRetryDue?.();
3219
+ };
3220
+ handleOffline = () => {
3221
+ this.setState("offline");
3222
+ };
3223
+ constructor(options = {}) {
3224
+ this.initialBackoffMs = options.initialBackoffMs ?? 1e3;
3225
+ this.maxBackoffMs = Math.max(this.initialBackoffMs, options.maxBackoffMs ?? 6e4);
3226
+ this.backoffMs = this.initialBackoffMs;
3227
+ this.respectBackoff = options.respectBackoff ?? true;
3228
+ this.now = options.now ?? (() => Date.now());
3229
+ this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
3230
+ this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
3231
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
3232
+ window.addEventListener("online", this.handleOnline);
3233
+ window.addEventListener("offline", this.handleOffline);
3234
+ }
3235
+ if (typeof navigator !== "undefined" && navigator.onLine === false) this.state = "offline";
3236
+ }
3237
+ /** What the app should be told: are we connected? */
3238
+ isOnline() {
3239
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return false;
3240
+ return this.state === "online";
3241
+ }
3242
+ /**
3243
+ * Should this request even be sent? False means "answer from the local
3244
+ * store instead" — the request would only burn a timeout to reach the same
3245
+ * conclusion the last one already did.
3246
+ */
3247
+ shouldAttempt() {
3248
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return false;
3249
+ if (this.state === "online" || !this.respectBackoff) return true;
3250
+ return this.now() >= this.retryAt;
3251
+ }
3252
+ /** A request reached the server. */
3253
+ markSuccess() {
3254
+ this.backoffMs = this.initialBackoffMs;
3255
+ this.retryAt = 0;
3256
+ this.clearPendingTimer();
3257
+ this.setState("online");
3258
+ }
3259
+ /** A request did not reach the server: we are offline until proven otherwise. */
3260
+ markFailure() {
3261
+ this.deferRetry();
3262
+ this.setState("offline");
3263
+ }
3264
+ /**
3265
+ * Back off and try again later without claiming the connection is gone.
3266
+ * This is what a 429 or a 503 deserves — the server answered, so the app
3267
+ * is demonstrably online; it just should not hammer.
3268
+ */
3269
+ deferRetry() {
3270
+ const jitter = .8 + Math.random() * .4;
3271
+ this.retryAt = this.now() + this.backoffMs * jitter;
3272
+ const delay = Math.max(0, this.retryAt - this.now());
3273
+ this.backoffMs = Math.min(this.maxBackoffMs, this.backoffMs * 2);
3274
+ this.scheduleRetry(delay);
3275
+ }
3276
+ /** Milliseconds until the next attempt is allowed; 0 when one is allowed now. */
3277
+ msUntilRetry() {
3278
+ if (this.state === "online") return 0;
3279
+ return Math.max(0, this.retryAt - this.now());
3280
+ }
3281
+ onChange(listener) {
3282
+ this.listeners.add(listener);
3283
+ return () => this.listeners.delete(listener);
3284
+ }
3285
+ dispose() {
3286
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
3287
+ window.removeEventListener("online", this.handleOnline);
3288
+ window.removeEventListener("offline", this.handleOffline);
3289
+ }
3290
+ this.clearPendingTimer();
3291
+ this.listeners.clear();
3292
+ this.onRetryDue = void 0;
3293
+ }
3294
+ scheduleRetry(delay) {
3295
+ this.clearPendingTimer();
3296
+ if (!this.onRetryDue) return;
3297
+ this.timer = this.setTimer(() => {
3298
+ this.timer = void 0;
3299
+ this.onRetryDue?.();
3300
+ }, delay);
3301
+ this.timer.unref?.();
3302
+ }
3303
+ clearPendingTimer() {
3304
+ if (this.timer !== void 0) {
3305
+ this.clearTimer(this.timer);
3306
+ this.timer = void 0;
3307
+ }
3308
+ }
3309
+ setState(next) {
3310
+ if (this.state === next) return;
3311
+ this.state = next;
3312
+ const online = this.isOnline();
3313
+ for (const listener of this.listeners) listener(online);
3314
+ }
3315
+ };
3316
+ //#endregion
3317
+ //#region src/offline-store.ts
3318
+ /**
3319
+ * Monotonic within a tab, unique across tabs, and sortable as a plain string:
3320
+ * `<ms base36, padded>-<counter>-<random>`. The padding is what keeps
3321
+ * lexicographic order equal to chronological order, and the random suffix is
3322
+ * what stops two tabs from writing the same queue key in the same millisecond
3323
+ * — which would silently drop one of the two writes.
3324
+ */
3325
+ var mutationCounter = 0;
3326
+ function createMutationId(now = Date.now()) {
3327
+ return `${now.toString(36).padStart(10, "0")}-${(mutationCounter = (mutationCounter + 1) % 1679616).toString(36).padStart(4, "0")}-${Math.random().toString(36).slice(2, 10).padStart(8, "0")}`;
3328
+ }
3329
+ /**
3330
+ * In-memory store: the default outside the browser and the workhorse of the
3331
+ * test suite. Values are deep-copied on the way in and out so a caller
3332
+ * mutating a returned row cannot silently edit the "persisted" copy — the
3333
+ * IndexedDB implementation gets the same guarantee for free from structured
3334
+ * cloning, and the two must not differ in aliasing behaviour.
3335
+ */
3336
+ var MemoryOfflineStore = class {
3337
+ cache = /* @__PURE__ */ new Map();
3338
+ queue = /* @__PURE__ */ new Map();
3339
+ async getCache(key) {
3340
+ const entry = this.cache.get(key);
3341
+ return entry ? structuredClone(entry) : void 0;
3342
+ }
3343
+ async setCache(key, entry) {
3344
+ this.cache.set(key, structuredClone(entry));
3345
+ }
3346
+ async setCacheMany(entries) {
3347
+ for (const { key, entry } of entries) this.cache.set(key, structuredClone(entry));
3348
+ }
3349
+ async deleteCache(keys) {
3350
+ for (const key of keys) this.cache.delete(key);
3351
+ }
3352
+ async listCache(prefix) {
3353
+ const out = [];
3354
+ for (const [key, entry] of this.cache) if (key.startsWith(prefix)) out.push({
3355
+ key,
3356
+ cachedAt: entry.cachedAt
3357
+ });
3358
+ return out;
3359
+ }
3360
+ async listCacheEntries(prefix) {
3361
+ const out = [];
3362
+ for (const [key, entry] of this.cache) if (key.startsWith(prefix)) out.push({
3363
+ key,
3364
+ ...structuredClone(entry)
3365
+ });
3366
+ out.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
3367
+ return out;
3368
+ }
3369
+ async enqueue(key, mutation) {
3370
+ this.queue.set(key, structuredClone(mutation));
3371
+ }
3372
+ async dequeue(key) {
3373
+ this.queue.delete(key);
3374
+ }
3375
+ async listQueue(prefix) {
3376
+ return [...this.queue.entries()].filter(([key]) => key.startsWith(prefix)).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, mutation]) => structuredClone(mutation));
3377
+ }
3378
+ async clear(prefix) {
3379
+ for (const key of [...this.cache.keys()]) if (key.startsWith(prefix)) this.cache.delete(key);
3380
+ for (const key of [...this.queue.keys()]) if (key.startsWith(prefix)) this.queue.delete(key);
3381
+ }
3382
+ };
3383
+ var IDB_NAME = "rebase-offline";
3384
+ /**
3385
+ * v2 introduced the normalized row cache and string mutation ids. A v1
3386
+ * database holds whole-response blobs under keys this version cannot read and
3387
+ * queue entries ordered by a numeric `seq` this version no longer writes, so
3388
+ * the upgrade drops both stores rather than trying to translate them. Offline
3389
+ * support had not shipped in a release when v2 landed, so nothing in the wild
3390
+ * loses a queued write to this.
3391
+ */
3392
+ var IDB_VERSION = 2;
3393
+ var CACHE_STORE = "cache";
3394
+ var QUEUE_STORE = "queue";
3395
+ /** The exclusive upper bound of an IDBKeyRange covering every key under `prefix`. */
3396
+ function prefixRange(prefix) {
3397
+ return IDBKeyRange.bound(prefix, prefix + "￿", false, false);
3398
+ }
3399
+ function requestToPromise(request) {
3400
+ return new Promise((resolve, reject) => {
3401
+ request.onsuccess = () => resolve(request.result);
3402
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("IndexedDB request failed"));
3403
+ });
3404
+ }
3405
+ /** Resolve when the whole transaction commits, not just when the last request returns. */
3406
+ function transactionDone(tx) {
3407
+ return new Promise((resolve, reject) => {
3408
+ tx.oncomplete = () => resolve();
3409
+ tx.onabort = tx.onerror = () => reject(tx.error ?? /* @__PURE__ */ new Error("IndexedDB transaction failed"));
3410
+ });
3411
+ }
3412
+ /**
3413
+ * IndexedDB-backed store — the browser default, so cached rows and queued
3414
+ * writes survive a reload or a browser restart. Everything lives in one
3415
+ * database with two object stores; keys are the manager's full prefixed
3416
+ * strings, so multiple users (scopes) share the database without ever
3417
+ * sharing entries.
3418
+ */
3419
+ var IndexedDBOfflineStore = class {
3420
+ dbPromise;
3421
+ open() {
3422
+ if (!this.dbPromise) this.dbPromise = new Promise((resolve, reject) => {
3423
+ const request = indexedDB.open(IDB_NAME, IDB_VERSION);
3424
+ request.onupgradeneeded = (event) => {
3425
+ const db = request.result;
3426
+ if (event.oldVersion > 0 && event.oldVersion < 2) {
3427
+ if (db.objectStoreNames.contains(CACHE_STORE)) db.deleteObjectStore(CACHE_STORE);
3428
+ if (db.objectStoreNames.contains(QUEUE_STORE)) db.deleteObjectStore(QUEUE_STORE);
3429
+ }
3430
+ if (!db.objectStoreNames.contains(CACHE_STORE)) db.createObjectStore(CACHE_STORE);
3431
+ if (!db.objectStoreNames.contains(QUEUE_STORE)) db.createObjectStore(QUEUE_STORE);
3432
+ };
3433
+ request.onsuccess = () => {
3434
+ const db = request.result;
3435
+ db.onversionchange = () => {
3436
+ db.close();
3437
+ this.dbPromise = void 0;
3438
+ };
3439
+ resolve(db);
3440
+ };
3441
+ request.onerror = () => {
3442
+ this.dbPromise = void 0;
3443
+ reject(request.error ?? /* @__PURE__ */ new Error("Failed to open IndexedDB"));
3444
+ };
3445
+ request.onblocked = () => {
3446
+ this.dbPromise = void 0;
3447
+ reject(/* @__PURE__ */ new Error("IndexedDB upgrade blocked by another tab"));
3448
+ };
3449
+ });
3450
+ return this.dbPromise;
3451
+ }
3452
+ async store(name, mode) {
3453
+ return (await this.open()).transaction(name, mode).objectStore(name);
3454
+ }
3455
+ async getCache(key) {
3456
+ return await requestToPromise((await this.store(CACHE_STORE, "readonly")).get(key));
3457
+ }
3458
+ async setCache(key, entry) {
3459
+ await requestToPromise((await this.store(CACHE_STORE, "readwrite")).put(entry, key));
3460
+ }
3461
+ async setCacheMany(entries) {
3462
+ if (entries.length === 0) return;
3463
+ const store = await this.store(CACHE_STORE, "readwrite");
3464
+ for (const { key, entry } of entries) store.put(entry, key);
3465
+ await transactionDone(store.transaction);
3466
+ }
3467
+ async deleteCache(keys) {
3468
+ if (keys.length === 0) return;
3469
+ const store = await this.store(CACHE_STORE, "readwrite");
3470
+ for (const key of keys) store.delete(key);
3471
+ await transactionDone(store.transaction);
3472
+ }
3473
+ async listCache(prefix) {
3474
+ const store = await this.store(CACHE_STORE, "readonly");
3475
+ const [keys, entries] = await Promise.all([requestToPromise(store.getAllKeys(prefixRange(prefix))), requestToPromise(store.getAll(prefixRange(prefix)))]);
3476
+ return keys.map((key, i) => ({
3477
+ key: String(key),
3478
+ cachedAt: entries[i]?.cachedAt ?? 0
3479
+ }));
3480
+ }
3481
+ async listCacheEntries(prefix) {
3482
+ const store = await this.store(CACHE_STORE, "readonly");
3483
+ const [keys, entries] = await Promise.all([requestToPromise(store.getAllKeys(prefixRange(prefix))), requestToPromise(store.getAll(prefixRange(prefix)))]);
3484
+ return keys.map((key, i) => {
3485
+ const entry = entries[i];
3486
+ return {
3487
+ key: String(key),
3488
+ value: entry?.value,
3489
+ cachedAt: entry?.cachedAt ?? 0
3490
+ };
3491
+ });
3492
+ }
3493
+ async enqueue(key, mutation) {
3494
+ await requestToPromise((await this.store(QUEUE_STORE, "readwrite")).put(mutation, key));
3495
+ }
3496
+ async dequeue(key) {
3497
+ await requestToPromise((await this.store(QUEUE_STORE, "readwrite")).delete(key));
3498
+ }
3499
+ async listQueue(prefix) {
3500
+ return await requestToPromise((await this.store(QUEUE_STORE, "readonly")).getAll(prefixRange(prefix)));
3501
+ }
3502
+ async clear(prefix) {
3503
+ await requestToPromise((await this.store(CACHE_STORE, "readwrite")).delete(prefixRange(prefix)));
3504
+ await requestToPromise((await this.store(QUEUE_STORE, "readwrite")).delete(prefixRange(prefix)));
3505
+ }
3506
+ };
3507
+ //#endregion
3508
+ //#region src/offline-query.ts
3509
+ /**
3510
+ * A local evaluator for `FindParams`, so cached rows can answer a query the
3511
+ * client has never sent to the server — and so a row written offline shows up
3512
+ * in every filtered list it belongs to, not just in unfiltered ones.
3513
+ *
3514
+ * This mirrors the Postgres driver's semantics rather than JavaScript's:
3515
+ *
3516
+ * - Comparing against NULL is *unknown*, not false-or-true. `status != "done"`
3517
+ * excludes rows where `status` is null, exactly as SQL does — a JS `!==`
3518
+ * would have included them.
3519
+ * - `ORDER BY` puts nulls last ascending and first descending, which is the
3520
+ * Postgres default.
3521
+ * - The wire format carries no types, so values arriving as strings are
3522
+ * compared numerically against numeric columns and as instants against
3523
+ * date columns. `["==", "3"]` matches the number `3`, as it does server-side.
3524
+ *
3525
+ * Two things it deliberately approximates, both flagged by
3526
+ * {@link isExactlyEvaluable}: `searchString` becomes a case-insensitive
3527
+ * substring scan over the row's string fields (the server runs real full-text
3528
+ * search over the collection's configured columns), and `include` cannot be
3529
+ * evaluated at all, because the related rows live in collections this query
3530
+ * knows nothing about.
3531
+ */
3532
+ var collator = typeof Intl !== "undefined" && typeof Intl.Collator === "function" ? new Intl.Collator(void 0, {
3533
+ numeric: false,
3534
+ sensitivity: "variant"
3535
+ }) : void 0;
3536
+ function isNullish(value) {
3537
+ return value === null || value === void 0;
3538
+ }
3539
+ /**
3540
+ * Reduce a value to something comparable. Relations compare by the id they
3541
+ * point at — the column holds a foreign key, so that is what the server
3542
+ * compares too.
3543
+ */
3544
+ function toComparable(value) {
3545
+ if (value instanceof Date) return value.getTime();
3546
+ if (value instanceof EntityRelation) return value.id;
3547
+ if (value && typeof value === "object") {
3548
+ const record = value;
3549
+ if (typeof record.__type === "string" && "id" in record) return record.id;
3550
+ }
3551
+ return value;
3552
+ }
3553
+ /**
3554
+ * Three-way compare with SQL's type coercion but not its collation. Returns
3555
+ * `undefined` when the two values are not ordered relative to each other,
3556
+ * which is how NULL propagates through a comparison.
3557
+ */
3558
+ function compareValues(a, b) {
3559
+ const left = toComparable(a);
3560
+ const right = toComparable(b);
3561
+ if (isNullish(left) || isNullish(right)) return void 0;
3562
+ if (typeof left === "boolean" || typeof right === "boolean") return (left === true || left === "true" || left === 1 ? 1 : 0) - (right === true || right === "true" || right === 1 ? 1 : 0);
3563
+ const leftNum = typeof left === "number" ? left : numericOrNaN(left);
3564
+ const rightNum = typeof right === "number" ? right : numericOrNaN(right);
3565
+ if (!Number.isNaN(leftNum) && !Number.isNaN(rightNum)) return leftNum < rightNum ? -1 : leftNum > rightNum ? 1 : 0;
3566
+ if (typeof left === "number" || typeof right === "number") {
3567
+ const leftTime = toTime(left);
3568
+ const rightTime = toTime(right);
3569
+ if (leftTime !== void 0 && rightTime !== void 0) return leftTime < rightTime ? -1 : leftTime > rightTime ? 1 : 0;
3570
+ }
3571
+ const leftStr = String(left);
3572
+ const rightStr = String(right);
3573
+ if (collator) return collator.compare(leftStr, rightStr);
3574
+ return leftStr < rightStr ? -1 : leftStr > rightStr ? 1 : 0;
3575
+ }
3576
+ function numericOrNaN(value) {
3577
+ if (typeof value === "number") return value;
3578
+ if (typeof value === "string" && value.trim() !== "") {
3579
+ const n = Number(value);
3580
+ return Number.isNaN(n) ? NaN : n;
3581
+ }
3582
+ if (typeof value === "bigint") return Number(value);
3583
+ return NaN;
3584
+ }
3585
+ function toTime(value) {
3586
+ if (typeof value === "number") return value;
3587
+ if (typeof value === "string") {
3588
+ const t = Date.parse(value);
3589
+ return Number.isNaN(t) ? void 0 : t;
3590
+ }
3591
+ }
3592
+ /** Equality with the wire's type erasure allowed for, but never across NULL. */
3593
+ function looseEquals(a, b) {
3594
+ const left = toComparable(a);
3595
+ const right = toComparable(b);
3596
+ if (isNullish(left) || isNullish(right)) return isNullish(left) && isNullish(right);
3597
+ if (left === right) return true;
3598
+ return compareValues(left, right) === 0;
3599
+ }
3600
+ /**
3601
+ * Translate a SQL `LIKE` pattern to an anchored regular expression.
3602
+ * `%` matches any run of characters, `_` exactly one, and a backslash escapes
3603
+ * either of them.
3604
+ */
3605
+ function likeToRegExp(pattern, caseInsensitive) {
3606
+ let source = "^";
3607
+ for (let i = 0; i < pattern.length; i++) {
3608
+ const char = pattern[i];
3609
+ if (char === "\\" && i + 1 < pattern.length) {
3610
+ source += pattern[i + 1].replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3611
+ i++;
3612
+ } else if (char === "%") source += "[\\s\\S]*";
3613
+ else if (char === "_") source += "[\\s\\S]";
3614
+ else source += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3615
+ }
3616
+ return new RegExp(source + "$", caseInsensitive ? "i" : "");
3617
+ }
3618
+ function asArray(value) {
3619
+ if (Array.isArray(value)) return value;
3620
+ if (value === void 0) return [];
3621
+ return [value];
3622
+ }
3623
+ /** Evaluate one canonical operator against one row value. */
3624
+ function matchesOperator(rowValue, op, filterValue) {
3625
+ switch (op) {
3626
+ case "is-null": return isNullish(rowValue);
3627
+ case "is-not-null": return !isNullish(rowValue);
3628
+ case "==": return looseEquals(rowValue, filterValue);
3629
+ case "!=":
3630
+ if (isNullish(rowValue)) return false;
3631
+ return !looseEquals(rowValue, filterValue);
3632
+ case "<":
3633
+ case "<=":
3634
+ case ">":
3635
+ case ">=": {
3636
+ const cmp = compareValues(rowValue, filterValue);
3637
+ if (cmp === void 0) return false;
3638
+ if (op === "<") return cmp < 0;
3639
+ if (op === "<=") return cmp <= 0;
3640
+ if (op === ">") return cmp > 0;
3641
+ return cmp >= 0;
3642
+ }
3643
+ case "in":
3644
+ if (isNullish(rowValue)) return false;
3645
+ return asArray(filterValue).some((v) => looseEquals(rowValue, v));
3646
+ case "not-in":
3647
+ if (isNullish(rowValue)) return false;
3648
+ return !asArray(filterValue).some((v) => looseEquals(rowValue, v));
3649
+ case "array-contains":
3650
+ if (!Array.isArray(rowValue)) return false;
3651
+ return rowValue.some((v) => looseEquals(v, filterValue));
3652
+ case "array-contains-any": {
3653
+ if (!Array.isArray(rowValue)) return false;
3654
+ const wanted = asArray(filterValue);
3655
+ return rowValue.some((v) => wanted.some((w) => looseEquals(v, w)));
3656
+ }
3657
+ case "like":
3658
+ case "not-like":
3659
+ case "ilike":
3660
+ case "not-ilike": {
3661
+ if (isNullish(rowValue)) return false;
3662
+ const insensitive = op === "ilike" || op === "not-ilike";
3663
+ const negated = op === "not-like" || op === "not-ilike";
3664
+ const matched = likeToRegExp(String(filterValue), insensitive).test(String(rowValue));
3665
+ return negated ? !matched : matched;
3666
+ }
3667
+ default: return true;
3668
+ }
3669
+ }
3670
+ function isTuple(value) {
3671
+ return Array.isArray(value) && value.length === 2 && typeof value[0] === "string" && toCanonicalOp(value[0]) !== void 0;
3672
+ }
3673
+ /** Evaluate a `where` clause: every field, and every tuple on a field, AND-ed. */
3674
+ function matchesWhere(row, where) {
3675
+ if (!where) return true;
3676
+ for (const [field, condition] of Object.entries(where)) {
3677
+ if (condition === void 0) continue;
3678
+ const tuples = isTuple(condition) ? [condition] : Array.isArray(condition) ? condition.filter(isTuple) : [];
3679
+ for (const [rawOp, value] of tuples) {
3680
+ const op = toCanonicalOp(rawOp) ?? rawOp;
3681
+ if (!matchesOperator(row[field], op, value)) return false;
3682
+ }
3683
+ }
3684
+ return true;
3685
+ }
3686
+ /** Evaluate a nested and/or tree. */
3687
+ function matchesLogical(row, condition) {
3688
+ if (!condition) return true;
3689
+ if ("type" in condition) {
3690
+ const children = condition.conditions ?? [];
3691
+ if (children.length === 0) return true;
3692
+ return condition.type === "or" ? children.some((c) => matchesLogical(row, c)) : children.every((c) => matchesLogical(row, c));
3693
+ }
3694
+ const op = toCanonicalOp(condition.operator) ?? condition.operator;
3695
+ return matchesOperator(row[condition.column], op, condition.value);
3696
+ }
3697
+ /**
3698
+ * Approximate the server's full-text search with a case-insensitive substring
3699
+ * scan over the row's own string fields. Narrower than the real thing (no
3700
+ * stemming, no configured search columns), and it never matches a field the
3701
+ * cached row does not carry — a local list may therefore be missing rows the
3702
+ * server would have returned, which is why {@link isExactlyEvaluable} refuses
3703
+ * to call a search query exact.
3704
+ */
3705
+ function matchesSearch(row, searchString) {
3706
+ if (!searchString) return true;
3707
+ const needle = searchString.trim().toLowerCase();
3708
+ if (!needle) return true;
3709
+ for (const value of Object.values(row)) {
3710
+ if (typeof value === "string" && value.toLowerCase().includes(needle)) return true;
3711
+ if (typeof value === "number" && String(value).includes(needle)) return true;
3712
+ }
3713
+ return false;
3714
+ }
3715
+ /** Does this row belong in the result set for `params`, ignoring pagination? */
3716
+ function matchesParams(row, params) {
3717
+ if (!params) return true;
3718
+ return matchesWhere(row, params.where) && matchesLogical(row, params.logical) && matchesSearch(row, params.searchString);
3719
+ }
3720
+ /**
3721
+ * Sort in place, Postgres-style: nulls last ascending, first descending, with
3722
+ * the row id as a tiebreak so paging through an unsorted-but-equal run does
3723
+ * not shuffle rows between pages.
3724
+ */
3725
+ function sortRows(rows, orderBy) {
3726
+ if (!orderBy) return rows;
3727
+ const [field, direction = "asc"] = orderBy;
3728
+ const sign = direction === "desc" ? -1 : 1;
3729
+ return rows.sort((a, b) => {
3730
+ const av = a[field];
3731
+ const bv = b[field];
3732
+ const aNull = isNullish(toComparable(av));
3733
+ const bNull = isNullish(toComparable(bv));
3734
+ if (aNull || bNull) {
3735
+ if (aNull && bNull) return tiebreak(a, b);
3736
+ return (aNull ? 1 : -1) * (direction === "desc" ? -1 : 1);
3737
+ }
3738
+ const cmp = compareValues(av, bv);
3739
+ if (cmp === void 0 || cmp === 0) return tiebreak(a, b);
3740
+ return cmp * sign;
3741
+ });
3742
+ }
3743
+ function tiebreak(a, b) {
3744
+ return compareValues(a.id, b.id) ?? 0;
3745
+ }
3746
+ /** Resolve `page`/`offset`/`limit` the way the server does. */
3747
+ function resolvePagination(params) {
3748
+ const limit = params?.limit ?? 20;
3749
+ return {
3750
+ limit,
3751
+ offset: params?.page != null ? Math.max(0, (params.page - 1) * limit) : params?.offset ?? 0
3752
+ };
3753
+ }
3754
+ /**
3755
+ * Can a locally evaluated answer to `params` be trusted to match the server's,
3756
+ * assuming the cache holds every row of the collection?
3757
+ *
3758
+ * `include` pulls in rows from other collections that this evaluator never
3759
+ * sees, and `searchString` is only approximated — both make the local answer a
3760
+ * best effort rather than an equivalent one.
3761
+ */
3762
+ function isExactlyEvaluable(params) {
3763
+ if (!params) return true;
3764
+ if (params.include && params.include.length > 0) return false;
3765
+ if (params.searchString) return false;
3766
+ return true;
3767
+ }
3768
+ /** Run a full query — filter, sort, paginate — over a set of rows. */
3769
+ function runLocalQuery(rows, params) {
3770
+ const matched = rows.filter((row) => matchesParams(row, params));
3771
+ sortRows(matched, params?.orderBy);
3772
+ const { limit, offset } = resolvePagination(params);
3773
+ const page = matched.slice(offset, offset + limit);
3774
+ return {
3775
+ data: page,
3776
+ meta: {
3777
+ total: matched.length,
3778
+ limit,
3779
+ offset,
3780
+ hasMore: offset + page.length < matched.length
3781
+ }
3782
+ };
3783
+ }
3784
+ //#endregion
3785
+ //#region src/offline.ts
3786
+ /** True when a read failed because there was neither network nor local data. */
3787
+ function isOfflineError(error) {
3788
+ return error instanceof RebaseApiError && error.code === "offline";
3789
+ }
3790
+ function offlineError(message) {
3791
+ return new RebaseApiError(message, {
3792
+ status: 0,
3793
+ code: "offline"
3794
+ });
3795
+ }
3796
+ function generateOfflineId() {
3797
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
3798
+ return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
3799
+ }
3800
+ var MISSING = "\0missing";
3801
+ var OfflineManager = class {
3802
+ store;
3803
+ maxCachedQueries;
3804
+ maxCachedRows;
3805
+ maxRetries;
3806
+ onSyncError;
3807
+ createInner;
3808
+ inners = /* @__PURE__ */ new Map();
3809
+ connectivity;
3810
+ scope = "anon";
3811
+ /** The local database: normalized rows and query snapshots per collection. */
3812
+ collections = /* @__PURE__ */ new Map();
3813
+ /** In-memory mirror of the current scope's queue, in replay order. */
3814
+ queue = [];
3815
+ queueLoad;
3816
+ /** Serializes enqueues so concurrent writes keep the order the app made them. */
3817
+ enqueueChain = Promise.resolve();
3818
+ flushPromise;
3819
+ queueListeners = /* @__PURE__ */ new Set();
3820
+ statusListeners = /* @__PURE__ */ new Set();
3821
+ observers = /* @__PURE__ */ new Map();
3822
+ refreshPending = /* @__PURE__ */ new Set();
3823
+ revCounter = 0;
3824
+ disposed = false;
3825
+ currentStatus = {
3826
+ online: true,
3827
+ syncing: false,
3828
+ pending: 0
3829
+ };
3830
+ channel;
3831
+ tabId = createMutationId();
3832
+ api;
3833
+ constructor(config, createInner) {
3834
+ this.store = config.store ?? (typeof indexedDB !== "undefined" ? new IndexedDBOfflineStore() : new MemoryOfflineStore());
3835
+ this.maxCachedQueries = config.maxCachedQueriesPerCollection ?? 50;
3836
+ this.maxCachedRows = config.maxCachedRowsPerCollection ?? 5e3;
3837
+ this.maxRetries = config.maxRetries ?? 5;
3838
+ this.onSyncError = config.onSyncError;
3839
+ this.createInner = createInner;
3840
+ const maxBackoffMs = config.syncIntervalMs ?? 6e4;
3841
+ this.connectivity = new ConnectivityMonitor({
3842
+ maxBackoffMs: Math.max(1e3, maxBackoffMs),
3843
+ respectBackoff: maxBackoffMs > 0
3844
+ });
3845
+ if (maxBackoffMs > 0) this.connectivity.onRetryDue = () => {
3846
+ this.sync().catch(() => void 0);
3847
+ };
3848
+ this.connectivity.onChange((online) => {
3849
+ this.patchStatus({ online });
3850
+ if (online) this.revalidateAll();
3851
+ });
3852
+ this.currentStatus.online = this.connectivity.isOnline();
3853
+ if ((config.crossTab ?? this.store instanceof IndexedDBOfflineStore) && typeof BroadcastChannel !== "undefined") try {
3854
+ this.channel = new BroadcastChannel("rebase-offline");
3855
+ this.channel.onmessage = (event) => this.onBroadcast(event.data);
3856
+ this.channel.unref?.();
3857
+ } catch {}
3858
+ this.api = {
3859
+ sync: () => this.sync(),
3860
+ pending: async () => {
3861
+ await this.ensureQueueLoaded();
3862
+ return this.queue.map((m) => structuredClone(m));
3863
+ },
3864
+ status: () => ({ ...this.currentStatus }),
3865
+ onStatusChange: (listener) => {
3866
+ this.statusListeners.add(listener);
3867
+ return () => this.statusListeners.delete(listener);
3868
+ },
3869
+ clear: async () => {
3870
+ await this.store.clear(`${this.scope}|`);
3871
+ this.queue = [];
3872
+ this.resetCollections();
3873
+ this.patchStatus({
3874
+ pending: 0,
3875
+ lastError: void 0
3876
+ });
3877
+ this.notifyQueue();
3878
+ for (const slug of this.observers.keys()) this.notifyCollection(slug, false);
3879
+ },
3880
+ onQueueChange: (listener) => {
3881
+ this.queueListeners.add(listener);
3882
+ return () => this.queueListeners.delete(listener);
3883
+ }
3884
+ };
3885
+ }
3886
+ /**
3887
+ * Cache and queue are partitioned per signed-in user: cached rows are
3888
+ * RLS-filtered for the user who fetched them, and queued writes must
3889
+ * replay under the credentials that made them — so neither may ever leak
3890
+ * across a sign-out/sign-in on a shared browser.
3891
+ */
3892
+ setScope(uid) {
3893
+ const next = uid || "anon";
3894
+ if (next === this.scope) return;
3895
+ this.scope = next;
3896
+ this.queueLoad = void 0;
3897
+ this.queue = [];
3898
+ this.resetCollections();
3899
+ this.patchStatus({
3900
+ pending: 0,
3901
+ lastError: void 0
3902
+ });
3903
+ this.notifyQueue();
3904
+ for (const slug of this.observers.keys()) this.notifyCollection(slug, false);
3905
+ this.revalidateAll();
3906
+ this.sync().catch(() => void 0);
3907
+ }
3908
+ /**
3909
+ * Throw away every local row, for a scope change or an explicit clear.
3910
+ *
3911
+ * The state objects are replaced rather than emptied, so a load still in
3912
+ * flight for the previous user fails its identity check and discards what
3913
+ * it read instead of grafting it onto the new one. The replacements are
3914
+ * marked ready: nothing needs loading until something asks, and observers
3915
+ * have to be told *now* that the rows they are showing are gone.
3916
+ */
3917
+ resetCollections() {
3918
+ const slugs = [...this.collections.keys()];
3919
+ this.collections = /* @__PURE__ */ new Map();
3920
+ for (const slug of slugs) this.collections.set(slug, {
3921
+ rows: /* @__PURE__ */ new Map(),
3922
+ snapshots: /* @__PURE__ */ new Map(),
3923
+ fresh: /* @__PURE__ */ new Set(),
3924
+ freshRows: /* @__PURE__ */ new Set(),
3925
+ absent: /* @__PURE__ */ new Set(),
3926
+ ready: true
3927
+ });
3928
+ }
3929
+ /** Release listeners, timers and the cross-tab channel (client.close()). */
3930
+ dispose() {
3931
+ this.disposed = true;
3932
+ this.connectivity.dispose();
3933
+ try {
3934
+ this.channel?.close();
3935
+ } catch {}
3936
+ this.observers.clear();
3937
+ this.queueListeners.clear();
3938
+ this.statusListeners.clear();
3939
+ }
3940
+ wrap(slug, inner) {
3941
+ this.inners.set(slug, inner);
3942
+ const wrapped = {
3943
+ find: async (params) => {
3944
+ const state = await this.ensureCollection(slug);
3945
+ if (this.connectivity.shouldAttempt()) try {
3946
+ const res = await inner.find(params);
3947
+ this.connectivity.markSuccess();
3948
+ await this.ingest(slug, res.data ?? []);
3949
+ const snapshot = this.recordSnapshot(slug, params, res);
3950
+ const answer = this.answer(slug, params, snapshot);
3951
+ this.notifyCollection(slug, false);
3952
+ return {
3953
+ data: answer.data,
3954
+ meta: answer.meta
3955
+ };
3956
+ } catch (error) {
3957
+ if (!isNetworkError(error)) {
3958
+ if (isRetryableError(error) && this.hasLocalAnswer(state, slug, params)) {
3959
+ const answer = this.answer(slug, params, this.snapshotFor(slug, params));
3960
+ return {
3961
+ data: answer.data,
3962
+ meta: answer.meta
3963
+ };
3964
+ }
3965
+ throw error;
3966
+ }
3967
+ this.connectivity.markFailure();
3968
+ }
3969
+ const answer = this.localFind(slug, params);
3970
+ this.notifyCollection(slug, false);
3971
+ return {
3972
+ data: answer.data,
3973
+ meta: answer.meta
3974
+ };
3975
+ },
3976
+ findById: async (id) => {
3977
+ await this.ensureCollection(slug);
3978
+ if (this.connectivity.shouldAttempt()) try {
3979
+ const row = await inner.findById(id);
3980
+ this.connectivity.markSuccess();
3981
+ if (row !== void 0) await this.ingest(slug, [row]);
3982
+ else if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, true);
3983
+ this.notifyCollection(slug, false);
3984
+ return this.localRow(slug, id);
3985
+ } catch (error) {
3986
+ if (!isNetworkError(error)) throw error;
3987
+ this.connectivity.markFailure();
3988
+ }
3989
+ const local = this.localRow(slug, id);
3990
+ if (local !== void 0 || this.hasPending(slug, id)) return local;
3991
+ if (this.collections.get(slug)?.absent.has(String(id))) return void 0;
3992
+ throw offlineError(`Offline: "${slug}" row ${String(id)} is not in the local database.`);
3993
+ },
3994
+ create: async (data, id) => {
3995
+ await this.ensureCollection(slug);
3996
+ if (this.connectivity.shouldAttempt()) try {
3997
+ const row = await inner.create(data, id);
3998
+ this.connectivity.markSuccess();
3999
+ await this.ingest(slug, [row]);
4000
+ this.notifyCollection(slug);
4001
+ this.scheduleRefresh(slug);
4002
+ return row;
4003
+ } catch (error) {
4004
+ if (!isNetworkError(error)) throw error;
4005
+ this.connectivity.markFailure();
4006
+ }
4007
+ const providedId = id ?? data.id;
4008
+ const rowId = providedId ?? generateOfflineId();
4009
+ const row = {
4010
+ ...data,
4011
+ id: rowId
4012
+ };
4013
+ await this.enqueue({
4014
+ collection: slug,
4015
+ type: "create",
4016
+ id: rowId,
4017
+ data: row,
4018
+ generatedId: providedId === void 0,
4019
+ rollback: { rows: { [String(rowId)]: this.rawLocalRow(slug, rowId) ?? null } }
4020
+ });
4021
+ this.setLocalRow(slug, rowId, row);
4022
+ this.notifyCollection(slug);
4023
+ return row;
4024
+ },
4025
+ createMany: async (data, options) => {
4026
+ await this.ensureCollection(slug);
4027
+ if (!Array.isArray(data)) throw new TypeError("createMany expects an array of records.");
4028
+ if (data.length === 0) return [];
4029
+ if (this.connectivity.shouldAttempt()) try {
4030
+ const rows = await inner.createMany(data, options);
4031
+ this.connectivity.markSuccess();
4032
+ await this.ingest(slug, rows);
4033
+ this.notifyCollection(slug);
4034
+ this.scheduleRefresh(slug);
4035
+ return rows;
4036
+ } catch (error) {
4037
+ if (!isNetworkError(error)) throw error;
4038
+ this.connectivity.markFailure();
4039
+ }
4040
+ const rows = data.map((r) => ({
4041
+ ...r,
4042
+ id: r.id ?? generateOfflineId()
4043
+ }));
4044
+ const rollback = {};
4045
+ for (const row of rows) {
4046
+ const key = String(row.id);
4047
+ rollback[key] = this.rawLocalRow(slug, row.id) ?? null;
4048
+ }
4049
+ await this.enqueue({
4050
+ collection: slug,
4051
+ type: "createMany",
4052
+ data: rows,
4053
+ upsert: options?.upsert,
4054
+ rollback: { rows: rollback }
4055
+ });
4056
+ for (const row of rows) this.setLocalRow(slug, row.id, row);
4057
+ this.notifyCollection(slug);
4058
+ return rows;
4059
+ },
4060
+ update: async (id, data) => {
4061
+ await this.ensureCollection(slug);
4062
+ if (this.connectivity.shouldAttempt()) try {
4063
+ const row = await inner.update(id, data);
4064
+ this.connectivity.markSuccess();
4065
+ await this.ingest(slug, [row]);
4066
+ this.notifyCollection(slug);
4067
+ return row;
4068
+ } catch (error) {
4069
+ if (!isNetworkError(error)) throw error;
4070
+ this.connectivity.markFailure();
4071
+ }
4072
+ const base = this.rawLocalRow(slug, id);
4073
+ await this.enqueue({
4074
+ collection: slug,
4075
+ type: "update",
4076
+ id,
4077
+ data,
4078
+ rollback: { rows: { [String(id)]: base ?? null } }
4079
+ });
4080
+ const optimistic = {
4081
+ ...base ?? {},
4082
+ ...data,
4083
+ id
4084
+ };
4085
+ this.setLocalRow(slug, id, optimistic);
4086
+ this.notifyCollection(slug);
4087
+ return optimistic;
4088
+ },
4089
+ delete: async (id) => {
4090
+ await this.ensureCollection(slug);
4091
+ if (this.connectivity.shouldAttempt()) try {
4092
+ await inner.delete(id);
4093
+ this.connectivity.markSuccess();
4094
+ this.removeLocalRow(slug, id, true);
4095
+ this.notifyCollection(slug);
4096
+ this.scheduleRefresh(slug);
4097
+ return;
4098
+ } catch (error) {
4099
+ if (!isNetworkError(error)) throw error;
4100
+ this.connectivity.markFailure();
4101
+ }
4102
+ await this.enqueue({
4103
+ collection: slug,
4104
+ type: "delete",
4105
+ id,
4106
+ rollback: { rows: { [String(id)]: this.rawLocalRow(slug, id) ?? null } }
4107
+ });
4108
+ this.removeLocalRow(slug, id);
4109
+ this.notifyCollection(slug);
4110
+ },
4111
+ count: async (params) => {
4112
+ await this.ensureCollection(slug);
4113
+ if (this.connectivity.shouldAttempt()) try {
4114
+ const n = await inner.count(params);
4115
+ this.connectivity.markSuccess();
4116
+ this.writeCache(this.countKey(slug, params), n);
4117
+ return Math.max(0, n + this.pendingDelta(slug, params));
4118
+ } catch (error) {
4119
+ if (!isNetworkError(error)) throw error;
4120
+ this.connectivity.markFailure();
4121
+ }
4122
+ const cached = await this.readCache(this.countKey(slug, params));
4123
+ if (cached !== void 0) return Math.max(0, cached + this.pendingDelta(slug, params));
4124
+ const state = this.collections.get(slug);
4125
+ if (state && state.rows.size > 0) return runLocalQuery([...state.rows.values()].map((e) => e.row), params).meta.total;
4126
+ throw offlineError(`Offline: no cached count for "${slug}".`);
4127
+ },
4128
+ observe: (params, onResult, onError, options) => this.observe(slug, wrapped, inner, params, onResult, onError, options),
4129
+ observeById: (id, onResult, onError, options) => this.observeById(slug, wrapped, inner, id, onResult, onError, options),
4130
+ where(columnOrCondition, operator, value) {
4131
+ const builder = new SDKQueryBuilder(wrapped);
4132
+ if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
4133
+ return builder.where(columnOrCondition, operator, value);
4134
+ },
4135
+ orderBy: (column, direction) => new SDKQueryBuilder(wrapped).orderBy(column, direction),
4136
+ limit: (count) => new SDKQueryBuilder(wrapped).limit(count),
4137
+ offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
4138
+ search: (searchString) => new SDKQueryBuilder(wrapped).search(searchString),
4139
+ include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations)
4140
+ };
4141
+ if (inner.listen) wrapped.listen = (params, onUpdate, onError) => inner.listen(params, (response) => {
4142
+ this.ingest(slug, response.data ?? []).then(() => this.notifyCollection(slug, false));
4143
+ onUpdate(response);
4144
+ }, onError);
4145
+ if (inner.listenById) wrapped.listenById = (id, onUpdate, onError) => inner.listenById(id, (row) => {
4146
+ if (row) this.ingest(slug, [row]).then(() => this.notifyCollection(slug, false));
4147
+ onUpdate(row);
4148
+ }, onError);
4149
+ return wrapped;
4150
+ }
4151
+ observe(slug, wrapped, inner, params, onResult, onError, options) {
4152
+ let closed = false;
4153
+ let unlisten;
4154
+ const observer = {
4155
+ slug,
4156
+ params,
4157
+ settled: false,
4158
+ refresh: () => wrapped.find(params).catch(() => void 0),
4159
+ emit: () => {
4160
+ if (closed || !this.collections.get(slug)?.ready) return;
4161
+ const result = this.answer(slug, params, this.snapshotFor(slug, params));
4162
+ const signature = `${result.fromCache ? "c" : "s"}${result.hasPendingWrites ? "p" : "-"}` + this.signature(slug, result.data, result.meta.total);
4163
+ if (observer.settled && signature === observer.signature) return;
4164
+ observer.signature = signature;
4165
+ observer.settled = true;
4166
+ onResult(observer.error ? {
4167
+ ...result,
4168
+ error: observer.error
4169
+ } : result);
4170
+ }
4171
+ };
4172
+ this.observersFor(slug).add(observer);
4173
+ (async () => {
4174
+ await this.ensureCollection(slug);
4175
+ if (closed) return;
4176
+ if (this.hasLocalAnswer(this.collections.get(slug), slug, params)) observer.emit();
4177
+ try {
4178
+ await wrapped.find(params);
4179
+ observer.error = void 0;
4180
+ } catch (error) {
4181
+ observer.error = error;
4182
+ if (closed) return;
4183
+ if (!observer.settled) {
4184
+ onError?.(error);
4185
+ return;
4186
+ }
4187
+ }
4188
+ if (!closed) observer.emit();
4189
+ })();
4190
+ if (options?.realtime !== false && inner.listen) unlisten = inner.listen(params, (response) => {
4191
+ this.ingest(slug, response.data ?? []).then(() => {
4192
+ this.recordSnapshot(slug, params, response);
4193
+ this.notifyCollection(slug, false);
4194
+ });
4195
+ }, onError);
4196
+ return () => {
4197
+ closed = true;
4198
+ this.observersFor(slug).delete(observer);
4199
+ unlisten?.();
4200
+ };
4201
+ }
4202
+ observeById(slug, wrapped, inner, id, onResult, onError, options) {
4203
+ let closed = false;
4204
+ let unlisten;
4205
+ const observer = {
4206
+ slug,
4207
+ id,
4208
+ settled: false,
4209
+ refresh: () => wrapped.findById(id).catch(() => void 0),
4210
+ emit: () => {
4211
+ if (closed || !this.collections.get(slug)?.ready) return;
4212
+ const row = this.localRow(slug, id);
4213
+ const entry = this.collections.get(slug)?.rows.get(String(id));
4214
+ const fromCache = !this.collections.get(slug)?.freshRows.has(String(id));
4215
+ const hasPendingWrites = this.hasPending(slug, id);
4216
+ const signature = `${fromCache ? "c" : "s"}${hasPendingWrites ? "p" : "-"}|` + (row === void 0 ? MISSING : `${String(id)}:${entry?.rev ?? 0}`);
4217
+ if (observer.settled && signature === observer.signature) return;
4218
+ observer.signature = signature;
4219
+ observer.settled = true;
4220
+ onResult(row, {
4221
+ fromCache,
4222
+ hasPendingWrites
4223
+ });
4224
+ }
4225
+ };
4226
+ this.observersFor(slug).add(observer);
4227
+ (async () => {
4228
+ await this.ensureCollection(slug);
4229
+ if (closed) return;
4230
+ if (this.localRow(slug, id) !== void 0) observer.emit();
4231
+ try {
4232
+ await wrapped.findById(id);
4233
+ } catch (error) {
4234
+ if (closed) return;
4235
+ if (!observer.settled) {
4236
+ onError?.(error);
4237
+ return;
4238
+ }
4239
+ }
4240
+ if (!closed) observer.emit();
4241
+ })();
4242
+ if (options?.realtime !== false && inner.listenById) unlisten = inner.listenById(id, (row) => {
4243
+ if (!row) {
4244
+ if (!this.hasPending(slug, id)) this.removeLocalRow(slug, id, true);
4245
+ this.notifyCollection(slug, false);
4246
+ return;
4247
+ }
4248
+ this.ingest(slug, [row]).then(() => this.notifyCollection(slug, false));
4249
+ }, onError);
4250
+ return () => {
4251
+ closed = true;
4252
+ this.observersFor(slug).delete(observer);
4253
+ unlisten?.();
4254
+ };
4255
+ }
4256
+ observersFor(slug) {
4257
+ let set = this.observers.get(slug);
4258
+ if (!set) {
4259
+ set = /* @__PURE__ */ new Set();
4260
+ this.observers.set(slug, set);
4261
+ }
4262
+ return set;
4263
+ }
4264
+ /** Cheap change detection: which rows, in what order, at which revision. */
4265
+ signature(slug, rows, total) {
4266
+ const state = this.collections.get(slug);
4267
+ return `${total}|${rows.map((row) => {
4268
+ const key = String(row.id);
4269
+ return `${key}:${state?.rows.get(key)?.rev ?? 0}`;
4270
+ }).join(",")}`;
4271
+ }
4272
+ notifyCollection(slug, broadcast = true) {
4273
+ const set = this.observers.get(slug);
4274
+ if (set) for (const observer of [...set]) observer.emit();
4275
+ if (broadcast) this.broadcast({
4276
+ type: "rows",
4277
+ slugs: [slug]
4278
+ });
4279
+ }
4280
+ /** Connectivity came back (or the user changed): re-read everything live. */
4281
+ revalidateAll() {
4282
+ for (const slug of this.observers.keys()) {
4283
+ this.notifyCollection(slug, false);
4284
+ this.scheduleRefresh(slug);
4285
+ }
4286
+ }
4287
+ collectionState(slug) {
4288
+ let state = this.collections.get(slug);
4289
+ if (!state) {
4290
+ state = {
4291
+ rows: /* @__PURE__ */ new Map(),
4292
+ snapshots: /* @__PURE__ */ new Map(),
4293
+ fresh: /* @__PURE__ */ new Set(),
4294
+ freshRows: /* @__PURE__ */ new Set(),
4295
+ absent: /* @__PURE__ */ new Set(),
4296
+ ready: false
4297
+ };
4298
+ this.collections.set(slug, state);
4299
+ }
4300
+ return state;
4301
+ }
4302
+ ensureCollection(slug) {
4303
+ const state = this.collectionState(slug);
4304
+ if (!state.loaded) {
4305
+ const scope = this.scope;
4306
+ state.loaded = (async () => {
4307
+ await this.ensureQueueLoaded();
4308
+ const [rows, snapshots, absent] = await Promise.all([
4309
+ this.store.listCacheEntries(`${scope}|row|${slug}|`).catch(() => []),
4310
+ this.store.listCacheEntries(`${scope}|q|${slug}|`).catch(() => []),
4311
+ this.store.listCache(`${scope}|abs|${slug}|`).catch(() => [])
4312
+ ]);
4313
+ if (this.scope !== scope || this.collections.get(slug) !== state) return;
4314
+ for (const entry of rows) {
4315
+ const row = entry.value;
4316
+ if (!row || row.id === void 0 || row.id === null) continue;
4317
+ state.rows.set(String(row.id), {
4318
+ row: hydrateRow(row),
4319
+ cachedAt: entry.cachedAt,
4320
+ rev: ++this.revCounter
4321
+ });
4322
+ }
4323
+ for (const entry of snapshots) {
4324
+ const key = entry.key.slice(`${scope}|q|${slug}|`.length);
4325
+ if (entry.value) state.snapshots.set(key, entry.value);
4326
+ }
4327
+ for (const entry of absent) state.absent.add(entry.key.slice(`${scope}|abs|${slug}|`.length));
4328
+ })().catch(() => void 0).finally(() => {
4329
+ state.ready = true;
4330
+ });
4331
+ }
4332
+ return state.loaded.then(() => state);
4333
+ }
4334
+ snapshotFor(slug, params) {
4335
+ return this.collections.get(slug)?.snapshots.get(buildQueryString(params));
4336
+ }
4337
+ hasLocalAnswer(state, slug, params) {
4338
+ if (!state) return false;
4339
+ return state.snapshots.has(buildQueryString(params)) || state.rows.size > 0;
4340
+ }
4341
+ /**
4342
+ * Answer a query from the local database.
4343
+ *
4344
+ * With a snapshot, the server's own page — its ids, order and total — is
4345
+ * the skeleton, and the local rows fill it in: rows deleted locally drop
4346
+ * out, rows edited locally show the edit, and rows *created* locally join
4347
+ * the first page if they match. Without one, the query is evaluated
4348
+ * outright over every cached row, which is the best that can be done for a
4349
+ * query the server has never answered here.
4350
+ */
4351
+ answer(slug, params, snapshot) {
4352
+ const state = this.collections.get(slug);
4353
+ const exact = isExactlyEvaluable(params);
4354
+ const fromCache = !state?.fresh.has(buildQueryString(params));
4355
+ if (!state) return {
4356
+ data: [],
4357
+ meta: {
4358
+ total: 0,
4359
+ limit: params?.limit ?? 20,
4360
+ offset: params?.offset ?? 0,
4361
+ hasMore: false
4362
+ },
4363
+ fromCache: true,
4364
+ hasPendingWrites: false,
4365
+ partial: true
4366
+ };
4367
+ if (!snapshot) {
4368
+ const local = runLocalQuery([...state.rows.values()].map((e) => e.row), params);
4369
+ return {
4370
+ ...local,
4371
+ fromCache,
4372
+ hasPendingWrites: local.data.some((row) => this.hasPending(slug, row.id)),
4373
+ partial: true
4374
+ };
4375
+ }
4376
+ const rows = [];
4377
+ const seen = /* @__PURE__ */ new Set();
4378
+ /** Rows the server counted that we know are no longer in the result. */
4379
+ let removed = 0;
4380
+ for (const id of snapshot.ids) {
4381
+ const key = String(id);
4382
+ const entry = state.rows.get(key);
4383
+ if (!entry) {
4384
+ if (state.absent.has(key) || this.hasPending(slug, key)) removed++;
4385
+ continue;
4386
+ }
4387
+ if (exact && this.hasPending(slug, key) && !matchesParams(entry.row, params)) {
4388
+ removed++;
4389
+ continue;
4390
+ }
4391
+ rows.push(entry.row);
4392
+ seen.add(key);
4393
+ }
4394
+ let added = 0;
4395
+ const offset = snapshot.offset ?? 0;
4396
+ if (exact && offset === 0) {
4397
+ for (const [key, entry] of state.rows) {
4398
+ if (seen.has(key) || !this.hasPending(slug, key)) continue;
4399
+ if (!this.isLocallyCreated(slug, key)) continue;
4400
+ if (!matchesParams(entry.row, params)) continue;
4401
+ rows.push(entry.row);
4402
+ added++;
4403
+ }
4404
+ if (added > 0 && params?.orderBy) sortRows(rows, params.orderBy);
4405
+ }
4406
+ return {
4407
+ data: rows,
4408
+ meta: {
4409
+ total: Math.max(rows.length, snapshot.total - removed + added),
4410
+ limit: snapshot.limit,
4411
+ offset,
4412
+ hasMore: snapshot.hasMore
4413
+ },
4414
+ fromCache,
4415
+ hasPendingWrites: rows.some((row) => this.hasPending(slug, row.id)),
4416
+ partial: !exact
4417
+ };
4418
+ }
4419
+ localFind(slug, params) {
4420
+ const state = this.collections.get(slug);
4421
+ const snapshot = this.snapshotFor(slug, params);
4422
+ state?.fresh.delete(buildQueryString(params));
4423
+ if (!snapshot && (!state || state.rows.size === 0)) throw offlineError(`Offline: no cached data for "${slug}".`);
4424
+ const answer = this.answer(slug, params, snapshot);
4425
+ return snapshot ? answer : {
4426
+ ...answer,
4427
+ partial: true
4428
+ };
4429
+ }
4430
+ rawLocalRow(slug, id) {
4431
+ const entry = this.collections.get(slug)?.rows.get(String(id));
4432
+ return entry ? { ...entry.row } : void 0;
4433
+ }
4434
+ localRow(slug, id) {
4435
+ return this.collections.get(slug)?.rows.get(String(id))?.row;
4436
+ }
4437
+ setLocalRow(slug, id, row) {
4438
+ const state = this.collectionState(slug);
4439
+ const key = String(id);
4440
+ const cachedAt = Date.now();
4441
+ state.rows.set(key, {
4442
+ row: { ...row },
4443
+ cachedAt,
4444
+ rev: ++this.revCounter
4445
+ });
4446
+ state.freshRows.delete(key);
4447
+ this.forgetTombstone(slug, key);
4448
+ this.writeCache(this.rowKey(slug, key), dehydrateRow(row), cachedAt);
4449
+ this.evictRows(slug);
4450
+ }
4451
+ /**
4452
+ * Drop a row and, when the server is the one saying it is gone, remember
4453
+ * that. "I looked it up and it does not exist" is real knowledge: without
4454
+ * it, opening a deleted row while offline would report a missing local
4455
+ * database instead of a missing row.
4456
+ */
4457
+ removeLocalRow(slug, id, known = false) {
4458
+ const state = this.collectionState(slug);
4459
+ const key = String(id);
4460
+ const existed = state.rows.delete(key);
4461
+ if (known) {
4462
+ state.absent.add(key);
4463
+ state.freshRows.add(key);
4464
+ this.writeCache(this.absentKey(slug, key), true);
4465
+ } else state.freshRows.delete(key);
4466
+ if (existed) this.deleteCache([this.rowKey(slug, key)]);
4467
+ }
4468
+ forgetTombstone(slug, key) {
4469
+ if (!this.collectionState(slug).absent.delete(key)) return;
4470
+ this.deleteCache([this.absentKey(slug, key)]);
4471
+ }
4472
+ /**
4473
+ * Merge server rows into the local database. A row with unsynced local
4474
+ * writes keeps them: the server's copy is the base the queued mutations
4475
+ * are re-applied to, not a replacement for what the user did.
4476
+ *
4477
+ * Rows that came back unchanged keep their identity and revision, so a
4478
+ * refetch that changed nothing does not re-render every live query that
4479
+ * touches them — or rewrite them all to disk.
4480
+ */
4481
+ async ingest(slug, rows) {
4482
+ if (rows.length === 0) return;
4483
+ const state = await this.ensureCollection(slug);
4484
+ const cachedAt = Date.now();
4485
+ const writes = [];
4486
+ const deletes = [];
4487
+ for (const raw of rows) {
4488
+ if (!raw || raw.id === void 0 || raw.id === null) continue;
4489
+ const key = String(raw.id);
4490
+ const merged = this.hasPending(slug, key) ? this.applyPendingToRow(slug, key, { ...raw }) : { ...raw };
4491
+ if (merged === void 0) {
4492
+ state.rows.delete(key);
4493
+ deletes.push(this.rowKey(slug, key));
4494
+ continue;
4495
+ }
4496
+ this.forgetTombstone(slug, key);
4497
+ state.freshRows.add(key);
4498
+ const existing = state.rows.get(key);
4499
+ if (existing && JSON.stringify(existing.row) === JSON.stringify(merged)) {
4500
+ existing.cachedAt = cachedAt;
4501
+ continue;
4502
+ }
4503
+ state.rows.set(key, {
4504
+ row: merged,
4505
+ cachedAt,
4506
+ rev: ++this.revCounter
4507
+ });
4508
+ writes.push({
4509
+ key: this.rowKey(slug, key),
4510
+ entry: {
4511
+ value: dehydrateRow(merged),
4512
+ cachedAt
4513
+ }
4514
+ });
4515
+ }
4516
+ if (writes.length > 0) this.store.setCacheMany(writes).catch(() => void 0);
4517
+ if (deletes.length > 0) this.deleteCache(deletes);
4518
+ this.evictRows(slug);
4519
+ }
4520
+ /**
4521
+ * Fold the queued mutations for one row over a base, newest last.
4522
+ * `afterMutationId` skips everything up to and including that mutation,
4523
+ * which is how a just-replayed write avoids being applied on top of the
4524
+ * server's response to it.
4525
+ */
4526
+ applyPendingToRow(slug, idKey, base, afterMutationId) {
4527
+ let row = base;
4528
+ let skipping = afterMutationId !== void 0;
4529
+ for (const op of this.queue) {
4530
+ if (skipping) {
4531
+ if (op.mutationId === afterMutationId) skipping = false;
4532
+ continue;
4533
+ }
4534
+ if (op.collection !== slug) continue;
4535
+ if (op.type === "createMany") {
4536
+ const match = op.data?.find((r) => String(r.id) === idKey);
4537
+ if (match) row = { ...match };
4538
+ continue;
4539
+ }
4540
+ if (op.id === void 0 || String(op.id) !== idKey) continue;
4541
+ if (op.type === "create") row = { ...op.data };
4542
+ else if (op.type === "update") row = {
4543
+ ...row ?? {},
4544
+ ...op.data,
4545
+ id: op.id
4546
+ };
4547
+ else if (op.type === "delete") row = void 0;
4548
+ }
4549
+ return row;
4550
+ }
4551
+ recordSnapshot(slug, params, result) {
4552
+ const meta = result.meta ?? {
4553
+ total: result.data?.length ?? 0,
4554
+ limit: 20,
4555
+ offset: 0,
4556
+ hasMore: false
4557
+ };
4558
+ const snapshot = {
4559
+ ids: (result.data ?? []).map((row) => row.id).filter((id) => id !== void 0),
4560
+ total: meta.total ?? result.data?.length ?? 0,
4561
+ limit: meta.limit ?? params?.limit ?? 20,
4562
+ offset: meta.offset ?? params?.offset ?? 0,
4563
+ hasMore: meta.hasMore ?? false
4564
+ };
4565
+ const state = this.collectionState(slug);
4566
+ const key = buildQueryString(params);
4567
+ state.snapshots.set(key, snapshot);
4568
+ state.fresh.add(key);
4569
+ this.writeCache(`${this.scope}|q|${slug}|${key}`, snapshot);
4570
+ this.evictSnapshots(slug);
4571
+ return snapshot;
4572
+ }
4573
+ /**
4574
+ * A write changed which rows belong in a list, and only the server can say
4575
+ * how — a row it generated is in no cached page, and the totals moved.
4576
+ * Re-run every live query on the collection; queries nobody is watching
4577
+ * are corrected by their next `find`.
4578
+ *
4579
+ * Coalesced per microtask so a burst of writes costs one round trip, and
4580
+ * skipped entirely while offline, where the local database is already the
4581
+ * best answer available.
4582
+ */
4583
+ scheduleRefresh(slug) {
4584
+ if (this.refreshPending.has(slug)) return;
4585
+ const observers = this.observers.get(slug);
4586
+ if (!observers || observers.size === 0) return;
4587
+ this.refreshPending.add(slug);
4588
+ Promise.resolve().then(() => {
4589
+ this.refreshPending.delete(slug);
4590
+ if (this.disposed || !this.connectivity.shouldAttempt()) return;
4591
+ for (const observer of [...this.observers.get(slug) ?? []]) observer.refresh();
4592
+ });
4593
+ }
4594
+ evictRows(slug) {
4595
+ const state = this.collections.get(slug);
4596
+ if (!state || state.rows.size <= this.maxCachedRows) return;
4597
+ const evictable = [...state.rows.entries()].filter(([key]) => !this.hasPending(slug, key)).sort((a, b) => a[1].cachedAt - b[1].cachedAt);
4598
+ const excess = state.rows.size - this.maxCachedRows;
4599
+ const doomed = evictable.slice(0, excess);
4600
+ for (const [key] of doomed) state.rows.delete(key);
4601
+ if (doomed.length > 0) this.deleteCache(doomed.map(([key]) => this.rowKey(slug, key)));
4602
+ if (state.absent.size > this.maxCachedRows) {
4603
+ const stale = [...state.absent].slice(0, state.absent.size - this.maxCachedRows);
4604
+ for (const key of stale) state.absent.delete(key);
4605
+ this.deleteCache(stale.map((key) => this.absentKey(slug, key)));
4606
+ }
4607
+ }
4608
+ evictSnapshots(slug) {
4609
+ const state = this.collections.get(slug);
4610
+ if (!state || state.snapshots.size <= this.maxCachedQueries) return;
4611
+ const excess = state.snapshots.size - this.maxCachedQueries;
4612
+ const doomed = [...state.snapshots.keys()].slice(0, excess);
4613
+ for (const key of doomed) state.snapshots.delete(key);
4614
+ this.deleteCache(doomed.map((key) => `${this.scope}|q|${slug}|${key}`));
4615
+ }
4616
+ ensureQueueLoaded() {
4617
+ if (!this.queueLoad) {
4618
+ const scope = this.scope;
4619
+ this.queueLoad = this.store.listQueue(`${scope}|`).then((queue) => {
4620
+ if (this.scope !== scope) return;
4621
+ this.queue = queue;
4622
+ this.patchStatus({ pending: queue.length });
4623
+ this.notifyQueue();
4624
+ }).catch(() => void 0);
4625
+ }
4626
+ return this.queueLoad;
4627
+ }
4628
+ enqueue(mutation) {
4629
+ const result = this.enqueueChain.then(async () => {
4630
+ await this.ensureQueueLoaded();
4631
+ if (mutation.type === "update") {
4632
+ const tail = this.queue[this.queue.length - 1];
4633
+ if (tail && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id) {
4634
+ tail.data = {
4635
+ ...tail.data,
4636
+ ...mutation.data,
4637
+ id: tail.id
4638
+ };
4639
+ await this.store.enqueue(this.queueKey(tail), tail);
4640
+ return;
4641
+ }
4642
+ }
4643
+ if (mutation.type === "delete") {
4644
+ if (this.queue.some((m) => m.collection === mutation.collection && m.type === "create" && m.id === mutation.id && m.generatedId === true)) {
4645
+ const doomed = this.queue.filter((m) => m.collection === mutation.collection && m.id === mutation.id && (m.type === "create" || m.type === "update"));
4646
+ for (const op of doomed) await this.store.dequeue(this.queueKey(op));
4647
+ this.queue = this.queue.filter((m) => !doomed.includes(m));
4648
+ this.afterQueueChange();
4649
+ return;
4650
+ }
4651
+ }
4652
+ const full = {
4653
+ ...mutation,
4654
+ mutationId: createMutationId(),
4655
+ queuedAt: Date.now()
4656
+ };
4657
+ await this.store.enqueue(this.queueKey(full), full);
4658
+ this.queue.push(full);
4659
+ this.afterQueueChange();
4660
+ });
4661
+ this.enqueueChain = result.catch(() => void 0);
4662
+ return result;
4663
+ }
4664
+ hasPending(slug, id) {
4665
+ const key = String(id);
4666
+ return this.queue.some((op) => {
4667
+ if (op.collection !== slug) return false;
4668
+ if (op.type === "createMany") return op.data?.some((r) => String(r.id) === key) ?? false;
4669
+ return op.id !== void 0 && String(op.id) === key;
4670
+ });
4671
+ }
4672
+ /** Is this row one the server has never been told about? */
4673
+ isLocallyCreated(slug, idKey) {
4674
+ return this.queue.some((op) => {
4675
+ if (op.collection !== slug) return false;
4676
+ if (op.type === "create") return op.id !== void 0 && String(op.id) === idKey;
4677
+ if (op.type === "createMany") return op.data?.some((r) => String(r.id) === idKey) ?? false;
4678
+ return false;
4679
+ });
4680
+ }
4681
+ /** How many rows the queue adds to (or removes from) a server-side count. */
4682
+ pendingDelta(slug, params) {
4683
+ if (!isExactlyEvaluable(params)) return 0;
4684
+ let delta = 0;
4685
+ for (const op of this.queue) {
4686
+ if (op.collection !== slug) continue;
4687
+ if (op.type === "create") {
4688
+ if (matchesParams(op.data, params)) delta++;
4689
+ } else if (op.type === "createMany") {
4690
+ for (const row of op.data ?? []) if (matchesParams(row, params)) delta++;
4691
+ } else if (op.type === "delete") {
4692
+ const before = op.rollback?.rows?.[String(op.id)];
4693
+ if (before && matchesParams(before, params)) delta--;
4694
+ }
4695
+ }
4696
+ return delta;
4697
+ }
4698
+ sync() {
4699
+ if (this.flushPromise) return this.flushPromise;
4700
+ this.flushPromise = this.withLock(() => this.flush()).finally(() => {
4701
+ this.flushPromise = void 0;
4702
+ });
4703
+ return this.flushPromise;
4704
+ }
4705
+ async flush() {
4706
+ await this.ensureQueueLoaded();
4707
+ await this.reloadQueue();
4708
+ if (this.queue.length === 0) return {
4709
+ flushed: 0,
4710
+ remaining: 0
4711
+ };
4712
+ this.patchStatus({ syncing: true });
4713
+ const touched = /* @__PURE__ */ new Set();
4714
+ const queuedAtStart = this.queue.length;
4715
+ let flushed = 0;
4716
+ try {
4717
+ while (this.queue.length > 0 && !this.disposed) {
4718
+ const op = this.queue[0];
4719
+ touched.add(op.collection);
4720
+ try {
4721
+ await this.replay(op);
4722
+ } catch (error) {
4723
+ if (isNetworkError(error)) {
4724
+ this.connectivity.markFailure();
4725
+ break;
4726
+ }
4727
+ op.attempts = (op.attempts ?? 0) + 1;
4728
+ op.lastError = error?.message ?? String(error);
4729
+ if (isRetryableError(error) && op.attempts < this.maxRetries) {
4730
+ await this.store.enqueue(this.queueKey(op), op).catch(() => void 0);
4731
+ this.connectivity.deferRetry();
4732
+ this.patchStatus({ lastError: op.lastError });
4733
+ break;
4734
+ }
4735
+ await this.rejectMutation(op, error);
4736
+ continue;
4737
+ }
4738
+ this.connectivity.markSuccess();
4739
+ await this.drop(op);
4740
+ flushed++;
4741
+ }
4742
+ } finally {
4743
+ this.patchStatus({ syncing: false });
4744
+ }
4745
+ if (this.queue.length !== queuedAtStart) {
4746
+ for (const slug of touched) {
4747
+ this.notifyCollection(slug);
4748
+ this.scheduleRefresh(slug);
4749
+ }
4750
+ this.broadcast({ type: "queue" });
4751
+ }
4752
+ if (this.queue.length === 0) this.patchStatus({ lastSyncedAt: Date.now() });
4753
+ return {
4754
+ flushed,
4755
+ remaining: this.queue.length
4756
+ };
4757
+ }
4758
+ async replay(op) {
4759
+ const inner = this.innerFor(op.collection);
4760
+ if (op.type === "create") {
4761
+ const row = await inner.create(op.data);
4762
+ await this.adoptServerRow(op, op.id, row);
4763
+ } else if (op.type === "createMany") {
4764
+ const queued = op.data ?? [];
4765
+ const rows = await inner.createMany(queued, op.upsert ? { upsert: true } : void 0);
4766
+ for (let i = 0; i < rows.length; i++) await this.adoptServerRow(op, queued[i]?.id, rows[i]);
4767
+ } else if (op.type === "update") {
4768
+ const row = await inner.update(op.id, op.data);
4769
+ await this.ingestReplaced(op, op.id, row);
4770
+ } else if (op.type === "delete") {
4771
+ await inner.delete(op.id);
4772
+ this.removeLocalRow(op.collection, op.id, true);
4773
+ }
4774
+ }
4775
+ /**
4776
+ * Take the server's version of a row the client created offline.
4777
+ *
4778
+ * The server may have assigned a different id — a serial column ignores
4779
+ * the id we invented — in which case every local trace of the temporary id
4780
+ * has to move with it, including queued writes that were made against it
4781
+ * before it was ever sent.
4782
+ */
4783
+ async adoptServerRow(op, localId, row) {
4784
+ if (!row) return;
4785
+ const slug = op.collection;
4786
+ const serverId = row.id;
4787
+ if (localId !== void 0 && serverId !== void 0 && String(serverId) !== String(localId)) {
4788
+ const oldKey = String(localId);
4789
+ this.removeLocalRow(slug, localId);
4790
+ for (const queued of this.queue) {
4791
+ if (queued.collection !== slug) continue;
4792
+ let dirty = false;
4793
+ if (queued.id !== void 0 && String(queued.id) === oldKey) {
4794
+ queued.id = serverId;
4795
+ if (queued.data && !Array.isArray(queued.data)) queued.data.id = serverId;
4796
+ dirty = true;
4797
+ }
4798
+ const rollbackRows = queued.rollback?.rows;
4799
+ if (rollbackRows && oldKey in rollbackRows) {
4800
+ rollbackRows[String(serverId)] = rollbackRows[oldKey];
4801
+ delete rollbackRows[oldKey];
4802
+ dirty = true;
4803
+ }
4804
+ if (dirty) await this.store.enqueue(this.queueKey(queued), queued).catch(() => void 0);
4805
+ }
4806
+ }
4807
+ await this.ingestReplaced(op, serverId ?? localId, row);
4808
+ }
4809
+ /**
4810
+ * Write a server row over the local one, ignoring the mutation that just
4811
+ * produced it — re-applying that would put the pre-server values back on
4812
+ * top of the server's answer — but keeping every write queued *after* it.
4813
+ * Those are still unsent, and dropping them here would make the row snap
4814
+ * back to the server's version in front of the user, only to change again
4815
+ * when they replay a moment later.
4816
+ */
4817
+ async ingestReplaced(op, id, row) {
4818
+ const slug = op.collection;
4819
+ const state = await this.ensureCollection(slug);
4820
+ const key = String(id);
4821
+ const merged = this.applyPendingToRow(slug, key, { ...row }, op.mutationId);
4822
+ if (merged === void 0) {
4823
+ this.removeLocalRow(slug, key);
4824
+ return;
4825
+ }
4826
+ const cachedAt = Date.now();
4827
+ state.rows.set(key, {
4828
+ row: merged,
4829
+ cachedAt,
4830
+ rev: ++this.revCounter
4831
+ });
4832
+ if (this.applyPendingToRow(slug, key, void 0, op.mutationId) === void 0) state.freshRows.add(key);
4833
+ this.writeCache(this.rowKey(slug, key), dehydrateRow(merged), cachedAt);
4834
+ }
4835
+ /**
4836
+ * The server refused a mutation. Put back what it changed, and discard the
4837
+ * queued writes that were built on top of it: an edit to a row whose
4838
+ * creation was rejected can only fail the same way, and applying it would
4839
+ * leave the local database claiming a row the server does not have.
4840
+ *
4841
+ * The cascade stops the moment a later write stops *depending* on the
4842
+ * rejected one. An `update` reads the row it edits, so it is doomed with
4843
+ * it; a `create` overwrites the row outright and a `delete` needs nothing
4844
+ * of it, so both stand on their own and are kept — dropping them would
4845
+ * silently lose writes the server would have accepted.
4846
+ */
4847
+ async rejectMutation(op, error) {
4848
+ const ids = new Set(Object.keys(op.rollback?.rows ?? {}));
4849
+ if (op.id !== void 0) ids.add(String(op.id));
4850
+ const doomed = [op];
4851
+ const orphaned = new Set(ids);
4852
+ const position = this.queue.indexOf(op);
4853
+ for (const later of this.queue.slice(position + 1)) {
4854
+ if (later.collection !== op.collection) continue;
4855
+ const hit = this.idsOf(later).filter((id) => orphaned.has(id));
4856
+ if (hit.length === 0) continue;
4857
+ if (later.type === "update") doomed.push(later);
4858
+ else for (const id of hit) orphaned.delete(id);
4859
+ }
4860
+ for (const dropped of doomed) await this.drop(dropped);
4861
+ for (const [idKey, previous] of Object.entries(op.rollback?.rows ?? {})) {
4862
+ const restored = this.applyPendingToRow(op.collection, idKey, previous ?? void 0);
4863
+ if (restored === void 0) this.removeLocalRow(op.collection, idKey);
4864
+ else this.setLocalRow(op.collection, idKey, restored);
4865
+ }
4866
+ this.patchStatus({ lastError: error.message });
4867
+ this.notifyCollection(op.collection);
4868
+ this.scheduleRefresh(op.collection);
4869
+ for (const dropped of doomed) this.onSyncError?.(error, dropped);
4870
+ }
4871
+ /** Every row id a mutation writes to. */
4872
+ idsOf(op) {
4873
+ if (op.type === "createMany") return (op.data ?? []).map((r) => String(r.id));
4874
+ return op.id === void 0 ? [] : [String(op.id)];
4875
+ }
4876
+ async drop(op) {
4877
+ await this.store.dequeue(this.queueKey(op)).catch(() => void 0);
4878
+ this.queue = this.queue.filter((m) => m.mutationId !== op.mutationId);
4879
+ this.afterQueueChange(false);
4880
+ }
4881
+ /** Replay uses unwrapped clients: a failure must never re-enqueue itself. */
4882
+ innerFor(slug) {
4883
+ let inner = this.inners.get(slug);
4884
+ if (!inner) {
4885
+ inner = this.createInner(slug);
4886
+ this.inners.set(slug, inner);
4887
+ }
4888
+ return inner;
4889
+ }
4890
+ async withLock(fn) {
4891
+ const locks = globalThis.navigator?.locks;
4892
+ if (!locks?.request) return fn();
4893
+ try {
4894
+ return await locks.request(`rebase-offline-sync:${this.scope}`, fn);
4895
+ } catch {
4896
+ return fn();
4897
+ }
4898
+ }
4899
+ broadcast(message) {
4900
+ if (!this.channel) return;
4901
+ try {
4902
+ this.channel.postMessage({
4903
+ ...message,
4904
+ scope: this.scope,
4905
+ sender: this.tabId
4906
+ });
4907
+ } catch {}
4908
+ }
4909
+ onBroadcast(message) {
4910
+ if (this.disposed || !message || typeof message !== "object") return;
4911
+ const msg = message;
4912
+ if (msg.sender === this.tabId || msg.scope !== this.scope) return;
4913
+ if (msg.type === "rows") for (const slug of msg.slugs ?? []) this.reloadCollection(slug);
4914
+ else if (msg.type === "queue") this.reloadQueue();
4915
+ }
4916
+ /** Re-read one collection from the store, replacing what is in memory. */
4917
+ async reloadCollection(slug) {
4918
+ const state = this.collections.get(slug);
4919
+ if (!state?.loaded) return;
4920
+ await this.reloadQueue();
4921
+ const scope = this.scope;
4922
+ const [rows, snapshots, absent] = await Promise.all([
4923
+ this.store.listCacheEntries(`${scope}|row|${slug}|`).catch(() => []),
4924
+ this.store.listCacheEntries(`${scope}|q|${slug}|`).catch(() => []),
4925
+ this.store.listCache(`${scope}|abs|${slug}|`).catch(() => [])
4926
+ ]);
4927
+ if (this.scope !== scope || this.collections.get(slug) !== state) return;
4928
+ const next = /* @__PURE__ */ new Map();
4929
+ for (const entry of rows) {
4930
+ const row = entry.value;
4931
+ if (!row || row.id === void 0 || row.id === null) continue;
4932
+ const key = String(row.id);
4933
+ const existing = state.rows.get(key);
4934
+ const hydrated = hydrateRow(row);
4935
+ const unchanged = existing && JSON.stringify(existing.row) === JSON.stringify(hydrated);
4936
+ next.set(key, {
4937
+ row: hydrated,
4938
+ cachedAt: entry.cachedAt,
4939
+ rev: unchanged ? existing.rev : ++this.revCounter
4940
+ });
4941
+ }
4942
+ state.rows = next;
4943
+ state.snapshots = /* @__PURE__ */ new Map();
4944
+ for (const entry of snapshots) {
4945
+ const key = entry.key.slice(`${scope}|q|${slug}|`.length);
4946
+ if (entry.value) state.snapshots.set(key, entry.value);
4947
+ }
4948
+ state.absent = new Set(absent.map((entry) => entry.key.slice(`${scope}|abs|${slug}|`.length)));
4949
+ this.notifyCollection(slug, false);
4950
+ }
4951
+ async reloadQueue() {
4952
+ const scope = this.scope;
4953
+ const queue = await this.store.listQueue(`${scope}|`).catch(() => void 0);
4954
+ if (!queue || this.scope !== scope) return;
4955
+ this.queue = queue;
4956
+ this.afterQueueChange(false);
4957
+ }
4958
+ afterQueueChange(broadcast = true) {
4959
+ this.patchStatus({ pending: this.queue.length });
4960
+ this.notifyQueue();
4961
+ if (broadcast) this.broadcast({ type: "queue" });
4962
+ }
4963
+ notifyQueue() {
4964
+ for (const listener of this.queueListeners) listener(this.queue.length);
4965
+ }
4966
+ patchStatus(patch) {
4967
+ let changed = false;
4968
+ for (const [key, value] of Object.entries(patch)) if (this.currentStatus[key] !== value) {
4969
+ this.currentStatus[key] = value;
4970
+ changed = true;
4971
+ }
4972
+ if (!changed) return;
4973
+ const snapshot = { ...this.currentStatus };
4974
+ for (const listener of this.statusListeners) listener(snapshot);
4975
+ }
4976
+ countKey(slug, params) {
4977
+ return `${this.scope}|count|${slug}|${buildQueryString(params)}`;
4978
+ }
4979
+ rowKey(slug, id) {
4980
+ return `${this.scope}|row|${slug}|${String(id)}`;
4981
+ }
4982
+ absentKey(slug, id) {
4983
+ return `${this.scope}|abs|${slug}|${String(id)}`;
4984
+ }
4985
+ queueKey(mutation) {
4986
+ return `${this.scope}|${mutation.mutationId}`;
4987
+ }
4988
+ async readCache(key) {
4989
+ try {
4990
+ return (await this.store.getCache(key))?.value;
4991
+ } catch {
4992
+ return;
4993
+ }
4994
+ }
4995
+ async writeCache(key, value, cachedAt = Date.now()) {
4996
+ try {
4997
+ await this.store.setCache(key, {
4998
+ value,
4999
+ cachedAt
5000
+ });
5001
+ } catch {}
5002
+ }
5003
+ async deleteCache(keys) {
5004
+ try {
5005
+ await this.store.deleteCache(keys);
5006
+ } catch {}
5007
+ }
5008
+ };
5009
+ //#endregion
2958
5010
  //#region src/index.ts
2959
5011
  /**
2960
5012
  * Derive a WebSocket URL from an HTTP base URL.
@@ -3017,14 +5069,7 @@ function createRebaseClient(options) {
3017
5069
  } catch (e) {}
3018
5070
  return session?.accessToken || options.token || "";
3019
5071
  },
3020
- onUnauthorized: options.onUnauthorized || (async () => {
3021
- try {
3022
- await auth.refreshSession();
3023
- return true;
3024
- } catch (e) {
3025
- return false;
3026
- }
3027
- })
5072
+ onUnauthorized: options.onUnauthorized || (() => auth.handleUnauthorized())
3028
5073
  });
3029
5074
  auth.onAuthStateChange((event, session) => {
3030
5075
  if (!ws) return;
@@ -3034,14 +5079,7 @@ function createRebaseClient(options) {
3034
5079
  }
3035
5080
  });
3036
5081
  }
3037
- if (!options.onUnauthorized) transport.setOnUnauthorized(async () => {
3038
- try {
3039
- await auth.refreshSession();
3040
- return true;
3041
- } catch (e) {
3042
- return false;
3043
- }
3044
- });
5082
+ if (!options.onUnauthorized) transport.setOnUnauthorized(() => auth.handleUnauthorized());
3045
5083
  /**
3046
5084
  * Suggest the closest known collection key for a mistyped accessor.
3047
5085
  * Uses edit-distance-1 and prefix matching — no external dependency.
@@ -3079,10 +5117,20 @@ function createRebaseClient(options) {
3079
5117
  if (diffs <= 1) return key;
3080
5118
  }
3081
5119
  }
5120
+ const offlineManager = options.offline ? new OfflineManager(typeof options.offline === "object" ? options.offline : {}, (slug) => createCollectionClient(transport, slug)) : void 0;
5121
+ if (offlineManager) {
5122
+ offlineManager.setScope(auth.getSession()?.user?.uid);
5123
+ auth.onAuthStateChange((event, session) => {
5124
+ offlineManager.setScope(event === "SIGNED_OUT" ? void 0 : session?.user?.uid);
5125
+ });
5126
+ }
3082
5127
  const collectionClients = /* @__PURE__ */ new Map();
3083
5128
  let untypedWarned = false;
3084
5129
  function collection(slug) {
3085
- if (!collectionClients.has(slug)) collectionClients.set(slug, createCollectionClient(transport, slug, ws));
5130
+ if (!collectionClients.has(slug)) {
5131
+ const inner = createCollectionClient(transport, slug, ws);
5132
+ collectionClients.set(slug, offlineManager ? offlineManager.wrap(slug, inner) : inner);
5133
+ }
3086
5134
  return collectionClients.get(slug);
3087
5135
  }
3088
5136
  const dataProxy = new Proxy({ collection }, { get(_target, prop) {
@@ -3146,6 +5194,7 @@ channel: (name, options) => {
3146
5194
  for (const channel of realtimeChannels.values()) channel.leave();
3147
5195
  realtimeChannels.clear();
3148
5196
  ws?.disconnect(true);
5197
+ offlineManager?.dispose();
3149
5198
  },
3150
5199
  setToken: transport.setToken,
3151
5200
  setAuthTokenGetter: transport.setAuthTokenGetter,
@@ -3161,10 +5210,11 @@ channel: (name, options) => {
3161
5210
  });
3162
5211
  return res.data ?? res;
3163
5212
  },
3164
- data: dataProxy
5213
+ data: dataProxy,
5214
+ ...offlineManager ? { offline: offlineManager.api } : {}
3165
5215
  };
3166
5216
  }
3167
5217
  //#endregion
3168
- export { QueryBuilder, RebaseApiError, RebaseClientError, RebaseRealtimeChannel, RebaseWebSocketClient, and, cond, createBackups, createCookieStorage, createMemoryStorage, createRebaseClient, or };
5218
+ export { MemoryOfflineStore, QueryBuilder, RebaseApiError, RebaseClientError, RebaseRealtimeChannel, RebaseWebSocketClient, and, cond, createBackups, createCookieStorage, createMemoryStorage, createRebaseClient, isOfflineError, or };
3169
5219
 
3170
5220
  //# sourceMappingURL=index.es.js.map