@doubling/compound-sync 1.10.5 → 1.12.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/README.md +23 -0
- package/files.js +95 -45
- package/org-sync.js +274 -60
- package/package.json +19 -6
- package/paths.d.ts +12 -0
- package/paths.js +29 -34
- package/storage-helpers.js +2 -0
- package/sync.js +37 -0
- package/yjs-binding-state.js +61 -0
- package/yjs-file-binding.js +236 -0
- package/yjs-firestore-update-store.js +117 -0
- package/yjs-provider.js +85 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Per-file Y.Doc binary state persistence (DOU-209).
|
|
2
|
+
//
|
|
3
|
+
// On daemon restart we need to merge offline edits made on disk with
|
|
4
|
+
// offline edits made via the web. Yjs converges deterministically when
|
|
5
|
+
// both sides apply their ops to a common baseline. The daemon's last
|
|
6
|
+
// view of the file (== ytext at last write, == disk content at last
|
|
7
|
+
// write) is that baseline. Persisting the Y.Doc binary state per file
|
|
8
|
+
// lets the daemon restore that baseline on startup so:
|
|
9
|
+
// 1. ytext == lastDisk after restore
|
|
10
|
+
// 2. fast-diff(ytext, currentDisk) gives positions valid in ytext
|
|
11
|
+
// 3. cloud yupdates apply on top via CRDT merge (idempotent for ops
|
|
12
|
+
// already in the loaded state, additive for new cloud ops).
|
|
13
|
+
//
|
|
14
|
+
// Storage layout: one file per Y.Doc, keyed by fileId, under
|
|
15
|
+
// `<localPath>/.compound-yjs-binding/<fileId>.yjs`. The binary blob is
|
|
16
|
+
// the output of Y.encodeStateAsUpdate(doc); the daemon applies it via
|
|
17
|
+
// Y.applyUpdate(doc, blob) on restore.
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
const STATE_DIR = '.compound-yjs-binding';
|
|
23
|
+
|
|
24
|
+
function statePathFor(localPath, fileId) {
|
|
25
|
+
return path.join(localPath, STATE_DIR, `${fileId}.yjs`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Load the encoded Y.Doc state for a file. Returns null if no state
|
|
29
|
+
// exists yet (first run, or this fileId hasn't been synced before) or
|
|
30
|
+
// if the file is unreadable for any reason (corruption, permissions).
|
|
31
|
+
// Treating unreadable as "no state" is safe: the binding falls back to
|
|
32
|
+
// its first-run behavior, which writes ytext to disk on initial flush.
|
|
33
|
+
export function loadBindingState(localPath, fileId) {
|
|
34
|
+
try {
|
|
35
|
+
return fs.readFileSync(statePathFor(localPath, fileId));
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Persist the encoded Y.Doc state for a file. Atomic via write-to-tmp +
|
|
42
|
+
// rename so a crash mid-write can't leave a half-written .yjs file that
|
|
43
|
+
// would corrupt the next startup. Best-effort: a failed save costs us a
|
|
44
|
+
// cold-start reconciliation next launch, never data.
|
|
45
|
+
export function saveBindingState(localPath, fileId, encoded) {
|
|
46
|
+
const finalPath = statePathFor(localPath, fileId);
|
|
47
|
+
const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.tmp`;
|
|
48
|
+
try {
|
|
49
|
+
fs.mkdirSync(path.dirname(finalPath), { recursive: true });
|
|
50
|
+
fs.writeFileSync(tmpPath, encoded);
|
|
51
|
+
fs.renameSync(tmpPath, finalPath);
|
|
52
|
+
} catch {
|
|
53
|
+
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Best-effort cleanup when a file is deleted from the workspace, so
|
|
58
|
+
// stale .yjs blobs don't accumulate. Failing to delete is not fatal.
|
|
59
|
+
export function deleteBindingState(localPath, fileId) {
|
|
60
|
+
try { fs.unlinkSync(statePathFor(localPath, fileId)); } catch { /* ignore */ }
|
|
61
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// YjsFileBinding: per-file glue that keeps a local Markdown file
|
|
2
|
+
// converged with the per-file Y.Doc that lives in Firestore.
|
|
3
|
+
//
|
|
4
|
+
// Two directions, both flowing through the Y.Doc:
|
|
5
|
+
// cloud -> disk: provider applies remote yupdates to ytext; an
|
|
6
|
+
// observer debounces the resulting text into a single disk write
|
|
7
|
+
// so a burst of web keystrokes coalesces into one fs.writeFile.
|
|
8
|
+
// disk -> cloud: when the local file changes, caller invokes
|
|
9
|
+
// applyDiskText(newText); we diff against ytext (fast-diff for a
|
|
10
|
+
// char-level edit script) and apply inserts/deletes in one
|
|
11
|
+
// transaction so the provider's local-update listener emits a
|
|
12
|
+
// single yupdate carrying just the changed regions, preserving
|
|
13
|
+
// concurrent web edits to unrelated regions.
|
|
14
|
+
//
|
|
15
|
+
// Disk I/O is injected via onDiskWrite so tests can record without
|
|
16
|
+
// touching the filesystem and so the daemon can plumb its existing
|
|
17
|
+
// suppressLocal set (avoids the disk write feeding back through
|
|
18
|
+
// chokidar as a fake user edit).
|
|
19
|
+
|
|
20
|
+
import * as Y from 'yjs';
|
|
21
|
+
import fastDiff from 'fast-diff';
|
|
22
|
+
import { YjsProvider } from './yjs-provider.js';
|
|
23
|
+
|
|
24
|
+
export class YjsFileBinding {
|
|
25
|
+
constructor(store, {
|
|
26
|
+
debounceMs = 300, onDiskWrite, onDocStateChange,
|
|
27
|
+
initialDocState = null, currentDiskText = null,
|
|
28
|
+
}) {
|
|
29
|
+
if (!onDiskWrite) throw new Error('YjsFileBinding: onDiskWrite is required');
|
|
30
|
+
this.doc = new Y.Doc();
|
|
31
|
+
this.ytext = this.doc.getText('content');
|
|
32
|
+
this.store = store;
|
|
33
|
+
this.provider = new YjsProvider(this.doc, store);
|
|
34
|
+
this.debounceMs = debounceMs;
|
|
35
|
+
this.onDiskWrite = onDiskWrite;
|
|
36
|
+
// Called after every Y.Doc mutation reaches a stable state, with
|
|
37
|
+
// the encoded Y.Doc binary state. The daemon persists this to
|
|
38
|
+
// disk so the next startup can restore the baseline and merge
|
|
39
|
+
// offline disk edits without losing offline cloud edits (DOU-209).
|
|
40
|
+
this.onDocStateChange = onDocStateChange ?? null;
|
|
41
|
+
// Restore options for offline-merge (DOU-209).
|
|
42
|
+
this.initialDocState = initialDocState;
|
|
43
|
+
this.currentDiskText = currentDiskText;
|
|
44
|
+
this.flushTimer = null;
|
|
45
|
+
this.lastDiskText = null;
|
|
46
|
+
this.observer = null;
|
|
47
|
+
this.stopped = false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async start() {
|
|
51
|
+
// DOU-209: offline-merge startup. Three-step process that turns the
|
|
52
|
+
// daemon's restart into a deterministic CRDT merge:
|
|
53
|
+
//
|
|
54
|
+
// 1. Restore Y.Doc from the daemon's last-saved binary state (if
|
|
55
|
+
// any). After this, ytext equals what disk was at the daemon's
|
|
56
|
+
// last write. This is the common ancestor for any divergence
|
|
57
|
+
// that happened while the daemon was offline.
|
|
58
|
+
// 2. If the current disk content differs from the restored ytext,
|
|
59
|
+
// the difference is disk-only edits made during downtime.
|
|
60
|
+
// Apply them as Y.Doc operations (positions are valid in the
|
|
61
|
+
// restored ytext since restored-ytext == lastDisk == fast-diff
|
|
62
|
+
// input).
|
|
63
|
+
// 3. provider.loadInitial() pulls cloud's yupdates on top. Y.applyUpdate
|
|
64
|
+
// is idempotent (Yjs's state-vector dedup), so already-known
|
|
65
|
+
// ops are no-ops. Only NEW cloud ops (offline web edits) take
|
|
66
|
+
// effect. They CRDT-merge with the disk ops from step 2.
|
|
67
|
+
//
|
|
68
|
+
// Without step 1, fast-diff(post-loadInitial-ytext, currentDisk)
|
|
69
|
+
// treats web's offline insertions as "things present in ytext but
|
|
70
|
+
// missing from disk" and DELETES them. That's the bug this fixes.
|
|
71
|
+
// Seed lastDiskText from the disk content we observed at construction
|
|
72
|
+
// time so the initial flush is a no-op when ytext already matches
|
|
73
|
+
// what's on disk. Without this, the binding's initial flush always
|
|
74
|
+
// wrote ytext to disk on startup, which clobbered any content the
|
|
75
|
+
// daemon's legacy contentHash + Storage blob pull (the fallthrough
|
|
76
|
+
// path in handleFirestoreChange) may have written first for non-Yjs
|
|
77
|
+
// cloud edits. Regression case: "after restart, pulls a web edit
|
|
78
|
+
// made while stopped despite a fresh local mtime" in
|
|
79
|
+
// sync/org-sync.test.js.
|
|
80
|
+
this.lastDiskText = this.currentDiskText;
|
|
81
|
+
|
|
82
|
+
if (this.initialDocState) {
|
|
83
|
+
Y.applyUpdate(this.doc, this.initialDocState);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Disk-merge ops emitted below need to reach the store so other
|
|
87
|
+
// clients see the offline disk edits. The provider's local-update
|
|
88
|
+
// handler isn't attached yet (it's wired in startSubscription,
|
|
89
|
+
// which we can't call until after loadInitial), so we capture the
|
|
90
|
+
// updates synchronously here and push them after the subscription
|
|
91
|
+
// is set up. Capture by Y.Doc 'update' event with a 'disk' origin
|
|
92
|
+
// filter so we only grab the ops from our own _applyTextDiff and
|
|
93
|
+
// not anything spurious.
|
|
94
|
+
//
|
|
95
|
+
// The disk-merge is only safe when we have an `initialDocState`:
|
|
96
|
+
// that gives ytext a known baseline (the daemon's last view of the
|
|
97
|
+
// file) before we diff against currentDiskText, so fast-diff
|
|
98
|
+
// positions are valid AND the resulting ops are scoped to JUST the
|
|
99
|
+
// disk-only changes. Without a baseline, applying currentDiskText
|
|
100
|
+
// as fresh inserts on top of an empty ytext would then collide
|
|
101
|
+
// with whatever loadInitial pulls from the cloud: Yjs would see
|
|
102
|
+
// both the daemon's "fresh insert" and the cloud's "original
|
|
103
|
+
// insert" as concurrent ops, producing duplicated content. So on
|
|
104
|
+
// first contact (no saved state) we skip the disk merge and let
|
|
105
|
+
// loadInitial + initial flush behave the same as before DOU-209.
|
|
106
|
+
const capturedDiskUpdates = [];
|
|
107
|
+
if (this.initialDocState && this.currentDiskText != null) {
|
|
108
|
+
const restored = this.ytext.toString();
|
|
109
|
+
if (this.currentDiskText !== restored) {
|
|
110
|
+
const captureHandler = (update, origin) => {
|
|
111
|
+
if (origin === 'disk') capturedDiskUpdates.push(update);
|
|
112
|
+
};
|
|
113
|
+
this.doc.on('update', captureHandler);
|
|
114
|
+
// Same diff-and-apply shape as applyDiskText, but we call the
|
|
115
|
+
// internal helper directly: applyDiskText is the live-chokidar
|
|
116
|
+
// path and also touches lastDiskText / _emitDocState in ways
|
|
117
|
+
// that aren't appropriate during the startup-merge phase.
|
|
118
|
+
this._applyTextDiff(restored, this.currentDiskText);
|
|
119
|
+
this.doc.off('update', captureHandler);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// DOU-204: two-phase provider start. Phase 1 loads snapshot +
|
|
124
|
+
// initial updates without subscribing. We then attach the
|
|
125
|
+
// Y.Text observer that drives the debounced disk write. Phase 2
|
|
126
|
+
// subscribes to remote updates; the first listener fire is the
|
|
127
|
+
// initial query result (re-applies the already-loaded docs
|
|
128
|
+
// idempotently), and any update that lands after loadInitial
|
|
129
|
+
// routes through the now-attached observer instead of mutating
|
|
130
|
+
// Y.Text silently between provider.start and observer attach.
|
|
131
|
+
await this.provider.loadInitial();
|
|
132
|
+
this.observer = () => {
|
|
133
|
+
if (this.stopped) return;
|
|
134
|
+
if (this.flushTimer) clearTimeout(this.flushTimer);
|
|
135
|
+
this.flushTimer = setTimeout(() => this._flushNow(), this.debounceMs);
|
|
136
|
+
};
|
|
137
|
+
this.ytext.observe(this.observer);
|
|
138
|
+
this.provider.startSubscription();
|
|
139
|
+
|
|
140
|
+
// Push the disk-side updates captured above so the store records
|
|
141
|
+
// the offline disk edits and other clients receive them. Y.applyUpdate
|
|
142
|
+
// is idempotent so the daemon's own subscription echo (when the
|
|
143
|
+
// store fires its own append back through subscribeUpdates) is a
|
|
144
|
+
// no-op on our local Y.Doc.
|
|
145
|
+
for (const update of capturedDiskUpdates) {
|
|
146
|
+
try { await this.store.appendUpdate(update); }
|
|
147
|
+
catch (err) { console.error('YjsFileBinding: disk-merge appendUpdate failed:', err && err.message); }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Initial flush: ytext may be non-empty after replay (file has
|
|
151
|
+
// existing Yjs state). Writes the loaded content to disk so the
|
|
152
|
+
// local file is up-to-date the moment the binding is wired in.
|
|
153
|
+
if (this.ytext.length > 0) this._flushNow();
|
|
154
|
+
// Persist the merged baseline so the next restart has the right
|
|
155
|
+
// anchor (the binding's state right now == disk after the flush
|
|
156
|
+
// above; future disk-side edits should diff against this state).
|
|
157
|
+
this._emitDocState();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
_applyTextDiff(fromText, toText) {
|
|
161
|
+
if (fromText === toText) return;
|
|
162
|
+
const diffs = fastDiff(fromText, toText);
|
|
163
|
+
this.doc.transact(() => {
|
|
164
|
+
let idx = 0;
|
|
165
|
+
for (const [op, str] of diffs) {
|
|
166
|
+
if (op === 0) {
|
|
167
|
+
idx += str.length;
|
|
168
|
+
} else if (op === 1) {
|
|
169
|
+
this.ytext.insert(idx, str);
|
|
170
|
+
idx += str.length;
|
|
171
|
+
} else {
|
|
172
|
+
this.ytext.delete(idx, str.length);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}, 'disk');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
_emitDocState() {
|
|
179
|
+
if (this.stopped || !this.onDocStateChange) return;
|
|
180
|
+
try {
|
|
181
|
+
this.onDocStateChange(Y.encodeStateAsUpdate(this.doc));
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.error('YjsFileBinding: onDocStateChange failed:', err && err.message);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Sync flush used by both the debounced observer and the final
|
|
188
|
+
// drain in stop().
|
|
189
|
+
_flushNow() {
|
|
190
|
+
if (this.stopped) return;
|
|
191
|
+
if (this.flushTimer) {
|
|
192
|
+
clearTimeout(this.flushTimer);
|
|
193
|
+
this.flushTimer = null;
|
|
194
|
+
}
|
|
195
|
+
const text = this.ytext.toString();
|
|
196
|
+
if (text === this.lastDiskText) return;
|
|
197
|
+
this.lastDiskText = text;
|
|
198
|
+
this.onDiskWrite(text);
|
|
199
|
+
// The disk now matches ytext: snapshot the Y.Doc state so the next
|
|
200
|
+
// restart has the right baseline for offline-merge (DOU-209).
|
|
201
|
+
this._emitDocState();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The local disk file changed (chokidar). Diff against the current
|
|
205
|
+
// ytext and apply as a sequence of inserts/deletes inside one
|
|
206
|
+
// transaction. Using a distinct origin ('disk') keeps the provider's
|
|
207
|
+
// local-update listener pushing the resulting yupdate to Firestore.
|
|
208
|
+
applyDiskText(newText) {
|
|
209
|
+
if (this.stopped) return;
|
|
210
|
+
const oldText = this.ytext.toString();
|
|
211
|
+
if (oldText === newText) return;
|
|
212
|
+
this._applyTextDiff(oldText, newText);
|
|
213
|
+
// Suppress the debounced echo: lastDiskText now matches the disk
|
|
214
|
+
// content the caller just observed, so the observer's pending
|
|
215
|
+
// flush is a no-op.
|
|
216
|
+
this.lastDiskText = newText;
|
|
217
|
+
// Snapshot the Y.Doc state so the next restart's baseline includes
|
|
218
|
+
// this disk-originated edit (DOU-209).
|
|
219
|
+
this._emitDocState();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async stop() {
|
|
223
|
+
if (this.stopped) return;
|
|
224
|
+
this.stopped = true;
|
|
225
|
+
if (this.observer) {
|
|
226
|
+
this.ytext.unobserve(this.observer);
|
|
227
|
+
this.observer = null;
|
|
228
|
+
}
|
|
229
|
+
if (this.flushTimer) {
|
|
230
|
+
clearTimeout(this.flushTimer);
|
|
231
|
+
this.flushTimer = null;
|
|
232
|
+
}
|
|
233
|
+
this.provider.stop();
|
|
234
|
+
this.doc.destroy();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// FirestoreUpdateStore: Firestore-backed store for per-file Yjs
|
|
2
|
+
// CRDT updates. Ported from web/src/yjs/firestore-update-store.ts so
|
|
3
|
+
// the published @doubling/compound-sync package does not reach into
|
|
4
|
+
// the web source tree. Keep the API identical to the web copy; a
|
|
5
|
+
// follow-up will unify the two into a shared workspace package once
|
|
6
|
+
// PR 4 finishes retiring the legacy blob path.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
collection,
|
|
10
|
+
doc,
|
|
11
|
+
addDoc,
|
|
12
|
+
getDoc,
|
|
13
|
+
getDocs,
|
|
14
|
+
setDoc,
|
|
15
|
+
query,
|
|
16
|
+
where,
|
|
17
|
+
orderBy,
|
|
18
|
+
onSnapshot,
|
|
19
|
+
serverTimestamp,
|
|
20
|
+
writeBatch,
|
|
21
|
+
Bytes,
|
|
22
|
+
Timestamp,
|
|
23
|
+
} from 'firebase/firestore';
|
|
24
|
+
|
|
25
|
+
function formatId(createdAt, docId) {
|
|
26
|
+
return `${createdAt.toMillis().toString().padStart(16, '0')}_${docId}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseTimestampFromId(id) {
|
|
30
|
+
return Timestamp.fromMillis(Number(id.split('_')[0]));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class FirestoreUpdateStore {
|
|
34
|
+
constructor(db, orgId, fileId) {
|
|
35
|
+
this.updatesCol = collection(db, 'orgs', orgId, 'files', fileId, 'yupdates');
|
|
36
|
+
this.snapshotDoc = doc(db, 'orgs', orgId, 'files', fileId, 'yjs', 'state');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async loadSnapshot() {
|
|
40
|
+
const snap = await getDoc(this.snapshotDoc);
|
|
41
|
+
if (!snap.exists()) return null;
|
|
42
|
+
const data = snap.data();
|
|
43
|
+
return {
|
|
44
|
+
state: data.state.toUint8Array(),
|
|
45
|
+
baselineId: data.baselineId ?? null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async loadUpdatesAfter(baselineId) {
|
|
50
|
+
const q =
|
|
51
|
+
baselineId === null
|
|
52
|
+
? query(this.updatesCol, orderBy('createdAt', 'asc'))
|
|
53
|
+
: query(
|
|
54
|
+
this.updatesCol,
|
|
55
|
+
where('createdAt', '>', parseTimestampFromId(baselineId)),
|
|
56
|
+
orderBy('createdAt', 'asc'),
|
|
57
|
+
);
|
|
58
|
+
const snap = await getDocs(q);
|
|
59
|
+
return snap.docs
|
|
60
|
+
.map((d) => {
|
|
61
|
+
const data = d.data();
|
|
62
|
+
if (!data.createdAt) return null;
|
|
63
|
+
return { id: formatId(data.createdAt, d.id), update: data.update.toUint8Array() };
|
|
64
|
+
})
|
|
65
|
+
.filter((r) => r !== null);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async appendUpdate(update) {
|
|
69
|
+
const docRef = await addDoc(this.updatesCol, {
|
|
70
|
+
update: Bytes.fromUint8Array(update),
|
|
71
|
+
createdAt: serverTimestamp(),
|
|
72
|
+
});
|
|
73
|
+
const fresh = await getDoc(docRef);
|
|
74
|
+
const data = fresh.data();
|
|
75
|
+
if (!data || !data.createdAt) {
|
|
76
|
+
throw new Error('FirestoreUpdateStore.appendUpdate: createdAt unresolved');
|
|
77
|
+
}
|
|
78
|
+
return { id: formatId(data.createdAt, docRef.id), update };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
subscribeUpdates(onUpdate) {
|
|
82
|
+
const q = query(this.updatesCol, orderBy('createdAt', 'asc'));
|
|
83
|
+
// includeMetadataChanges + emitted-id dedup: see the web copy for
|
|
84
|
+
// the full rationale. Skipping pending writes alone drops the
|
|
85
|
+
// server-ack delivery because that fire is metadata-only.
|
|
86
|
+
const emitted = new Set();
|
|
87
|
+
return onSnapshot(q, { includeMetadataChanges: true }, (snap) => {
|
|
88
|
+
for (const change of snap.docChanges({ includeMetadataChanges: true })) {
|
|
89
|
+
if (change.type !== 'added' && change.type !== 'modified') continue;
|
|
90
|
+
const data = change.doc.data();
|
|
91
|
+
if (!data.createdAt) continue;
|
|
92
|
+
if (emitted.has(change.doc.id)) continue;
|
|
93
|
+
emitted.add(change.doc.id);
|
|
94
|
+
onUpdate({
|
|
95
|
+
id: formatId(data.createdAt, change.doc.id),
|
|
96
|
+
update: data.update.toUint8Array(),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async saveSnapshotAndCompact(state, newBaselineId) {
|
|
103
|
+
const baselineTs = parseTimestampFromId(newBaselineId);
|
|
104
|
+
await setDoc(this.snapshotDoc, {
|
|
105
|
+
state: Bytes.fromUint8Array(state),
|
|
106
|
+
baselineId: newBaselineId,
|
|
107
|
+
baselineCreatedAt: baselineTs,
|
|
108
|
+
updatedAt: serverTimestamp(),
|
|
109
|
+
});
|
|
110
|
+
const q = query(this.updatesCol, where('createdAt', '<=', baselineTs));
|
|
111
|
+
const stale = await getDocs(q);
|
|
112
|
+
if (stale.empty) return;
|
|
113
|
+
const batch = writeBatch(this.snapshotDoc.firestore);
|
|
114
|
+
for (const d of stale.docs) batch.delete(d.ref);
|
|
115
|
+
await batch.commit();
|
|
116
|
+
}
|
|
117
|
+
}
|
package/yjs-provider.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// YjsProvider: binds a Y.Doc to an UpdateStore. Ported from
|
|
2
|
+
// web/src/yjs/provider.ts; see that file for the full design notes
|
|
3
|
+
// (echo handling, snapshot replay, compaction semantics, two-phase
|
|
4
|
+
// start lifecycle from DOU-204). API kept identical so the two copies
|
|
5
|
+
// can be unified into a shared workspace package in a follow-up.
|
|
6
|
+
|
|
7
|
+
import * as Y from 'yjs';
|
|
8
|
+
|
|
9
|
+
export class YjsProvider {
|
|
10
|
+
constructor(doc, store) {
|
|
11
|
+
this.doc = doc;
|
|
12
|
+
this.store = store;
|
|
13
|
+
this.unsubscribeStore = null;
|
|
14
|
+
this.updateHandler = null;
|
|
15
|
+
this.loaded = false;
|
|
16
|
+
this.subscribed = false;
|
|
17
|
+
this.stopped = false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Phase 1: snapshot + initial updates → Y.Doc. No remote
|
|
21
|
+
* subscription yet. Callers that intend to attach a Y.Text observer
|
|
22
|
+
* (e.g. a disk-write debouncer here, yCollab on the web) should
|
|
23
|
+
* install that observer between `loadInitial()` and
|
|
24
|
+
* `startSubscription()` so the first remote update routes through
|
|
25
|
+
* to it instead of mutating Y.Text silently in the gap (DOU-204). */
|
|
26
|
+
async loadInitial() {
|
|
27
|
+
if (this.loaded) throw new Error('YjsProvider.loadInitial() called twice');
|
|
28
|
+
this.loaded = true;
|
|
29
|
+
|
|
30
|
+
const snapshot = await this.store.loadSnapshot();
|
|
31
|
+
if (snapshot) Y.applyUpdate(this.doc, snapshot.state, this);
|
|
32
|
+
|
|
33
|
+
const initial = await this.store.loadUpdatesAfter(snapshot?.baselineId ?? null);
|
|
34
|
+
for (const record of initial) Y.applyUpdate(this.doc, record.update, this);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Phase 2: subscribe to remote updates and push local Y.Doc
|
|
38
|
+
* mutations back to the store. */
|
|
39
|
+
startSubscription() {
|
|
40
|
+
if (!this.loaded) throw new Error('YjsProvider.startSubscription() before loadInitial()');
|
|
41
|
+
if (this.subscribed) throw new Error('YjsProvider.startSubscription() called twice');
|
|
42
|
+
this.subscribed = true;
|
|
43
|
+
|
|
44
|
+
this.unsubscribeStore = this.store.subscribeUpdates((record) => {
|
|
45
|
+
if (this.stopped) return;
|
|
46
|
+
Y.applyUpdate(this.doc, record.update, this);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
this.updateHandler = (update, origin) => {
|
|
50
|
+
if (this.stopped) return;
|
|
51
|
+
if (origin === this) return;
|
|
52
|
+
this.store.appendUpdate(update).catch((err) => {
|
|
53
|
+
console.error('YjsProvider: appendUpdate failed:', err && err.message);
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
this.doc.on('update', this.updateHandler);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Convenience: load + subscribe in one call. Use only when no
|
|
60
|
+
* view-layer observer needs to attach between the phases. */
|
|
61
|
+
async start() {
|
|
62
|
+
await this.loadInitial();
|
|
63
|
+
this.startSubscription();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
stop() {
|
|
67
|
+
this.stopped = true;
|
|
68
|
+
if (this.unsubscribeStore) {
|
|
69
|
+
this.unsubscribeStore();
|
|
70
|
+
this.unsubscribeStore = null;
|
|
71
|
+
}
|
|
72
|
+
if (this.updateHandler) {
|
|
73
|
+
this.doc.off('update', this.updateHandler);
|
|
74
|
+
this.updateHandler = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async compact() {
|
|
79
|
+
const state = Y.encodeStateAsUpdate(this.doc);
|
|
80
|
+
const records = await this.store.loadUpdatesAfter(null);
|
|
81
|
+
const last = records[records.length - 1];
|
|
82
|
+
if (!last) return;
|
|
83
|
+
await this.store.saveSnapshotAndCompact(state, last.id);
|
|
84
|
+
}
|
|
85
|
+
}
|