@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 +15 -0
- package/dist/index.es.js +591 -3
- package/dist/index.es.js.map +1 -1
- package/dist/offline-store.d.ts +109 -0
- package/dist/offline.d.ts +129 -0
- package/package.json +5 -4
- package/src/index.ts +47 -1
- package/src/offline-idb-store.test.ts +151 -0
- package/src/offline-integration.test.ts +134 -0
- package/src/offline-store.ts +232 -0
- package/src/offline.test.ts +688 -0
- package/src/offline.ts +621 -0
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))
|
|
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
|