@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
|
@@ -1991,18 +1991,19 @@ var HocuspocusProvider = class extends EventEmitter {
|
|
|
1991
1991
|
|
|
1992
1992
|
//#endregion
|
|
1993
1993
|
//#region packages/provider/src/OfflineStore.ts
|
|
1994
|
-
const DB_VERSION =
|
|
1994
|
+
const DB_VERSION = 2;
|
|
1995
1995
|
function idbAvailable() {
|
|
1996
1996
|
return typeof globalThis !== "undefined" && "indexedDB" in globalThis;
|
|
1997
1997
|
}
|
|
1998
|
-
function openDb$1(
|
|
1998
|
+
function openDb$1(storeKey) {
|
|
1999
1999
|
return new Promise((resolve, reject) => {
|
|
2000
|
-
const req = globalThis.indexedDB.open(`abracadabra:${
|
|
2000
|
+
const req = globalThis.indexedDB.open(`abracadabra:${storeKey}`, DB_VERSION);
|
|
2001
2001
|
req.onupgradeneeded = (event) => {
|
|
2002
2002
|
const db = event.target.result;
|
|
2003
2003
|
if (!db.objectStoreNames.contains("updates")) db.createObjectStore("updates", { autoIncrement: true });
|
|
2004
2004
|
if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta");
|
|
2005
2005
|
if (!db.objectStoreNames.contains("subdoc_queue")) db.createObjectStore("subdoc_queue", { keyPath: "childId" });
|
|
2006
|
+
if (!db.objectStoreNames.contains("doc_state")) db.createObjectStore("doc_state");
|
|
2006
2007
|
};
|
|
2007
2008
|
req.onsuccess = () => resolve(req.result);
|
|
2008
2009
|
req.onerror = () => reject(req.error);
|
|
@@ -2015,13 +2016,19 @@ function txPromise(store, request) {
|
|
|
2015
2016
|
});
|
|
2016
2017
|
}
|
|
2017
2018
|
var OfflineStore = class {
|
|
2018
|
-
|
|
2019
|
+
/**
|
|
2020
|
+
* @param docId The document UUID.
|
|
2021
|
+
* @param serverOrigin Hostname of the server (e.g. "abra.cou.sh").
|
|
2022
|
+
* When provided the IndexedDB database is namespaced
|
|
2023
|
+
* per-server, preventing cross-server data contamination.
|
|
2024
|
+
*/
|
|
2025
|
+
constructor(docId, serverOrigin) {
|
|
2019
2026
|
this.db = null;
|
|
2020
|
-
this.
|
|
2027
|
+
this.storeKey = serverOrigin ? `${serverOrigin}/${docId}` : docId;
|
|
2021
2028
|
}
|
|
2022
2029
|
async getDb() {
|
|
2023
2030
|
if (!idbAvailable()) return null;
|
|
2024
|
-
if (!this.db) this.db = await openDb$1(this.
|
|
2031
|
+
if (!this.db) this.db = await openDb$1(this.storeKey).catch(() => null);
|
|
2025
2032
|
return this.db;
|
|
2026
2033
|
}
|
|
2027
2034
|
async persistUpdate(update) {
|
|
@@ -2045,6 +2052,25 @@ var OfflineStore = class {
|
|
|
2045
2052
|
const tx = db.transaction("updates", "readwrite");
|
|
2046
2053
|
await txPromise(tx.objectStore("updates"), tx.objectStore("updates").clear());
|
|
2047
2054
|
}
|
|
2055
|
+
/**
|
|
2056
|
+
* Persist a full Y.js state snapshot (Y.encodeStateAsUpdate output).
|
|
2057
|
+
* Replaces any previously stored snapshot.
|
|
2058
|
+
*/
|
|
2059
|
+
async saveDocSnapshot(snapshot) {
|
|
2060
|
+
const db = await this.getDb();
|
|
2061
|
+
if (!db) return;
|
|
2062
|
+
const tx = db.transaction("doc_state", "readwrite");
|
|
2063
|
+
await txPromise(tx.objectStore("doc_state"), tx.objectStore("doc_state").put(snapshot, "snapshot"));
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* Retrieve the stored full document snapshot, or null if none exists.
|
|
2067
|
+
*/
|
|
2068
|
+
async getDocSnapshot() {
|
|
2069
|
+
const db = await this.getDb();
|
|
2070
|
+
if (!db) return null;
|
|
2071
|
+
const tx = db.transaction("doc_state", "readonly");
|
|
2072
|
+
return await txPromise(tx.objectStore("doc_state"), tx.objectStore("doc_state").get("snapshot")) ?? null;
|
|
2073
|
+
}
|
|
2048
2074
|
async getStateVector() {
|
|
2049
2075
|
const db = await this.getDb();
|
|
2050
2076
|
if (!db) return null;
|
|
@@ -2138,10 +2164,16 @@ function isValidDocId(id) {
|
|
|
2138
2164
|
* own AbracadabraProvider instances sharing the same WebSocket connection.
|
|
2139
2165
|
*
|
|
2140
2166
|
* 2. Offline-first – persists CRDT updates to IndexedDB so they survive
|
|
2141
|
-
* page reloads and network outages. On
|
|
2142
|
-
*
|
|
2167
|
+
* page reloads and network outages. On startup, the last saved document
|
|
2168
|
+
* snapshot is applied immediately so the UI is usable without a server
|
|
2169
|
+
* connection. On reconnect, pending updates are flushed and a fresh
|
|
2170
|
+
* snapshot is saved.
|
|
2171
|
+
*
|
|
2172
|
+
* 3. Server-scoped storage – the IndexedDB database is keyed by server
|
|
2173
|
+
* hostname + docId, preventing cross-server data contamination when the
|
|
2174
|
+
* same docId is used on multiple servers.
|
|
2143
2175
|
*
|
|
2144
|
-
*
|
|
2176
|
+
* 4. Permission snapshotting – stores the resolved role locally so the UI
|
|
2145
2177
|
* can gate write operations without a network round-trip. Role is
|
|
2146
2178
|
* refreshed from the server on every reconnect.
|
|
2147
2179
|
*/
|
|
@@ -2161,12 +2193,41 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2161
2193
|
this._client = client;
|
|
2162
2194
|
this.abracadabraConfig = configuration;
|
|
2163
2195
|
this.subdocLoading = configuration.subdocLoading ?? "lazy";
|
|
2164
|
-
|
|
2196
|
+
const serverOrigin = AbracadabraProvider.deriveServerOrigin(configuration, client);
|
|
2197
|
+
this.offlineStore = configuration.disableOfflineStore ? null : new OfflineStore(configuration.name, serverOrigin);
|
|
2165
2198
|
this.on("subdocRegistered", configuration.onSubdocRegistered ?? (() => null));
|
|
2166
2199
|
this.on("subdocLoaded", configuration.onSubdocLoaded ?? (() => null));
|
|
2167
2200
|
this.document.on("subdocs", this.boundHandleYSubdocsChange);
|
|
2168
2201
|
this.on("synced", () => this.flushPendingUpdates());
|
|
2169
2202
|
this.restorePermissionSnapshot();
|
|
2203
|
+
this.ready = this._initFromOfflineStore();
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* Extract the server hostname from the provider configuration.
|
|
2207
|
+
* Used to namespace the IndexedDB key so docs from different servers
|
|
2208
|
+
* never share the same database.
|
|
2209
|
+
*/
|
|
2210
|
+
static deriveServerOrigin(config, client) {
|
|
2211
|
+
try {
|
|
2212
|
+
const url = config.url ?? config.websocketProvider?.url ?? client?.wsUrl;
|
|
2213
|
+
if (url) return new URL(url).hostname;
|
|
2214
|
+
} catch {}
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Load the stored document snapshot (and any pending local edits) from
|
|
2218
|
+
* IndexedDB and apply them to the Y.Doc so the UI can render without a
|
|
2219
|
+
* server connection.
|
|
2220
|
+
*
|
|
2221
|
+
* Uses `this.offlineStore` as the Y.js update origin so that
|
|
2222
|
+
* `documentUpdateHandler` ignores these replayed updates and does not
|
|
2223
|
+
* attempt to re-persist or re-send them.
|
|
2224
|
+
*/
|
|
2225
|
+
async _initFromOfflineStore() {
|
|
2226
|
+
if (!this.offlineStore) return;
|
|
2227
|
+
const snapshot = await this.offlineStore.getDocSnapshot().catch(() => null);
|
|
2228
|
+
if (snapshot) yjs.applyUpdate(this.document, snapshot, this.offlineStore);
|
|
2229
|
+
const pending = await this.offlineStore.getPendingUpdates().catch(() => []);
|
|
2230
|
+
for (const update of pending) yjs.applyUpdate(this.document, update, this.offlineStore);
|
|
2170
2231
|
}
|
|
2171
2232
|
authenticatedHandler(scope) {
|
|
2172
2233
|
super.authenticatedHandler(scope);
|
|
@@ -2304,7 +2365,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2304
2365
|
}
|
|
2305
2366
|
async _doLoadChild(childId) {
|
|
2306
2367
|
const childDoc = new yjs.Doc({ guid: childId });
|
|
2307
|
-
await new Promise((resolve) => {
|
|
2368
|
+
if (this.isConnected) await new Promise((resolve) => {
|
|
2308
2369
|
const onRegistered = ({ childId: cid }) => {
|
|
2309
2370
|
if (cid === childId) {
|
|
2310
2371
|
this.off("subdocRegistered", onRegistered);
|
|
@@ -2315,6 +2376,7 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2315
2376
|
this.registerSubdoc(childDoc);
|
|
2316
2377
|
setTimeout(resolve, 3e3);
|
|
2317
2378
|
});
|
|
2379
|
+
else this.registerSubdoc(childDoc);
|
|
2318
2380
|
const childProvider = new AbracadabraProvider({
|
|
2319
2381
|
name: childId,
|
|
2320
2382
|
document: childDoc,
|
|
@@ -2347,31 +2409,45 @@ var AbracadabraProvider = class AbracadabraProvider extends HocuspocusProvider {
|
|
|
2347
2409
|
/**
|
|
2348
2410
|
* Override to persist every local update to IndexedDB before sending it
|
|
2349
2411
|
* over the wire, ensuring no work is lost during connection outages.
|
|
2412
|
+
*
|
|
2413
|
+
* Updates applied by the server (origin === this) and updates replayed
|
|
2414
|
+
* from the offline store (origin === this.offlineStore) are both skipped
|
|
2415
|
+
* to avoid loops.
|
|
2350
2416
|
*/
|
|
2351
2417
|
documentUpdateHandler(update, origin) {
|
|
2352
2418
|
if (origin === this) return;
|
|
2419
|
+
if (this.offlineStore !== null && origin === this.offlineStore) return;
|
|
2353
2420
|
this.offlineStore?.persistUpdate(update).catch(() => null);
|
|
2354
2421
|
super.documentUpdateHandler(update, origin);
|
|
2355
2422
|
}
|
|
2356
2423
|
/**
|
|
2357
|
-
* After reconnect + sync
|
|
2358
|
-
*
|
|
2424
|
+
* After reconnect + sync:
|
|
2425
|
+
* 1. Flush any updates that were generated while offline.
|
|
2426
|
+
* 2. Flush any queued subdoc registrations.
|
|
2427
|
+
* 3. Save a fresh full document snapshot for the next offline session.
|
|
2428
|
+
*
|
|
2429
|
+
* Uses a local `store` reference captured at the start so that a concurrent
|
|
2430
|
+
* `destroy()` call setting `offlineStore = null` does not cause null-ref
|
|
2431
|
+
* errors across async await boundaries.
|
|
2359
2432
|
*/
|
|
2360
2433
|
async flushPendingUpdates() {
|
|
2361
|
-
|
|
2362
|
-
|
|
2434
|
+
const store = this.offlineStore;
|
|
2435
|
+
if (!store) return;
|
|
2436
|
+
const updates = await store.getPendingUpdates();
|
|
2363
2437
|
if (updates.length > 0) {
|
|
2364
2438
|
for (const update of updates) this.send(UpdateMessage, {
|
|
2365
2439
|
update,
|
|
2366
2440
|
documentName: this.configuration.name
|
|
2367
2441
|
});
|
|
2368
|
-
await
|
|
2442
|
+
await store.clearPendingUpdates();
|
|
2369
2443
|
}
|
|
2370
|
-
const pendingSubdocs = await
|
|
2444
|
+
const pendingSubdocs = await store.getPendingSubdocs();
|
|
2371
2445
|
for (const { childId } of pendingSubdocs) this.send(SubdocMessage, {
|
|
2372
2446
|
documentName: this.configuration.name,
|
|
2373
2447
|
childDocumentName: childId
|
|
2374
2448
|
});
|
|
2449
|
+
const snapshot = yjs.encodeStateAsUpdate(this.document);
|
|
2450
|
+
await store.saveDocSnapshot(snapshot).catch(() => null);
|
|
2375
2451
|
}
|
|
2376
2452
|
get isConnected() {
|
|
2377
2453
|
return this.configuration.websocketProvider.status === "connected";
|