@doubling/compound-sync 1.12.4 → 1.12.6

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.
@@ -16,221 +16,234 @@
16
16
  // touching the filesystem and so the daemon can plumb its existing
17
17
  // suppressLocal set (avoids the disk write feeding back through
18
18
  // chokidar as a fake user edit).
19
-
20
19
  import * as Y from 'yjs';
21
20
  import fastDiff from 'fast-diff';
22
21
  import { YjsProvider } from './yjs-provider.js';
23
-
24
22
  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);
23
+ doc;
24
+ ytext;
25
+ store;
26
+ provider;
27
+ debounceMs;
28
+ onDiskWrite;
29
+ onDocStateChange;
30
+ initialDocState;
31
+ currentDiskText;
32
+ flushTimer = null;
33
+ lastDiskText = null;
34
+ observer = null;
35
+ stopped = false;
36
+ constructor(store, { debounceMs = 300, onDiskWrite, onDocStateChange, initialDocState = null, currentDiskText = null, }) {
37
+ if (!onDiskWrite)
38
+ throw new Error('YjsFileBinding: onDiskWrite is required');
39
+ this.doc = new Y.Doc();
40
+ this.ytext = this.doc.getText('content');
41
+ this.store = store;
42
+ this.provider = new YjsProvider(this.doc, store);
43
+ this.debounceMs = debounceMs;
44
+ this.onDiskWrite = onDiskWrite;
45
+ // Called after every Y.Doc mutation reaches a stable state, with
46
+ // the encoded Y.Doc binary state. The daemon persists this to
47
+ // disk so the next startup can restore the baseline and merge
48
+ // offline disk edits without losing offline cloud edits (DOU-209).
49
+ this.onDocStateChange = onDocStateChange ?? null;
50
+ // Restore options for offline-merge (DOU-209).
51
+ this.initialDocState = initialDocState;
52
+ this.currentDiskText = currentDiskText;
84
53
  }
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);
54
+ async start() {
55
+ // DOU-209: offline-merge startup. Three-step process that turns the
56
+ // daemon's restart into a deterministic CRDT merge:
57
+ //
58
+ // 1. Restore Y.Doc from the daemon's last-saved binary state (if
59
+ // any). After this, ytext equals what disk was at the daemon's
60
+ // last write. This is the common ancestor for any divergence
61
+ // that happened while the daemon was offline.
62
+ // 2. If the current disk content differs from the restored ytext,
63
+ // the difference is disk-only edits made during downtime.
64
+ // Apply them as Y.Doc operations (positions are valid in the
65
+ // restored ytext since restored-ytext == lastDisk == fast-diff
66
+ // input).
67
+ // 3. provider.loadInitial() pulls cloud's yupdates on top. Y.applyUpdate
68
+ // is idempotent (Yjs's state-vector dedup), so already-known
69
+ // ops are no-ops. Only NEW cloud ops (offline web edits) take
70
+ // effect. They CRDT-merge with the disk ops from step 2.
71
+ //
72
+ // Without step 1, fast-diff(post-loadInitial-ytext, currentDisk)
73
+ // treats web's offline insertions as "things present in ytext but
74
+ // missing from disk" and DELETES them. That's the bug this fixes.
75
+ // Seed lastDiskText from the disk content we observed at construction
76
+ // time so the initial flush is a no-op when ytext already matches
77
+ // what's on disk. Without this, the binding's initial flush always
78
+ // wrote ytext to disk on startup, which clobbered any content the
79
+ // daemon's legacy contentHash + Storage blob pull (the fallthrough
80
+ // path in handleFirestoreChange) may have written first for non-Yjs
81
+ // cloud edits. Regression case: "after restart, pulls a web edit
82
+ // made while stopped despite a fresh local mtime" in
83
+ // sync/org-sync.test.js.
84
+ this.lastDiskText = this.currentDiskText;
85
+ if (this.initialDocState) {
86
+ Y.applyUpdate(this.doc, this.initialDocState);
87
+ }
88
+ // Disk-merge ops emitted below need to reach the store so other
89
+ // clients see the offline disk edits. The provider's local-update
90
+ // handler isn't attached yet (it's wired in startSubscription,
91
+ // which we can't call until after loadInitial), so we capture the
92
+ // updates synchronously here and push them after the subscription
93
+ // is set up. Capture by Y.Doc 'update' event with a 'disk' origin
94
+ // filter so we only grab the ops from our own _applyTextDiff and
95
+ // not anything spurious.
96
+ //
97
+ // The disk-merge is only safe when we have an `initialDocState`:
98
+ // that gives ytext a known baseline (the daemon's last view of the
99
+ // file) before we diff against currentDiskText, so fast-diff
100
+ // positions are valid AND the resulting ops are scoped to JUST the
101
+ // disk-only changes. Without a baseline, applying currentDiskText
102
+ // as fresh inserts on top of an empty ytext would then collide
103
+ // with whatever loadInitial pulls from the cloud: Yjs would see
104
+ // both the daemon's "fresh insert" and the cloud's "original
105
+ // insert" as concurrent ops, producing duplicated content. So on
106
+ // first contact (no saved state) we skip the disk merge and let
107
+ // loadInitial + initial flush behave the same as before DOU-209.
108
+ const capturedDiskUpdates = [];
109
+ if (this.initialDocState && this.currentDiskText != null) {
110
+ const restored = this.ytext.toString();
111
+ if (this.currentDiskText !== restored) {
112
+ const captureHandler = (update, origin) => {
113
+ if (origin === 'disk')
114
+ capturedDiskUpdates.push(update);
115
+ };
116
+ this.doc.on('update', captureHandler);
117
+ // Same diff-and-apply shape as applyDiskText, but we call the
118
+ // internal helper directly: applyDiskText is the live-chokidar
119
+ // path and also touches lastDiskText / _emitDocState in ways
120
+ // that aren't appropriate during the startup-merge phase.
121
+ this._applyTextDiff(restored, this.currentDiskText);
122
+ this.doc.off('update', captureHandler);
123
+ }
124
+ }
125
+ // DOU-204: two-phase provider start. Phase 1 loads snapshot +
126
+ // initial updates without subscribing. We then attach the
127
+ // Y.Text observer that drives the debounced disk write. Phase 2
128
+ // subscribes to remote updates; the first listener fire is the
129
+ // initial query result (re-applies the already-loaded docs
130
+ // idempotently), and any update that lands after loadInitial
131
+ // routes through the now-attached observer instead of mutating
132
+ // Y.Text silently between provider.start and observer attach.
133
+ await this.provider.loadInitial();
134
+ this.observer = () => {
135
+ if (this.stopped)
136
+ return;
137
+ if (this.flushTimer)
138
+ clearTimeout(this.flushTimer);
139
+ this.flushTimer = setTimeout(() => this._flushNow(), this.debounceMs);
112
140
  };
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
- }
141
+ this.ytext.observe(this.observer);
142
+ this.provider.startSubscription();
143
+ // Push the disk-side updates captured above so the store records
144
+ // the offline disk edits and other clients receive them. Y.applyUpdate
145
+ // is idempotent so the daemon's own subscription echo (when the
146
+ // store fires its own append back through subscribeUpdates) is a
147
+ // no-op on our local Y.Doc.
148
+ for (const update of capturedDiskUpdates) {
149
+ try {
150
+ await this.store.appendUpdate(update);
151
+ }
152
+ catch (err) {
153
+ console.error('YjsFileBinding: disk-merge appendUpdate failed:', err?.message);
154
+ }
155
+ }
156
+ // Initial flush: ytext may be non-empty after replay (file has
157
+ // existing Yjs state). Writes the loaded content to disk so the
158
+ // local file is up-to-date the moment the binding is wired in.
159
+ if (this.ytext.length > 0)
160
+ this._flushNow();
161
+ // Persist the merged baseline so the next restart has the right
162
+ // anchor (the binding's state right now == disk after the flush
163
+ // above; future disk-side edits should diff against this state).
164
+ this._emitDocState();
121
165
  }
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); }
166
+ _applyTextDiff(fromText, toText) {
167
+ if (fromText === toText)
168
+ return;
169
+ const diffs = fastDiff(fromText, toText);
170
+ this.doc.transact(() => {
171
+ let idx = 0;
172
+ for (const [op, str] of diffs) {
173
+ if (op === 0) {
174
+ idx += str.length;
175
+ }
176
+ else if (op === 1) {
177
+ this.ytext.insert(idx, str);
178
+ idx += str.length;
179
+ }
180
+ else {
181
+ this.ytext.delete(idx, str.length);
182
+ }
183
+ }
184
+ }, 'disk');
148
185
  }
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);
186
+ _emitDocState() {
187
+ if (this.stopped || !this.onDocStateChange)
188
+ return;
189
+ try {
190
+ this.onDocStateChange(Y.encodeStateAsUpdate(this.doc));
191
+ }
192
+ catch (err) {
193
+ console.error('YjsFileBinding: onDocStateChange failed:', err?.message);
173
194
  }
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
195
  }
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;
196
+ // Sync flush used by both the debounced observer and the final
197
+ // drain in stop().
198
+ _flushNow() {
199
+ if (this.stopped)
200
+ return;
201
+ if (this.flushTimer) {
202
+ clearTimeout(this.flushTimer);
203
+ this.flushTimer = null;
204
+ }
205
+ const text = this.ytext.toString();
206
+ if (text === this.lastDiskText)
207
+ return;
208
+ this.lastDiskText = text;
209
+ this.onDiskWrite(text);
210
+ // The disk now matches ytext: snapshot the Y.Doc state so the next
211
+ // restart has the right baseline for offline-merge (DOU-209).
212
+ this._emitDocState();
194
213
  }
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;
214
+ // The local disk file changed (chokidar). Diff against the current
215
+ // ytext and apply as a sequence of inserts/deletes inside one
216
+ // transaction. Using a distinct origin ('disk') keeps the provider's
217
+ // local-update listener pushing the resulting yupdate to Firestore.
218
+ applyDiskText(newText) {
219
+ if (this.stopped)
220
+ return;
221
+ const oldText = this.ytext.toString();
222
+ if (oldText === newText)
223
+ return;
224
+ this._applyTextDiff(oldText, newText);
225
+ // Suppress the debounced echo: lastDiskText now matches the disk
226
+ // content the caller just observed, so the observer's pending
227
+ // flush is a no-op.
228
+ this.lastDiskText = newText;
229
+ // Snapshot the Y.Doc state so the next restart's baseline includes
230
+ // this disk-originated edit (DOU-209).
231
+ this._emitDocState();
228
232
  }
229
- if (this.flushTimer) {
230
- clearTimeout(this.flushTimer);
231
- this.flushTimer = null;
233
+ async stop() {
234
+ if (this.stopped)
235
+ return;
236
+ this.stopped = true;
237
+ if (this.observer) {
238
+ this.ytext.unobserve(this.observer);
239
+ this.observer = null;
240
+ }
241
+ if (this.flushTimer) {
242
+ clearTimeout(this.flushTimer);
243
+ this.flushTimer = null;
244
+ }
245
+ this.provider.stop();
246
+ this.doc.destroy();
232
247
  }
233
- this.provider.stop();
234
- this.doc.destroy();
235
- }
236
248
  }
249
+ //# sourceMappingURL=yjs-file-binding.js.map
@@ -0,0 +1,13 @@
1
+ import { type Firestore, type Unsubscribe } from 'firebase/firestore';
2
+ import type { YjsSnapshot, YjsUpdateListener, YjsUpdateRecord, YjsUpdateStore } from './yjs-provider.js';
3
+ export declare class FirestoreUpdateStore implements YjsUpdateStore {
4
+ private readonly updatesCol;
5
+ private readonly snapshotDoc;
6
+ constructor(db: Firestore, orgId: string, fileId: string);
7
+ loadSnapshot(): Promise<YjsSnapshot | null>;
8
+ loadUpdatesAfter(baselineId: string | null): Promise<YjsUpdateRecord[]>;
9
+ appendUpdate(update: Uint8Array): Promise<YjsUpdateRecord>;
10
+ subscribeUpdates(onUpdate: YjsUpdateListener): Unsubscribe;
11
+ saveSnapshotAndCompact(state: Uint8Array, newBaselineId: string): Promise<void>;
12
+ }
13
+ //# sourceMappingURL=yjs-firestore-update-store.d.ts.map