@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
package/dist/index.d.ts
CHANGED
|
@@ -299,10 +299,16 @@ interface AbracadabraProviderConfiguration extends Omit<HocuspocusProviderConfig
|
|
|
299
299
|
* own AbracadabraProvider instances sharing the same WebSocket connection.
|
|
300
300
|
*
|
|
301
301
|
* 2. Offline-first – persists CRDT updates to IndexedDB so they survive
|
|
302
|
-
* page reloads and network outages. On
|
|
303
|
-
*
|
|
302
|
+
* page reloads and network outages. On startup, the last saved document
|
|
303
|
+
* snapshot is applied immediately so the UI is usable without a server
|
|
304
|
+
* connection. On reconnect, pending updates are flushed and a fresh
|
|
305
|
+
* snapshot is saved.
|
|
304
306
|
*
|
|
305
|
-
* 3.
|
|
307
|
+
* 3. Server-scoped storage – the IndexedDB database is keyed by server
|
|
308
|
+
* hostname + docId, preventing cross-server data contamination when the
|
|
309
|
+
* same docId is used on multiple servers.
|
|
310
|
+
*
|
|
311
|
+
* 4. Permission snapshotting – stores the resolved role locally so the UI
|
|
306
312
|
* can gate write operations without a network round-trip. Role is
|
|
307
313
|
* refreshed from the server on every reconnect.
|
|
308
314
|
*/
|
|
@@ -315,7 +321,30 @@ declare class AbracadabraProvider extends HocuspocusProvider {
|
|
|
315
321
|
private subdocLoading;
|
|
316
322
|
private abracadabraConfig;
|
|
317
323
|
private readonly boundHandleYSubdocsChange;
|
|
324
|
+
/**
|
|
325
|
+
* Resolves once the document has been pre-populated from the local
|
|
326
|
+
* IndexedDB snapshot (if one exists). Await this before rendering UI
|
|
327
|
+
* that depends on the document content — it resolves immediately when
|
|
328
|
+
* the offline store is disabled or when no snapshot has been saved yet.
|
|
329
|
+
*/
|
|
330
|
+
readonly ready: Promise<void>;
|
|
318
331
|
constructor(configuration: AbracadabraProviderConfiguration);
|
|
332
|
+
/**
|
|
333
|
+
* Extract the server hostname from the provider configuration.
|
|
334
|
+
* Used to namespace the IndexedDB key so docs from different servers
|
|
335
|
+
* never share the same database.
|
|
336
|
+
*/
|
|
337
|
+
private static deriveServerOrigin;
|
|
338
|
+
/**
|
|
339
|
+
* Load the stored document snapshot (and any pending local edits) from
|
|
340
|
+
* IndexedDB and apply them to the Y.Doc so the UI can render without a
|
|
341
|
+
* server connection.
|
|
342
|
+
*
|
|
343
|
+
* Uses `this.offlineStore` as the Y.js update origin so that
|
|
344
|
+
* `documentUpdateHandler` ignores these replayed updates and does not
|
|
345
|
+
* attempt to re-persist or re-send them.
|
|
346
|
+
*/
|
|
347
|
+
private _initFromOfflineStore;
|
|
319
348
|
authenticatedHandler(scope: string): void;
|
|
320
349
|
/**
|
|
321
350
|
* Override sendToken to send a pubkey-only identity declaration instead of a
|
|
@@ -361,11 +390,21 @@ declare class AbracadabraProvider extends HocuspocusProvider {
|
|
|
361
390
|
/**
|
|
362
391
|
* Override to persist every local update to IndexedDB before sending it
|
|
363
392
|
* over the wire, ensuring no work is lost during connection outages.
|
|
393
|
+
*
|
|
394
|
+
* Updates applied by the server (origin === this) and updates replayed
|
|
395
|
+
* from the offline store (origin === this.offlineStore) are both skipped
|
|
396
|
+
* to avoid loops.
|
|
364
397
|
*/
|
|
365
398
|
documentUpdateHandler(update: Uint8Array, origin: unknown): void;
|
|
366
399
|
/**
|
|
367
|
-
* After reconnect + sync
|
|
368
|
-
*
|
|
400
|
+
* After reconnect + sync:
|
|
401
|
+
* 1. Flush any updates that were generated while offline.
|
|
402
|
+
* 2. Flush any queued subdoc registrations.
|
|
403
|
+
* 3. Save a fresh full document snapshot for the next offline session.
|
|
404
|
+
*
|
|
405
|
+
* Uses a local `store` reference captured at the start so that a concurrent
|
|
406
|
+
* `destroy()` call setting `offlineStore = null` does not cause null-ref
|
|
407
|
+
* errors across async await boundaries.
|
|
369
408
|
*/
|
|
370
409
|
private flushPendingUpdates;
|
|
371
410
|
get isConnected(): boolean;
|
|
@@ -864,8 +903,12 @@ declare class HocuspocusProvider extends EventEmitter {
|
|
|
864
903
|
* - Store the last-known state vector for fast reconnect diffs.
|
|
865
904
|
* - Store a permission snapshot so the UI can gate writes without a network round-trip.
|
|
866
905
|
* - Queue subdoc registration events created while offline.
|
|
906
|
+
* - Store a full document snapshot so the app can load content without a server connection.
|
|
867
907
|
*
|
|
868
908
|
* Falls back to a silent no-op when IndexedDB is unavailable (e.g. SSR / Node.js).
|
|
909
|
+
*
|
|
910
|
+
* Database key is scoped by server origin to prevent cross-server data contamination
|
|
911
|
+
* when the same docId is used on multiple servers.
|
|
869
912
|
*/
|
|
870
913
|
interface PendingSubdoc {
|
|
871
914
|
childId: string;
|
|
@@ -873,13 +916,28 @@ interface PendingSubdoc {
|
|
|
873
916
|
createdAt: number;
|
|
874
917
|
}
|
|
875
918
|
declare class OfflineStore {
|
|
876
|
-
private
|
|
919
|
+
private storeKey;
|
|
877
920
|
private db;
|
|
878
|
-
|
|
921
|
+
/**
|
|
922
|
+
* @param docId The document UUID.
|
|
923
|
+
* @param serverOrigin Hostname of the server (e.g. "abra.cou.sh").
|
|
924
|
+
* When provided the IndexedDB database is namespaced
|
|
925
|
+
* per-server, preventing cross-server data contamination.
|
|
926
|
+
*/
|
|
927
|
+
constructor(docId: string, serverOrigin?: string);
|
|
879
928
|
private getDb;
|
|
880
929
|
persistUpdate(update: Uint8Array): Promise<void>;
|
|
881
930
|
getPendingUpdates(): Promise<Uint8Array[]>;
|
|
882
931
|
clearPendingUpdates(): Promise<void>;
|
|
932
|
+
/**
|
|
933
|
+
* Persist a full Y.js state snapshot (Y.encodeStateAsUpdate output).
|
|
934
|
+
* Replaces any previously stored snapshot.
|
|
935
|
+
*/
|
|
936
|
+
saveDocSnapshot(snapshot: Uint8Array): Promise<void>;
|
|
937
|
+
/**
|
|
938
|
+
* Retrieve the stored full document snapshot, or null if none exists.
|
|
939
|
+
*/
|
|
940
|
+
getDocSnapshot(): Promise<Uint8Array | null>;
|
|
883
941
|
getStateVector(): Promise<Uint8Array | null>;
|
|
884
942
|
saveStateVector(sv: Uint8Array): Promise<void>;
|
|
885
943
|
getPermissionSnapshot(): Promise<string | null>;
|
package/package.json
CHANGED
|
@@ -74,10 +74,16 @@ function isValidDocId(id: string): boolean {
|
|
|
74
74
|
* own AbracadabraProvider instances sharing the same WebSocket connection.
|
|
75
75
|
*
|
|
76
76
|
* 2. Offline-first – persists CRDT updates to IndexedDB so they survive
|
|
77
|
-
* page reloads and network outages. On
|
|
78
|
-
*
|
|
77
|
+
* page reloads and network outages. On startup, the last saved document
|
|
78
|
+
* snapshot is applied immediately so the UI is usable without a server
|
|
79
|
+
* connection. On reconnect, pending updates are flushed and a fresh
|
|
80
|
+
* snapshot is saved.
|
|
79
81
|
*
|
|
80
|
-
* 3.
|
|
82
|
+
* 3. Server-scoped storage – the IndexedDB database is keyed by server
|
|
83
|
+
* hostname + docId, preventing cross-server data contamination when the
|
|
84
|
+
* same docId is used on multiple servers.
|
|
85
|
+
*
|
|
86
|
+
* 4. Permission snapshotting – stores the resolved role locally so the UI
|
|
81
87
|
* can gate write operations without a network round-trip. Role is
|
|
82
88
|
* refreshed from the server on every reconnect.
|
|
83
89
|
*/
|
|
@@ -94,6 +100,14 @@ export class AbracadabraProvider extends HocuspocusProvider {
|
|
|
94
100
|
|
|
95
101
|
private readonly boundHandleYSubdocsChange = this.handleYSubdocsChange.bind(this);
|
|
96
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Resolves once the document has been pre-populated from the local
|
|
105
|
+
* IndexedDB snapshot (if one exists). Await this before rendering UI
|
|
106
|
+
* that depends on the document content — it resolves immediately when
|
|
107
|
+
* the offline store is disabled or when no snapshot has been saved yet.
|
|
108
|
+
*/
|
|
109
|
+
public readonly ready: Promise<void>;
|
|
110
|
+
|
|
97
111
|
constructor(configuration: AbracadabraProviderConfiguration) {
|
|
98
112
|
// Derive URL and token from client when not explicitly set.
|
|
99
113
|
const resolved = { ...configuration } as HocuspocusProviderConfiguration;
|
|
@@ -113,9 +127,10 @@ export class AbracadabraProvider extends HocuspocusProvider {
|
|
|
113
127
|
this.abracadabraConfig = configuration;
|
|
114
128
|
this.subdocLoading = configuration.subdocLoading ?? "lazy";
|
|
115
129
|
|
|
130
|
+
const serverOrigin = AbracadabraProvider.deriveServerOrigin(configuration, client);
|
|
116
131
|
this.offlineStore = configuration.disableOfflineStore
|
|
117
132
|
? null
|
|
118
|
-
: new OfflineStore(configuration.name);
|
|
133
|
+
: new OfflineStore(configuration.name, serverOrigin);
|
|
119
134
|
|
|
120
135
|
this.on(
|
|
121
136
|
"subdocRegistered",
|
|
@@ -131,6 +146,57 @@ export class AbracadabraProvider extends HocuspocusProvider {
|
|
|
131
146
|
|
|
132
147
|
// Restore permission snapshot while offline.
|
|
133
148
|
this.restorePermissionSnapshot();
|
|
149
|
+
|
|
150
|
+
// Pre-populate the Y.Doc from the local snapshot so the UI works offline.
|
|
151
|
+
this.ready = this._initFromOfflineStore();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Server origin derivation ──────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Extract the server hostname from the provider configuration.
|
|
158
|
+
* Used to namespace the IndexedDB key so docs from different servers
|
|
159
|
+
* never share the same database.
|
|
160
|
+
*/
|
|
161
|
+
private static deriveServerOrigin(
|
|
162
|
+
config: AbracadabraProviderConfiguration,
|
|
163
|
+
client: AbracadabraClient | null,
|
|
164
|
+
): string | undefined {
|
|
165
|
+
try {
|
|
166
|
+
const url =
|
|
167
|
+
config.url ??
|
|
168
|
+
(config.websocketProvider as HocuspocusProviderWebsocket | undefined)?.url ??
|
|
169
|
+
client?.wsUrl;
|
|
170
|
+
if (url) return new URL(url).hostname;
|
|
171
|
+
} catch {
|
|
172
|
+
// Malformed URL — fall back to no scoping
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Offline-first initialisation ──────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Load the stored document snapshot (and any pending local edits) from
|
|
181
|
+
* IndexedDB and apply them to the Y.Doc so the UI can render without a
|
|
182
|
+
* server connection.
|
|
183
|
+
*
|
|
184
|
+
* Uses `this.offlineStore` as the Y.js update origin so that
|
|
185
|
+
* `documentUpdateHandler` ignores these replayed updates and does not
|
|
186
|
+
* attempt to re-persist or re-send them.
|
|
187
|
+
*/
|
|
188
|
+
private async _initFromOfflineStore(): Promise<void> {
|
|
189
|
+
if (!this.offlineStore) return;
|
|
190
|
+
|
|
191
|
+
const snapshot = await this.offlineStore.getDocSnapshot().catch(() => null);
|
|
192
|
+
if (snapshot) {
|
|
193
|
+
Y.applyUpdate(this.document, snapshot, this.offlineStore);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const pending = await this.offlineStore.getPendingUpdates().catch(() => []);
|
|
197
|
+
for (const update of pending) {
|
|
198
|
+
Y.applyUpdate(this.document, update, this.offlineStore);
|
|
199
|
+
}
|
|
134
200
|
}
|
|
135
201
|
|
|
136
202
|
// ── Auth / permission snapshot ────────────────────────────────────────────
|
|
@@ -351,23 +417,29 @@ export class AbracadabraProvider extends HocuspocusProvider {
|
|
|
351
417
|
private async _doLoadChild(childId: string): Promise<AbracadabraProvider> {
|
|
352
418
|
const childDoc = new Y.Doc({ guid: childId });
|
|
353
419
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
420
|
+
if (this.isConnected) {
|
|
421
|
+
// Online: notify the server that this child belongs to the parent and
|
|
422
|
+
// wait for server confirmation before opening the child WebSocket.
|
|
423
|
+
// Without waiting, the child's SyncStep1 may race against subdoc
|
|
424
|
+
// creation and get a NotFound error on first sync.
|
|
425
|
+
await new Promise<void>((resolve) => {
|
|
426
|
+
const onRegistered = ({ childId: cid }: onSubdocRegisteredParameters) => {
|
|
427
|
+
if (cid === childId) {
|
|
428
|
+
this.off("subdocRegistered", onRegistered);
|
|
429
|
+
resolve();
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
this.on("subdocRegistered", onRegistered);
|
|
433
|
+
this.registerSubdoc(childDoc);
|
|
434
|
+
// Fallback: don't block forever if the server is slow or the
|
|
435
|
+
// doc was already registered in a previous session.
|
|
436
|
+
setTimeout(resolve, 3000);
|
|
437
|
+
});
|
|
438
|
+
} else {
|
|
439
|
+
// Offline: queue the registration for replay on reconnect and proceed
|
|
440
|
+
// immediately so the child provider can populate from its local store.
|
|
366
441
|
this.registerSubdoc(childDoc);
|
|
367
|
-
|
|
368
|
-
// doc was already registered in a previous session.
|
|
369
|
-
setTimeout(resolve, 3000);
|
|
370
|
-
});
|
|
442
|
+
}
|
|
371
443
|
|
|
372
444
|
// Each child gets its own WebSocket connection. Omitting
|
|
373
445
|
// websocketProvider lets HocuspocusProvider create one automatically
|
|
@@ -409,9 +481,18 @@ export class AbracadabraProvider extends HocuspocusProvider {
|
|
|
409
481
|
/**
|
|
410
482
|
* Override to persist every local update to IndexedDB before sending it
|
|
411
483
|
* over the wire, ensuring no work is lost during connection outages.
|
|
484
|
+
*
|
|
485
|
+
* Updates applied by the server (origin === this) and updates replayed
|
|
486
|
+
* from the offline store (origin === this.offlineStore) are both skipped
|
|
487
|
+
* to avoid loops.
|
|
412
488
|
*/
|
|
413
489
|
override documentUpdateHandler(update: Uint8Array, origin: unknown) {
|
|
414
490
|
if (origin === this) return;
|
|
491
|
+
// Only skip when the store exists AND origin matches it.
|
|
492
|
+
// Without the null-guard, a null offlineStore would match any update
|
|
493
|
+
// with origin=null (the default for user transactions), silently
|
|
494
|
+
// dropping all local writes when disableOfflineStore is true.
|
|
495
|
+
if (this.offlineStore !== null && origin === this.offlineStore) return;
|
|
415
496
|
|
|
416
497
|
// Persist locally first (fire-and-forget; errors are non-fatal).
|
|
417
498
|
this.offlineStore?.persistUpdate(update).catch(() => null);
|
|
@@ -420,13 +501,20 @@ export class AbracadabraProvider extends HocuspocusProvider {
|
|
|
420
501
|
}
|
|
421
502
|
|
|
422
503
|
/**
|
|
423
|
-
* After reconnect + sync
|
|
424
|
-
*
|
|
504
|
+
* After reconnect + sync:
|
|
505
|
+
* 1. Flush any updates that were generated while offline.
|
|
506
|
+
* 2. Flush any queued subdoc registrations.
|
|
507
|
+
* 3. Save a fresh full document snapshot for the next offline session.
|
|
508
|
+
*
|
|
509
|
+
* Uses a local `store` reference captured at the start so that a concurrent
|
|
510
|
+
* `destroy()` call setting `offlineStore = null` does not cause null-ref
|
|
511
|
+
* errors across async await boundaries.
|
|
425
512
|
*/
|
|
426
513
|
private async flushPendingUpdates() {
|
|
427
|
-
|
|
514
|
+
const store = this.offlineStore;
|
|
515
|
+
if (!store) return;
|
|
428
516
|
|
|
429
|
-
const updates = await
|
|
517
|
+
const updates = await store.getPendingUpdates();
|
|
430
518
|
if (updates.length > 0) {
|
|
431
519
|
for (const update of updates) {
|
|
432
520
|
this.send(UpdateMessage, {
|
|
@@ -434,16 +522,20 @@ export class AbracadabraProvider extends HocuspocusProvider {
|
|
|
434
522
|
documentName: this.configuration.name,
|
|
435
523
|
});
|
|
436
524
|
}
|
|
437
|
-
await
|
|
525
|
+
await store.clearPendingUpdates();
|
|
438
526
|
}
|
|
439
527
|
|
|
440
|
-
const pendingSubdocs = await
|
|
528
|
+
const pendingSubdocs = await store.getPendingSubdocs();
|
|
441
529
|
for (const { childId } of pendingSubdocs) {
|
|
442
530
|
this.send(SubdocMessage, {
|
|
443
531
|
documentName: this.configuration.name,
|
|
444
532
|
childDocumentName: childId,
|
|
445
533
|
} as any);
|
|
446
534
|
}
|
|
535
|
+
|
|
536
|
+
// Snapshot the current merged state so the next offline load sees it.
|
|
537
|
+
const snapshot = Y.encodeStateAsUpdate(this.document);
|
|
538
|
+
await store.saveDocSnapshot(snapshot).catch(() => null);
|
|
447
539
|
}
|
|
448
540
|
|
|
449
541
|
get isConnected(): boolean {
|
package/src/OfflineStore.ts
CHANGED
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
* - Store the last-known state vector for fast reconnect diffs.
|
|
7
7
|
* - Store a permission snapshot so the UI can gate writes without a network round-trip.
|
|
8
8
|
* - Queue subdoc registration events created while offline.
|
|
9
|
+
* - Store a full document snapshot so the app can load content without a server connection.
|
|
9
10
|
*
|
|
10
11
|
* Falls back to a silent no-op when IndexedDB is unavailable (e.g. SSR / Node.js).
|
|
12
|
+
*
|
|
13
|
+
* Database key is scoped by server origin to prevent cross-server data contamination
|
|
14
|
+
* when the same docId is used on multiple servers.
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
export interface PendingSubdoc {
|
|
@@ -16,15 +20,15 @@ export interface PendingSubdoc {
|
|
|
16
20
|
createdAt: number;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
|
-
const DB_VERSION =
|
|
23
|
+
const DB_VERSION = 2;
|
|
20
24
|
|
|
21
25
|
function idbAvailable(): boolean {
|
|
22
26
|
return typeof globalThis !== "undefined" && "indexedDB" in globalThis;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
|
-
function openDb(
|
|
29
|
+
function openDb(storeKey: string): Promise<IDBDatabase> {
|
|
26
30
|
return new Promise((resolve, reject) => {
|
|
27
|
-
const req = globalThis.indexedDB.open(`abracadabra:${
|
|
31
|
+
const req = globalThis.indexedDB.open(`abracadabra:${storeKey}`, DB_VERSION);
|
|
28
32
|
|
|
29
33
|
req.onupgradeneeded = (event) => {
|
|
30
34
|
const db = (event.target as IDBOpenDBRequest).result;
|
|
@@ -38,6 +42,10 @@ function openDb(docId: string): Promise<IDBDatabase> {
|
|
|
38
42
|
if (!db.objectStoreNames.contains("subdoc_queue")) {
|
|
39
43
|
db.createObjectStore("subdoc_queue", { keyPath: "childId" });
|
|
40
44
|
}
|
|
45
|
+
// v2: full document snapshot store
|
|
46
|
+
if (!db.objectStoreNames.contains("doc_state")) {
|
|
47
|
+
db.createObjectStore("doc_state");
|
|
48
|
+
}
|
|
41
49
|
};
|
|
42
50
|
|
|
43
51
|
req.onsuccess = () => resolve(req.result);
|
|
@@ -56,22 +64,28 @@ function txPromise<T>(
|
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
export class OfflineStore {
|
|
59
|
-
private
|
|
67
|
+
private storeKey: string;
|
|
60
68
|
private db: IDBDatabase | null = null;
|
|
61
69
|
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
/**
|
|
71
|
+
* @param docId The document UUID.
|
|
72
|
+
* @param serverOrigin Hostname of the server (e.g. "abra.cou.sh").
|
|
73
|
+
* When provided the IndexedDB database is namespaced
|
|
74
|
+
* per-server, preventing cross-server data contamination.
|
|
75
|
+
*/
|
|
76
|
+
constructor(docId: string, serverOrigin?: string) {
|
|
77
|
+
this.storeKey = serverOrigin ? `${serverOrigin}/${docId}` : docId;
|
|
64
78
|
}
|
|
65
79
|
|
|
66
80
|
private async getDb(): Promise<IDBDatabase | null> {
|
|
67
81
|
if (!idbAvailable()) return null;
|
|
68
82
|
if (!this.db) {
|
|
69
|
-
this.db = await openDb(this.
|
|
83
|
+
this.db = await openDb(this.storeKey).catch(() => null);
|
|
70
84
|
}
|
|
71
85
|
return this.db;
|
|
72
86
|
}
|
|
73
87
|
|
|
74
|
-
// ──
|
|
88
|
+
// ── Pending (unsynced) updates ────────────────────────────────────────────
|
|
75
89
|
|
|
76
90
|
async persistUpdate(update: Uint8Array): Promise<void> {
|
|
77
91
|
const db = await this.getDb();
|
|
@@ -100,6 +114,36 @@ export class OfflineStore {
|
|
|
100
114
|
await txPromise(tx.objectStore("updates"), tx.objectStore("updates").clear());
|
|
101
115
|
}
|
|
102
116
|
|
|
117
|
+
// ── Full document snapshot ────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Persist a full Y.js state snapshot (Y.encodeStateAsUpdate output).
|
|
121
|
+
* Replaces any previously stored snapshot.
|
|
122
|
+
*/
|
|
123
|
+
async saveDocSnapshot(snapshot: Uint8Array): Promise<void> {
|
|
124
|
+
const db = await this.getDb();
|
|
125
|
+
if (!db) return;
|
|
126
|
+
const tx = db.transaction("doc_state", "readwrite");
|
|
127
|
+
await txPromise(
|
|
128
|
+
tx.objectStore("doc_state"),
|
|
129
|
+
tx.objectStore("doc_state").put(snapshot, "snapshot"),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Retrieve the stored full document snapshot, or null if none exists.
|
|
135
|
+
*/
|
|
136
|
+
async getDocSnapshot(): Promise<Uint8Array | null> {
|
|
137
|
+
const db = await this.getDb();
|
|
138
|
+
if (!db) return null;
|
|
139
|
+
const tx = db.transaction("doc_state", "readonly");
|
|
140
|
+
const result = await txPromise<Uint8Array | undefined>(
|
|
141
|
+
tx.objectStore("doc_state"),
|
|
142
|
+
tx.objectStore("doc_state").get("snapshot"),
|
|
143
|
+
);
|
|
144
|
+
return result ?? null;
|
|
145
|
+
}
|
|
146
|
+
|
|
103
147
|
// ── State vector ─────────────────────────────────────────────────────────
|
|
104
148
|
|
|
105
149
|
async getStateVector(): Promise<Uint8Array | null> {
|