@rebasepro/client 0.10.0 → 0.10.1-canary.a54c057

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.d.ts CHANGED
@@ -8,6 +8,7 @@ import { CollectionClient } from "./collection";
8
8
  import { createFunctionsClient } from "./functions";
9
9
  import { RebaseWebSocketClient } from "./websocket";
10
10
  import { RebaseRealtimeChannel, type ChannelOptions } from "./realtime-channel";
11
+ import { type OfflineApi, type OfflineConfig } from "./offline";
11
12
  import { InsertOf, RebaseClient, RebaseSdkData, RowOf, StorageSource, StorageSourceDefinition, StorageSourceRegistry, UpdateOf } from "@rebasepro/types";
12
13
  export { RebaseApiError } from "./transport";
13
14
  export { RebaseClientError } from "./errors";
@@ -30,6 +31,9 @@ export type { FunctionInvokeOptions, FunctionsClient } from "./functions";
30
31
  export { RebaseWebSocketClient } from "./websocket";
31
32
  export { RebaseRealtimeChannel } from "./realtime-channel";
32
33
  export type { PresenceState, PresenceDiff, BroadcastEvent, ChannelTransport, ChannelOptions, ChannelHistoryEntry, ChannelHistoryResult } from "./realtime-channel";
34
+ export type { OfflineApi, OfflineConfig } from "./offline";
35
+ export type { OfflineStore, OfflineCacheEntry, PendingMutation } from "./offline-store";
36
+ export { MemoryOfflineStore } from "./offline-store";
33
37
  export interface CreateRebaseClientOptions extends RebaseClientConfig {
34
38
  auth?: CreateAuthOptions;
35
39
  admin?: CreateAdminOptions;
@@ -50,6 +54,15 @@ export interface CreateRebaseClientOptions extends RebaseClientConfig {
50
54
  * correct slugs via this map before falling back to automatic snake_casing.
51
55
  */
52
56
  collections?: Record<string, string>;
57
+ /**
58
+ * Offline support for the data layer. `true` enables it with defaults
59
+ * (network-first reads served from cache on network failure, writes
60
+ * queued and replayed when connectivity returns); pass an
61
+ * {@link OfflineConfig} to control the store, cache size, or sync error
62
+ * handling. Cached reads and queued writes are partitioned per signed-in
63
+ * user. Off by default.
64
+ */
65
+ offline?: boolean | OfflineConfig;
53
66
  }
54
67
  type KebabToCamelCase<S extends string> = S extends `${infer T}-${infer U}` ? `${T}${Capitalize<KebabToCamelCase<U>>}` : S;
55
68
  type DBEntry<DB, S extends string> = KebabToCamelCase<S> extends keyof DB ? DB[KebabToCamelCase<S>] : unknown;
@@ -110,5 +123,7 @@ export type CreateRebaseClientResult<DB = Record<string, unknown>> = Omit<Rebase
110
123
  call: <T = unknown>(endpoint: string, payload?: unknown) => Promise<T>;
111
124
  collection: <M extends Record<string, unknown> = Record<string, unknown>>(slug: string) => CollectionClient<M>;
112
125
  data: TypedDataLayer<DB>;
126
+ /** Present only when the client was created with `offline` enabled. */
127
+ offline?: OfflineApi;
113
128
  };
114
129
  export declare function createRebaseClient<DB = Record<string, unknown>>(options: CreateRebaseClientOptions): CreateRebaseClientResult<DB>;
package/dist/index.es.js CHANGED
@@ -2955,6 +2955,582 @@ var RebaseRealtimeChannel = class {
2955
2955
  }
2956
2956
  };
2957
2957
  //#endregion
2958
+ //#region src/offline-store.ts
2959
+ /**
2960
+ * In-memory store: the default outside the browser and the workhorse of the
2961
+ * test suite. Values are deep-copied on the way in and out so a caller
2962
+ * mutating a returned row cannot silently edit the "persisted" copy — the
2963
+ * IndexedDB implementation gets the same guarantee for free from structured
2964
+ * cloning, and the two must not differ in aliasing behaviour.
2965
+ */
2966
+ var MemoryOfflineStore = class {
2967
+ cache = /* @__PURE__ */ new Map();
2968
+ queue = /* @__PURE__ */ new Map();
2969
+ async getCache(key) {
2970
+ const entry = this.cache.get(key);
2971
+ return entry ? structuredClone(entry) : void 0;
2972
+ }
2973
+ async setCache(key, entry) {
2974
+ this.cache.set(key, structuredClone(entry));
2975
+ }
2976
+ async deleteCache(keys) {
2977
+ for (const key of keys) this.cache.delete(key);
2978
+ }
2979
+ async listCache(prefix) {
2980
+ const out = [];
2981
+ for (const [key, entry] of this.cache) if (key.startsWith(prefix)) out.push({
2982
+ key,
2983
+ cachedAt: entry.cachedAt
2984
+ });
2985
+ return out;
2986
+ }
2987
+ async enqueue(key, mutation) {
2988
+ this.queue.set(key, structuredClone(mutation));
2989
+ }
2990
+ async dequeue(key) {
2991
+ this.queue.delete(key);
2992
+ }
2993
+ async listQueue(prefix) {
2994
+ return [...this.queue.entries()].filter(([key]) => key.startsWith(prefix)).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, mutation]) => structuredClone(mutation));
2995
+ }
2996
+ async clear(prefix) {
2997
+ for (const key of [...this.cache.keys()]) if (key.startsWith(prefix)) this.cache.delete(key);
2998
+ for (const key of [...this.queue.keys()]) if (key.startsWith(prefix)) this.queue.delete(key);
2999
+ }
3000
+ };
3001
+ var IDB_NAME = "rebase-offline";
3002
+ var IDB_VERSION = 1;
3003
+ var CACHE_STORE = "cache";
3004
+ var QUEUE_STORE = "queue";
3005
+ /** The exclusive upper bound of an IDBKeyRange covering every key under `prefix`. */
3006
+ function prefixRange(prefix) {
3007
+ return IDBKeyRange.bound(prefix, prefix + "￿", false, false);
3008
+ }
3009
+ function requestToPromise(request) {
3010
+ return new Promise((resolve, reject) => {
3011
+ request.onsuccess = () => resolve(request.result);
3012
+ request.onerror = () => reject(request.error ?? /* @__PURE__ */ new Error("IndexedDB request failed"));
3013
+ });
3014
+ }
3015
+ /**
3016
+ * IndexedDB-backed store — the browser default, so cached reads and queued
3017
+ * writes survive a reload or a browser restart. Everything lives in one
3018
+ * database with two object stores; keys are the manager's full prefixed
3019
+ * strings, so multiple users (scopes) share the database without ever
3020
+ * sharing entries.
3021
+ */
3022
+ var IndexedDBOfflineStore = class {
3023
+ dbPromise;
3024
+ open() {
3025
+ if (!this.dbPromise) this.dbPromise = new Promise((resolve, reject) => {
3026
+ const request = indexedDB.open(IDB_NAME, IDB_VERSION);
3027
+ request.onupgradeneeded = () => {
3028
+ const db = request.result;
3029
+ if (!db.objectStoreNames.contains(CACHE_STORE)) db.createObjectStore(CACHE_STORE);
3030
+ if (!db.objectStoreNames.contains(QUEUE_STORE)) db.createObjectStore(QUEUE_STORE);
3031
+ };
3032
+ request.onsuccess = () => resolve(request.result);
3033
+ request.onerror = () => {
3034
+ this.dbPromise = void 0;
3035
+ reject(request.error ?? /* @__PURE__ */ new Error("Failed to open IndexedDB"));
3036
+ };
3037
+ });
3038
+ return this.dbPromise;
3039
+ }
3040
+ async store(name, mode) {
3041
+ return (await this.open()).transaction(name, mode).objectStore(name);
3042
+ }
3043
+ async getCache(key) {
3044
+ return await requestToPromise((await this.store(CACHE_STORE, "readonly")).get(key));
3045
+ }
3046
+ async setCache(key, entry) {
3047
+ await requestToPromise((await this.store(CACHE_STORE, "readwrite")).put(entry, key));
3048
+ }
3049
+ async deleteCache(keys) {
3050
+ if (keys.length === 0) return;
3051
+ const store = await this.store(CACHE_STORE, "readwrite");
3052
+ await Promise.all(keys.map((key) => requestToPromise(store.delete(key))));
3053
+ }
3054
+ async listCache(prefix) {
3055
+ const store = await this.store(CACHE_STORE, "readonly");
3056
+ const [keys, entries] = await Promise.all([requestToPromise(store.getAllKeys(prefixRange(prefix))), requestToPromise(store.getAll(prefixRange(prefix)))]);
3057
+ return keys.map((key, i) => ({
3058
+ key: String(key),
3059
+ cachedAt: entries[i]?.cachedAt ?? 0
3060
+ }));
3061
+ }
3062
+ async enqueue(key, mutation) {
3063
+ await requestToPromise((await this.store(QUEUE_STORE, "readwrite")).put(mutation, key));
3064
+ }
3065
+ async dequeue(key) {
3066
+ await requestToPromise((await this.store(QUEUE_STORE, "readwrite")).delete(key));
3067
+ }
3068
+ async listQueue(prefix) {
3069
+ return await requestToPromise((await this.store(QUEUE_STORE, "readonly")).getAll(prefixRange(prefix)));
3070
+ }
3071
+ async clear(prefix) {
3072
+ await requestToPromise((await this.store(CACHE_STORE, "readwrite")).delete(prefixRange(prefix)));
3073
+ await requestToPromise((await this.store(QUEUE_STORE, "readwrite")).delete(prefixRange(prefix)));
3074
+ }
3075
+ };
3076
+ //#endregion
3077
+ //#region src/offline.ts
3078
+ /** True for "the request never reached the server", false for a server reply. */
3079
+ function isNetworkError(error) {
3080
+ if (error instanceof RebaseApiError) return false;
3081
+ return error instanceof TypeError;
3082
+ }
3083
+ function generateOfflineId() {
3084
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
3085
+ return `off-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
3086
+ }
3087
+ /** A find is "appendable" when queued creates provably belong in its result set. */
3088
+ function isAppendable(params) {
3089
+ return !params?.where && !params?.logical && !params?.searchString;
3090
+ }
3091
+ var OfflineManager = class {
3092
+ store;
3093
+ maxCachedQueries;
3094
+ onSyncError;
3095
+ syncIntervalMs;
3096
+ createInner;
3097
+ inners = /* @__PURE__ */ new Map();
3098
+ scope = "anon";
3099
+ /** In-memory mirror of the current scope's queue, kept in seq order. */
3100
+ queue = [];
3101
+ loadPromise;
3102
+ nextSeq = 1;
3103
+ /** Serializes enqueues so concurrent writes get distinct, ordered seqs. */
3104
+ enqueueChain = Promise.resolve();
3105
+ flushPromise;
3106
+ queueListeners = /* @__PURE__ */ new Set();
3107
+ syncTimer;
3108
+ onOnline = () => {
3109
+ this.sync().catch(() => void 0);
3110
+ };
3111
+ api;
3112
+ constructor(config, createInner) {
3113
+ this.store = config.store ?? (typeof indexedDB !== "undefined" ? new IndexedDBOfflineStore() : new MemoryOfflineStore());
3114
+ this.maxCachedQueries = config.maxCachedQueriesPerCollection ?? 50;
3115
+ this.syncIntervalMs = config.syncIntervalMs ?? 3e4;
3116
+ this.onSyncError = config.onSyncError;
3117
+ this.createInner = createInner;
3118
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") window.addEventListener("online", this.onOnline);
3119
+ this.api = {
3120
+ sync: () => this.sync(),
3121
+ pending: async () => {
3122
+ await this.ensureLoaded();
3123
+ return this.queue.map((m) => structuredClone(m));
3124
+ },
3125
+ clear: async () => {
3126
+ await this.store.clear(`${this.scope}|`);
3127
+ this.queue = [];
3128
+ this.notifyQueue();
3129
+ },
3130
+ onQueueChange: (listener) => {
3131
+ this.queueListeners.add(listener);
3132
+ return () => this.queueListeners.delete(listener);
3133
+ }
3134
+ };
3135
+ }
3136
+ /**
3137
+ * Cache and queue are partitioned per signed-in user: cached rows are
3138
+ * RLS-filtered for the user who fetched them, and queued writes must
3139
+ * replay under the credentials that made them — so neither may ever leak
3140
+ * across a sign-out/sign-in on a shared browser.
3141
+ */
3142
+ setScope(uid) {
3143
+ const next = uid || "anon";
3144
+ if (next === this.scope) return;
3145
+ this.scope = next;
3146
+ this.loadPromise = void 0;
3147
+ this.queue = [];
3148
+ this.sync().catch(() => void 0);
3149
+ }
3150
+ /** Release the online listener and retry timer (client.close()). */
3151
+ dispose() {
3152
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") window.removeEventListener("online", this.onOnline);
3153
+ this.stopTimer();
3154
+ }
3155
+ wrap(slug, inner) {
3156
+ this.inners.set(slug, inner);
3157
+ const wrapped = {
3158
+ find: async (params) => {
3159
+ try {
3160
+ const res = await inner.find(params);
3161
+ await this.cacheSet(this.findKey(slug, params), res);
3162
+ return await this.overlayFind(slug, params, res);
3163
+ } catch (error) {
3164
+ if (!isNetworkError(error)) throw error;
3165
+ const cached = await this.cacheGet(this.findKey(slug, params));
3166
+ if (cached === void 0) throw error;
3167
+ return this.overlayFind(slug, params, cached);
3168
+ }
3169
+ },
3170
+ findById: async (id) => {
3171
+ try {
3172
+ const row = await inner.findById(id);
3173
+ if (row !== void 0) await this.cacheSet(this.rowKey(slug, id), row);
3174
+ return await this.overlayRow(slug, id, row);
3175
+ } catch (error) {
3176
+ if (!isNetworkError(error)) throw error;
3177
+ const cached = await this.cacheGet(this.rowKey(slug, id));
3178
+ const { touched, row } = await this.composeRowFromQueue(slug, id, cached);
3179
+ if (!touched && cached === void 0) throw error;
3180
+ return touched ? row : cached;
3181
+ }
3182
+ },
3183
+ create: async (data, id) => {
3184
+ try {
3185
+ return await inner.create(data, id);
3186
+ } catch (error) {
3187
+ if (!isNetworkError(error)) throw error;
3188
+ const providedId = id ?? data.id;
3189
+ const rowId = providedId ?? generateOfflineId();
3190
+ const row = {
3191
+ ...data,
3192
+ id: rowId
3193
+ };
3194
+ await this.enqueue({
3195
+ collection: slug,
3196
+ type: "create",
3197
+ id: rowId,
3198
+ data: row,
3199
+ generatedId: providedId === void 0
3200
+ });
3201
+ await this.cacheSet(this.rowKey(slug, rowId), row);
3202
+ return row;
3203
+ }
3204
+ },
3205
+ createMany: async (data, options) => {
3206
+ try {
3207
+ return await inner.createMany(data, options);
3208
+ } catch (error) {
3209
+ if (!isNetworkError(error)) throw error;
3210
+ if (!Array.isArray(data) || data.length === 0) return [];
3211
+ const rows = data.map((r) => ({
3212
+ ...r,
3213
+ id: r.id ?? generateOfflineId()
3214
+ }));
3215
+ await this.enqueue({
3216
+ collection: slug,
3217
+ type: "createMany",
3218
+ data: rows,
3219
+ upsert: options?.upsert
3220
+ });
3221
+ for (const row of rows) await this.cacheSet(this.rowKey(slug, row.id), row);
3222
+ return rows;
3223
+ }
3224
+ },
3225
+ update: async (id, data) => {
3226
+ try {
3227
+ const row = await inner.update(id, data);
3228
+ await this.cacheSet(this.rowKey(slug, id), row);
3229
+ return row;
3230
+ } catch (error) {
3231
+ if (!isNetworkError(error)) throw error;
3232
+ await this.enqueue({
3233
+ collection: slug,
3234
+ type: "update",
3235
+ id,
3236
+ data
3237
+ });
3238
+ const optimistic = {
3239
+ ...await this.cacheGet(this.rowKey(slug, id)) ?? {},
3240
+ ...data,
3241
+ id
3242
+ };
3243
+ await this.cacheSet(this.rowKey(slug, id), optimistic);
3244
+ return optimistic;
3245
+ }
3246
+ },
3247
+ delete: async (id) => {
3248
+ try {
3249
+ await inner.delete(id);
3250
+ await this.cacheDelete(this.rowKey(slug, id));
3251
+ } catch (error) {
3252
+ if (!isNetworkError(error)) throw error;
3253
+ await this.enqueue({
3254
+ collection: slug,
3255
+ type: "delete",
3256
+ id
3257
+ });
3258
+ await this.cacheDelete(this.rowKey(slug, id));
3259
+ }
3260
+ },
3261
+ count: async (params) => {
3262
+ try {
3263
+ const n = await inner.count(params);
3264
+ await this.cacheSet(this.countKey(slug, params), n);
3265
+ return this.overlayCount(slug, params, n);
3266
+ } catch (error) {
3267
+ if (!isNetworkError(error)) throw error;
3268
+ const cached = await this.cacheGet(this.countKey(slug, params));
3269
+ if (cached === void 0) throw error;
3270
+ return this.overlayCount(slug, params, cached);
3271
+ }
3272
+ },
3273
+ where(columnOrCondition, operator, value) {
3274
+ const builder = new SDKQueryBuilder(wrapped);
3275
+ if (typeof columnOrCondition === "object") return builder.where(columnOrCondition);
3276
+ return builder.where(columnOrCondition, operator, value);
3277
+ },
3278
+ orderBy: (column, direction) => new SDKQueryBuilder(wrapped).orderBy(column, direction),
3279
+ limit: (count) => new SDKQueryBuilder(wrapped).limit(count),
3280
+ offset: (count) => new SDKQueryBuilder(wrapped).offset(count),
3281
+ search: (searchString) => new SDKQueryBuilder(wrapped).search(searchString),
3282
+ include: (...relations) => new SDKQueryBuilder(wrapped).include(...relations)
3283
+ };
3284
+ if (inner.listen) wrapped.listen = inner.listen.bind(inner);
3285
+ if (inner.listenById) wrapped.listenById = inner.listenById.bind(inner);
3286
+ return wrapped;
3287
+ }
3288
+ async pendingFor(slug) {
3289
+ await this.ensureLoaded();
3290
+ return this.queue.filter((m) => m.collection === slug);
3291
+ }
3292
+ async overlayFind(slug, params, result) {
3293
+ const ops = await this.pendingFor(slug);
3294
+ if (ops.length === 0) return result;
3295
+ const appendable = isAppendable(params);
3296
+ let rows = result.data.map((r) => ({ ...r }));
3297
+ let total = result.meta.total;
3298
+ const applyCreate = (row) => {
3299
+ if (!appendable) return;
3300
+ if (rows.some((r) => r.id === row.id)) return;
3301
+ rows.push({ ...row });
3302
+ total++;
3303
+ };
3304
+ for (const op of ops) if (op.type === "create") applyCreate(op.data);
3305
+ else if (op.type === "createMany") for (const row of op.data) applyCreate(row);
3306
+ else if (op.type === "update") {
3307
+ const idx = rows.findIndex((r) => r.id === op.id);
3308
+ if (idx >= 0) rows[idx] = {
3309
+ ...rows[idx],
3310
+ ...op.data
3311
+ };
3312
+ } else if (op.type === "delete") {
3313
+ const before = rows.length;
3314
+ rows = rows.filter((r) => r.id !== op.id);
3315
+ if (rows.length < before) total = Math.max(0, total - 1);
3316
+ }
3317
+ return {
3318
+ data: rows,
3319
+ meta: {
3320
+ ...result.meta,
3321
+ total
3322
+ }
3323
+ };
3324
+ }
3325
+ async overlayCount(slug, params, count) {
3326
+ const ops = await this.pendingFor(slug);
3327
+ if (ops.length === 0 || !isAppendable(params)) return count;
3328
+ let n = count;
3329
+ for (const op of ops) if (op.type === "create") n++;
3330
+ else if (op.type === "createMany") n += op.data.length;
3331
+ else if (op.type === "delete") n = Math.max(0, n - 1);
3332
+ return n;
3333
+ }
3334
+ /**
3335
+ * Fold the queued ops for one row into a base value. `touched` separates
3336
+ * "the queue says this row does not exist" (a pending delete → undefined)
3337
+ * from "the queue has nothing to say" — the caller falls back differently.
3338
+ */
3339
+ async composeRowFromQueue(slug, id, base) {
3340
+ const ops = await this.pendingFor(slug);
3341
+ let touched = false;
3342
+ let row = base;
3343
+ for (const op of ops) if (op.type === "create" && op.id === id) {
3344
+ row = { ...op.data };
3345
+ touched = true;
3346
+ } else if (op.type === "createMany") {
3347
+ const match = op.data.find((r) => r.id === id);
3348
+ if (match) {
3349
+ row = { ...match };
3350
+ touched = true;
3351
+ }
3352
+ } else if (op.type === "update" && op.id === id) {
3353
+ row = {
3354
+ ...row ?? {},
3355
+ ...op.data,
3356
+ id
3357
+ };
3358
+ touched = true;
3359
+ } else if (op.type === "delete" && op.id === id) {
3360
+ row = void 0;
3361
+ touched = true;
3362
+ }
3363
+ return {
3364
+ touched,
3365
+ row
3366
+ };
3367
+ }
3368
+ async overlayRow(slug, id, base) {
3369
+ const { touched, row } = await this.composeRowFromQueue(slug, id, base);
3370
+ return touched ? row : base;
3371
+ }
3372
+ ensureLoaded() {
3373
+ if (!this.loadPromise) {
3374
+ const scope = this.scope;
3375
+ this.loadPromise = this.store.listQueue(`${scope}|`).then((queue) => {
3376
+ if (this.scope !== scope) return;
3377
+ this.queue = queue;
3378
+ this.nextSeq = queue.reduce((max, m) => Math.max(max, m.seq), 0) + 1;
3379
+ if (queue.length > 0) this.startTimer();
3380
+ this.notifyQueue();
3381
+ });
3382
+ }
3383
+ return this.loadPromise;
3384
+ }
3385
+ enqueue(mutation) {
3386
+ const result = this.enqueueChain.then(async () => {
3387
+ await this.ensureLoaded();
3388
+ if (mutation.type === "update") {
3389
+ const tail = this.queue[this.queue.length - 1];
3390
+ if (tail && tail.collection === mutation.collection && (tail.type === "create" || tail.type === "update") && tail.id === mutation.id) {
3391
+ tail.data = {
3392
+ ...tail.data,
3393
+ ...mutation.data,
3394
+ id: tail.id
3395
+ };
3396
+ await this.store.enqueue(this.queueKey(tail.seq), tail);
3397
+ return;
3398
+ }
3399
+ }
3400
+ if (mutation.type === "delete") {
3401
+ if (this.queue.some((m) => m.collection === mutation.collection && m.type === "create" && m.id === mutation.id && m.generatedId === true)) {
3402
+ const doomed = this.queue.filter((m) => m.collection === mutation.collection && m.id === mutation.id && (m.type === "create" || m.type === "update"));
3403
+ for (const op of doomed) await this.store.dequeue(this.queueKey(op.seq));
3404
+ this.queue = this.queue.filter((m) => !doomed.includes(m));
3405
+ this.notifyQueue();
3406
+ return;
3407
+ }
3408
+ }
3409
+ const full = {
3410
+ ...mutation,
3411
+ seq: this.nextSeq++,
3412
+ queuedAt: Date.now()
3413
+ };
3414
+ await this.store.enqueue(this.queueKey(full.seq), full);
3415
+ this.queue.push(full);
3416
+ this.startTimer();
3417
+ this.notifyQueue();
3418
+ });
3419
+ this.enqueueChain = result.catch(() => void 0);
3420
+ return result;
3421
+ }
3422
+ sync() {
3423
+ if (this.flushPromise) return this.flushPromise;
3424
+ this.flushPromise = (async () => {
3425
+ await this.ensureLoaded();
3426
+ let flushed = 0;
3427
+ while (this.queue.length > 0) {
3428
+ const op = this.queue[0];
3429
+ try {
3430
+ await this.replay(op);
3431
+ } catch (error) {
3432
+ if (isNetworkError(error)) break;
3433
+ await this.drop(op);
3434
+ this.onSyncError?.(error, op);
3435
+ continue;
3436
+ }
3437
+ await this.drop(op);
3438
+ flushed++;
3439
+ }
3440
+ if (this.queue.length === 0) this.stopTimer();
3441
+ return {
3442
+ flushed,
3443
+ remaining: this.queue.length
3444
+ };
3445
+ })().finally(() => {
3446
+ this.flushPromise = void 0;
3447
+ });
3448
+ return this.flushPromise;
3449
+ }
3450
+ async replay(op) {
3451
+ const inner = this.innerFor(op.collection);
3452
+ if (op.type === "create") {
3453
+ const row = await inner.create(op.data);
3454
+ await this.cacheSet(this.rowKey(op.collection, row.id ?? op.id), row);
3455
+ } else if (op.type === "createMany") await inner.createMany(op.data, op.upsert ? { upsert: true } : void 0);
3456
+ else if (op.type === "update") {
3457
+ const row = await inner.update(op.id, op.data);
3458
+ await this.cacheSet(this.rowKey(op.collection, op.id), row);
3459
+ } else if (op.type === "delete") await inner.delete(op.id);
3460
+ }
3461
+ async drop(op) {
3462
+ await this.store.dequeue(this.queueKey(op.seq));
3463
+ this.queue = this.queue.filter((m) => m.seq !== op.seq);
3464
+ this.notifyQueue();
3465
+ }
3466
+ /** Replay uses unwrapped clients: a failure must never re-enqueue itself. */
3467
+ innerFor(slug) {
3468
+ let inner = this.inners.get(slug);
3469
+ if (!inner) {
3470
+ inner = this.createInner(slug);
3471
+ this.inners.set(slug, inner);
3472
+ }
3473
+ return inner;
3474
+ }
3475
+ notifyQueue() {
3476
+ for (const listener of this.queueListeners) listener(this.queue.length);
3477
+ }
3478
+ startTimer() {
3479
+ if (this.syncTimer || this.syncIntervalMs <= 0) return;
3480
+ this.syncTimer = setInterval(() => {
3481
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return;
3482
+ this.sync().catch(() => void 0);
3483
+ }, this.syncIntervalMs);
3484
+ this.syncTimer.unref?.();
3485
+ }
3486
+ stopTimer() {
3487
+ if (this.syncTimer) {
3488
+ clearInterval(this.syncTimer);
3489
+ this.syncTimer = void 0;
3490
+ }
3491
+ }
3492
+ findKey(slug, params) {
3493
+ return `${this.scope}|find|${slug}|${buildQueryString(params)}`;
3494
+ }
3495
+ countKey(slug, params) {
3496
+ return `${this.scope}|count|${slug}|${buildQueryString(params)}`;
3497
+ }
3498
+ rowKey(slug, id) {
3499
+ return `${this.scope}|row|${slug}|${String(id)}`;
3500
+ }
3501
+ queueKey(seq) {
3502
+ return `${this.scope}|${String(seq).padStart(16, "0")}`;
3503
+ }
3504
+ async cacheGet(key) {
3505
+ try {
3506
+ return (await this.store.getCache(key))?.value;
3507
+ } catch {
3508
+ return;
3509
+ }
3510
+ }
3511
+ async cacheSet(key, value) {
3512
+ try {
3513
+ await this.store.setCache(key, {
3514
+ value,
3515
+ cachedAt: Date.now()
3516
+ });
3517
+ if (key.startsWith(`${this.scope}|find|`)) {
3518
+ const bucket = key.slice(0, key.lastIndexOf("|") + 1);
3519
+ const entries = await this.store.listCache(bucket);
3520
+ if (entries.length > this.maxCachedQueries) {
3521
+ entries.sort((a, b) => a.cachedAt - b.cachedAt);
3522
+ await this.store.deleteCache(entries.slice(0, entries.length - this.maxCachedQueries).map((e) => e.key));
3523
+ }
3524
+ }
3525
+ } catch {}
3526
+ }
3527
+ async cacheDelete(key) {
3528
+ try {
3529
+ await this.store.deleteCache([key]);
3530
+ } catch {}
3531
+ }
3532
+ };
3533
+ //#endregion
2958
3534
  //#region src/index.ts
2959
3535
  /**
2960
3536
  * Derive a WebSocket URL from an HTTP base URL.
@@ -3079,10 +3655,20 @@ function createRebaseClient(options) {
3079
3655
  if (diffs <= 1) return key;
3080
3656
  }
3081
3657
  }
3658
+ const offlineManager = options.offline ? new OfflineManager(typeof options.offline === "object" ? options.offline : {}, (slug) => createCollectionClient(transport, slug)) : void 0;
3659
+ if (offlineManager) {
3660
+ offlineManager.setScope(auth.getSession()?.user?.uid);
3661
+ auth.onAuthStateChange((event, session) => {
3662
+ offlineManager.setScope(event === "SIGNED_OUT" ? void 0 : session?.user?.uid);
3663
+ });
3664
+ }
3082
3665
  const collectionClients = /* @__PURE__ */ new Map();
3083
3666
  let untypedWarned = false;
3084
3667
  function collection(slug) {
3085
- if (!collectionClients.has(slug)) collectionClients.set(slug, createCollectionClient(transport, slug, ws));
3668
+ if (!collectionClients.has(slug)) {
3669
+ const inner = createCollectionClient(transport, slug, ws);
3670
+ collectionClients.set(slug, offlineManager ? offlineManager.wrap(slug, inner) : inner);
3671
+ }
3086
3672
  return collectionClients.get(slug);
3087
3673
  }
3088
3674
  const dataProxy = new Proxy({ collection }, { get(_target, prop) {
@@ -3146,6 +3732,7 @@ channel: (name, options) => {
3146
3732
  for (const channel of realtimeChannels.values()) channel.leave();
3147
3733
  realtimeChannels.clear();
3148
3734
  ws?.disconnect(true);
3735
+ offlineManager?.dispose();
3149
3736
  },
3150
3737
  setToken: transport.setToken,
3151
3738
  setAuthTokenGetter: transport.setAuthTokenGetter,
@@ -3161,10 +3748,11 @@ channel: (name, options) => {
3161
3748
  });
3162
3749
  return res.data ?? res;
3163
3750
  },
3164
- data: dataProxy
3751
+ data: dataProxy,
3752
+ ...offlineManager ? { offline: offlineManager.api } : {}
3165
3753
  };
3166
3754
  }
3167
3755
  //#endregion
3168
- export { QueryBuilder, RebaseApiError, RebaseClientError, RebaseRealtimeChannel, RebaseWebSocketClient, and, cond, createBackups, createCookieStorage, createMemoryStorage, createRebaseClient, or };
3756
+ export { MemoryOfflineStore, QueryBuilder, RebaseApiError, RebaseClientError, RebaseRealtimeChannel, RebaseWebSocketClient, and, cond, createBackups, createCookieStorage, createMemoryStorage, createRebaseClient, or };
3169
3757
 
3170
3758
  //# sourceMappingURL=index.es.js.map