@abraca/dabra 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/abracadabra-provider.cjs +93 -17
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +93 -17
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +65 -7
- package/package.json +1 -1
- package/src/AbracadabraProvider.ts +118 -26
- package/src/OfflineStore.ts +52 -8
|
@@ -1961,18 +1961,19 @@ var HocuspocusProvider = class extends EventEmitter {
|
|
|
1961
1961
|
|
|
1962
1962
|
//#endregion
|
|
1963
1963
|
//#region packages/provider/src/OfflineStore.ts
|
|
1964
|
-
const DB_VERSION =
|
|
1964
|
+
const DB_VERSION = 2;
|
|
1965
1965
|
function idbAvailable() {
|
|
1966
1966
|
return typeof globalThis !== "undefined" && "indexedDB" in globalThis;
|
|
1967
1967
|
}
|
|
1968
|
-
function openDb$1(
|
|
1968
|
+
function openDb$1(storeKey) {
|
|
1969
1969
|
return new Promise((resolve, reject) => {
|
|
1970
|
-
const req = globalThis.indexedDB.open(`abracadabra:${
|
|
1970
|
+
const req = globalThis.indexedDB.open(`abracadabra:${storeKey}`, DB_VERSION);
|
|
1971
1971
|
req.onupgradeneeded = (event) => {
|
|
1972
1972
|
const db = event.target.result;
|
|
1973
1973
|
if (!db.objectStoreNames.contains("updates")) db.createObjectStore("updates", { autoIncrement: true });
|
|
1974
1974
|
if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta");
|
|
1975
1975
|
if (!db.objectStoreNames.contains("subdoc_queue")) db.createObjectStore("subdoc_queue", { keyPath: "childId" });
|
|
1976
|
+
if (!db.objectStoreNames.contains("doc_state")) db.createObjectStore("doc_state");
|
|
1976
1977
|
};
|
|
1977
1978
|
req.onsuccess = () => resolve(req.result);
|
|
1978
1979
|
req.onerror = () => reject(req.error);
|
|
@@ -1985,13 +1986,19 @@ function txPromise(store, request) {
|
|
|
1985
1986
|
});
|
|
1986
1987
|
}
|
|
1987
1988
|
var OfflineStore = class {
|
|
1988
|
-
|
|
1989
|
+
/**
|
|
1990
|
+
* @param docId The document UUID.
|
|
1991
|
+
* @param serverOrigin Hostname of the server (e.g. "abra.cou.sh").
|
|
1992
|
+
* When provided the IndexedDB database is namespaced
|
|
1993
|
+
* per-server, preventing cross-server data contamination.
|
|
1994
|
+
*/
|
|
1995
|
+
constructor(docId, serverOrigin) {
|
|
1989
1996
|
this.db = null;
|
|
1990
|
-
this.
|
|
1997
|
+
this.storeKey = serverOrigin ? `${serverOrigin}/${docId}` : docId;
|
|
1991
1998
|
}
|
|
1992
1999
|
async getDb() {
|
|
1993
2000
|
if (!idbAvailable()) return null;
|
|
1994
|
-
if (!this.db) this.db = await openDb$1(this.
|
|
2001
|
+
if (!this.db) this.db = await openDb$1(this.storeKey).catch(() => null);
|
|
1995
2002
|
return this.db;
|
|
1996
2003
|
}
|
|
1997
2004
|
async persistUpdate(update) {
|
|
@@ -2015,6 +2022,25 @@ var OfflineStore = class {
|
|
|
2015
2022
|
const tx = db.transaction("updates", "readwrite");
|
|
2016
2023
|
await txPromise(tx.objectStore("updates"), tx.objectStore("updates").clear());
|
|
2017
2024
|
}
|
|
2025
|
+
/**
|
|
2026
|
+
* Persist a full Y.js state snapshot (Y.encodeStateAsUpdate output).
|
|
2027
|
+
* Replaces any previously stored snapshot.
|
|
2028
|
+
*/
|
|
2029
|
+
async saveDocSnapshot(snapshot) {
|
|
2030
|
+
const db = await this.getDb();
|
|
2031
|
+
if (!db) return;
|
|
2032
|
+
const tx = db.transaction("doc_state", "readwrite");
|
|
2033
|
+
await txPromise(tx.objectStore("doc_state"), tx.objectStore("doc_state").put(snapshot, "snapshot"));
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* Retrieve the stored full document snapshot, or null if none exists.
|
|
2037
|
+
*/
|
|
2038
|
+
async getDocSnapshot() {
|
|
2039
|
+
const db = await this.getDb();
|
|
2040
|
+
if (!db) return null;
|
|
2041
|
+
const tx = db.transaction("doc_state", "readonly");
|
|
2042
|
+
return await txPromise(tx.objectStore("doc_state"), tx.objectStore("doc_state").get("snapshot")) ?? null;
|
|
2043
|
+
}
|
|
2018
2044
|
async getStateVector() {
|
|
2019
2045
|
const db = await this.getDb();
|
|
2020
2046
|
if (!db) return null;
|
|
@@ -2108,10 +2134,16 @@ function isValidDocId(id) {
|
|
|
2108
2134
|
* own AbracadabraProvider instances sharing the same WebSocket connection.
|
|
2109
2135
|
*
|
|
2110
2136
|
* 2. Offline-first – persists CRDT updates to IndexedDB so they survive
|
|
2111
|
-
* page reloads and network outages. On
|
|
2112
|
-
*
|
|
2137
|
+
* page reloads and network outages. On startup, the last saved document
|
|
2138
|
+
* snapshot is applied immediately so the UI is usable without a server
|
|
2139
|
+
* connection. On reconnect, pending updates are flushed and a fresh
|
|
2140
|
+
* snapshot is saved.
|
|
2141
|
+
*
|
|
2142
|
+
* 3. Server-scoped storage – the IndexedDB database is keyed by server
|
|
2143
|
+
* hostname + docId, preventing cross-server data contamination when the
|
|
2144
|
+
* same docId is used on multiple servers.
|
|
2113
2145
|
*
|
|
2114
|
-
*
|
|
2146
|
+
* 4. Permission snapshotting – stores the resolved role locally so the UI
|
|
2115
2147
|
* can gate write operations without a network round-trip. Role is
|
|
2116
2148
|
* refreshed from the server on every reconnect.
|
|
2117
2149
|
*/
|
|
@@ -2131,12 +2163,41 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2131
2163
|
this._client = client;
|
|
2132
2164
|
this.abracadabraConfig = configuration;
|
|
2133
2165
|
this.subdocLoading = configuration.subdocLoading ?? "lazy";
|
|
2134
|
-
|
|
2166
|
+
const serverOrigin = AbracadabraProvider.deriveServerOrigin(configuration, client);
|
|
2167
|
+
this.offlineStore = configuration.disableOfflineStore ? null : new OfflineStore(configuration.name, serverOrigin);
|
|
2135
2168
|
this.on("subdocRegistered", configuration.onSubdocRegistered ?? (() => null));
|
|
2136
2169
|
this.on("subdocLoaded", configuration.onSubdocLoaded ?? (() => null));
|
|
2137
2170
|
this.document.on("subdocs", this.boundHandleYSubdocsChange);
|
|
2138
2171
|
this.on("synced", () => this.flushPendingUpdates());
|
|
2139
2172
|
this.restorePermissionSnapshot();
|
|
2173
|
+
this.ready = this._initFromOfflineStore();
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Extract the server hostname from the provider configuration.
|
|
2177
|
+
* Used to namespace the IndexedDB key so docs from different servers
|
|
2178
|
+
* never share the same database.
|
|
2179
|
+
*/
|
|
2180
|
+
static deriveServerOrigin(config, client) {
|
|
2181
|
+
try {
|
|
2182
|
+
const url = config.url ?? config.websocketProvider?.url ?? client?.wsUrl;
|
|
2183
|
+
if (url) return new URL(url).hostname;
|
|
2184
|
+
} catch {}
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* Load the stored document snapshot (and any pending local edits) from
|
|
2188
|
+
* IndexedDB and apply them to the Y.Doc so the UI can render without a
|
|
2189
|
+
* server connection.
|
|
2190
|
+
*
|
|
2191
|
+
* Uses `this.offlineStore` as the Y.js update origin so that
|
|
2192
|
+
* `documentUpdateHandler` ignores these replayed updates and does not
|
|
2193
|
+
* attempt to re-persist or re-send them.
|
|
2194
|
+
*/
|
|
2195
|
+
async _initFromOfflineStore() {
|
|
2196
|
+
if (!this.offlineStore) return;
|
|
2197
|
+
const snapshot = await this.offlineStore.getDocSnapshot().catch(() => null);
|
|
2198
|
+
if (snapshot) Y.applyUpdate(this.document, snapshot, this.offlineStore);
|
|
2199
|
+
const pending = await this.offlineStore.getPendingUpdates().catch(() => []);
|
|
2200
|
+
for (const update of pending) Y.applyUpdate(this.document, update, this.offlineStore);
|
|
2140
2201
|
}
|
|
2141
2202
|
authenticatedHandler(scope) {
|
|
2142
2203
|
super.authenticatedHandler(scope);
|
|
@@ -2274,7 +2335,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2274
2335
|
}
|
|
2275
2336
|
async _doLoadChild(childId) {
|
|
2276
2337
|
const childDoc = new Y.Doc({ guid: childId });
|
|
2277
|
-
await new Promise((resolve) => {
|
|
2338
|
+
if (this.isConnected) await new Promise((resolve) => {
|
|
2278
2339
|
const onRegistered = ({ childId: cid }) => {
|
|
2279
2340
|
if (cid === childId) {
|
|
2280
2341
|
this.off("subdocRegistered", onRegistered);
|
|
@@ -2285,6 +2346,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2285
2346
|
this.registerSubdoc(childDoc);
|
|
2286
2347
|
setTimeout(resolve, 3e3);
|
|
2287
2348
|
});
|
|
2349
|
+
else this.registerSubdoc(childDoc);
|
|
2288
2350
|
const childProvider = new AbracadabraProvider({
|
|
2289
2351
|
name: childId,
|
|
2290
2352
|
document: childDoc,
|
|
@@ -2317,31 +2379,45 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2317
2379
|
/**
|
|
2318
2380
|
* Override to persist every local update to IndexedDB before sending it
|
|
2319
2381
|
* over the wire, ensuring no work is lost during connection outages.
|
|
2382
|
+
*
|
|
2383
|
+
* Updates applied by the server (origin === this) and updates replayed
|
|
2384
|
+
* from the offline store (origin === this.offlineStore) are both skipped
|
|
2385
|
+
* to avoid loops.
|
|
2320
2386
|
*/
|
|
2321
2387
|
documentUpdateHandler(update, origin) {
|
|
2322
2388
|
if (origin === this) return;
|
|
2389
|
+
if (this.offlineStore !== null && origin === this.offlineStore) return;
|
|
2323
2390
|
this.offlineStore?.persistUpdate(update).catch(() => null);
|
|
2324
2391
|
super.documentUpdateHandler(update, origin);
|
|
2325
2392
|
}
|
|
2326
2393
|
/**
|
|
2327
|
-
* After reconnect + sync
|
|
2328
|
-
*
|
|
2394
|
+
* After reconnect + sync:
|
|
2395
|
+
* 1. Flush any updates that were generated while offline.
|
|
2396
|
+
* 2. Flush any queued subdoc registrations.
|
|
2397
|
+
* 3. Save a fresh full document snapshot for the next offline session.
|
|
2398
|
+
*
|
|
2399
|
+
* Uses a local `store` reference captured at the start so that a concurrent
|
|
2400
|
+
* `destroy()` call setting `offlineStore = null` does not cause null-ref
|
|
2401
|
+
* errors across async await boundaries.
|
|
2329
2402
|
*/
|
|
2330
2403
|
async flushPendingUpdates() {
|
|
2331
|
-
|
|
2332
|
-
|
|
2404
|
+
const store = this.offlineStore;
|
|
2405
|
+
if (!store) return;
|
|
2406
|
+
const updates = await store.getPendingUpdates();
|
|
2333
2407
|
if (updates.length > 0) {
|
|
2334
2408
|
for (const update of updates) this.send(UpdateMessage, {
|
|
2335
2409
|
update,
|
|
2336
2410
|
documentName: this.configuration.name
|
|
2337
2411
|
});
|
|
2338
|
-
await
|
|
2412
|
+
await store.clearPendingUpdates();
|
|
2339
2413
|
}
|
|
2340
|
-
const pendingSubdocs = await
|
|
2414
|
+
const pendingSubdocs = await store.getPendingSubdocs();
|
|
2341
2415
|
for (const { childId } of pendingSubdocs) this.send(SubdocMessage, {
|
|
2342
2416
|
documentName: this.configuration.name,
|
|
2343
2417
|
childDocumentName: childId
|
|
2344
2418
|
});
|
|
2419
|
+
const snapshot = Y.encodeStateAsUpdate(this.document);
|
|
2420
|
+
await store.saveDocSnapshot(snapshot).catch(() => null);
|
|
2345
2421
|
}
|
|
2346
2422
|
get isConnected() {
|
|
2347
2423
|
return this.configuration.websocketProvider.status === "connected";
|