@doubling/compound-sync 1.12.3 → 1.12.5

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.
@@ -4,114 +4,110 @@
4
4
  // the web source tree. Keep the API identical to the web copy; a
5
5
  // follow-up will unify the two into a shared workspace package once
6
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
-
7
+ import { collection, doc, addDoc, getDoc, getDocs, setDoc, query, where, orderBy, onSnapshot, serverTimestamp, writeBatch, Bytes, Timestamp, } from 'firebase/firestore';
25
8
  function formatId(createdAt, docId) {
26
- return `${createdAt.toMillis().toString().padStart(16, '0')}_${docId}`;
9
+ return `${createdAt.toMillis().toString().padStart(16, '0')}_${docId}`;
27
10
  }
28
-
29
11
  function parseTimestampFromId(id) {
30
- return Timestamp.fromMillis(Number(id.split('_')[0]));
12
+ const [millisStr] = id.split('_');
13
+ return Timestamp.fromMillis(Number(millisStr));
31
14
  }
32
-
33
15
  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');
16
+ updatesCol;
17
+ snapshotDoc;
18
+ constructor(db, orgId, fileId) {
19
+ this.updatesCol = collection(db, 'orgs', orgId, 'files', fileId, 'yupdates');
20
+ this.snapshotDoc = doc(db, 'orgs', orgId, 'files', fileId, 'yjs', 'state');
77
21
  }
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(),
22
+ async loadSnapshot() {
23
+ const snap = await getDoc(this.snapshotDoc);
24
+ if (!snap.exists())
25
+ return null;
26
+ const data = snap.data();
27
+ const state = data['state'];
28
+ if (!state)
29
+ return null;
30
+ // Pre-DOU-204 snapshot docs may not carry baselineId; surface that
31
+ // as null instead of coercing to '' (which would then crash
32
+ // parseTimestampFromId on the next loadUpdatesAfter call). null
33
+ // tells the provider "no known baseline" and loadUpdatesAfter
34
+ // takes the unfiltered query path.
35
+ const baselineId = data['baselineId'];
36
+ return {
37
+ state: state.toUint8Array(),
38
+ baselineId: typeof baselineId === 'string' && baselineId ? baselineId : null,
39
+ };
40
+ }
41
+ async loadUpdatesAfter(baselineId) {
42
+ const q = baselineId === null
43
+ ? query(this.updatesCol, orderBy('createdAt', 'asc'))
44
+ : query(this.updatesCol, where('createdAt', '>', parseTimestampFromId(baselineId)), orderBy('createdAt', 'asc'));
45
+ const snap = await getDocs(q);
46
+ const out = [];
47
+ for (const d of snap.docs) {
48
+ const data = d.data();
49
+ const createdAt = data['createdAt'];
50
+ const update = data['update'];
51
+ if (!createdAt || !update)
52
+ continue;
53
+ out.push({ id: formatId(createdAt, d.id), update: update.toUint8Array() });
54
+ }
55
+ return out;
56
+ }
57
+ async appendUpdate(update) {
58
+ const docRef = await addDoc(this.updatesCol, {
59
+ update: Bytes.fromUint8Array(update),
60
+ createdAt: serverTimestamp(),
61
+ });
62
+ const fresh = await getDoc(docRef);
63
+ const data = fresh.data();
64
+ const createdAt = data?.['createdAt'];
65
+ if (!createdAt) {
66
+ throw new Error('FirestoreUpdateStore.appendUpdate: createdAt unresolved');
67
+ }
68
+ return { id: formatId(createdAt, docRef.id), update };
69
+ }
70
+ subscribeUpdates(onUpdate) {
71
+ const q = query(this.updatesCol, orderBy('createdAt', 'asc'));
72
+ // includeMetadataChanges + emitted-id dedup: see the web copy for
73
+ // the full rationale. Skipping pending writes alone drops the
74
+ // server-ack delivery because that fire is metadata-only.
75
+ const emitted = new Set();
76
+ return onSnapshot(q, { includeMetadataChanges: true }, (snap) => {
77
+ for (const change of snap.docChanges({ includeMetadataChanges: true })) {
78
+ if (change.type !== 'added' && change.type !== 'modified')
79
+ continue;
80
+ const data = change.doc.data();
81
+ const createdAt = data['createdAt'];
82
+ const update = data['update'];
83
+ if (!createdAt || !update)
84
+ continue;
85
+ if (emitted.has(change.doc.id))
86
+ continue;
87
+ emitted.add(change.doc.id);
88
+ onUpdate({
89
+ id: formatId(createdAt, change.doc.id),
90
+ update: update.toUint8Array(),
91
+ });
92
+ }
97
93
  });
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
- }
94
+ }
95
+ async saveSnapshotAndCompact(state, newBaselineId) {
96
+ const baselineTs = parseTimestampFromId(newBaselineId);
97
+ await setDoc(this.snapshotDoc, {
98
+ state: Bytes.fromUint8Array(state),
99
+ baselineId: newBaselineId,
100
+ baselineCreatedAt: baselineTs,
101
+ updatedAt: serverTimestamp(),
102
+ });
103
+ const q = query(this.updatesCol, where('createdAt', '<=', baselineTs));
104
+ const stale = await getDocs(q);
105
+ if (stale.empty)
106
+ return;
107
+ const batch = writeBatch(this.snapshotDoc.firestore);
108
+ for (const d of stale.docs)
109
+ batch.delete(d.ref);
110
+ await batch.commit();
111
+ }
117
112
  }
113
+ //# sourceMappingURL=yjs-firestore-update-store.js.map
@@ -0,0 +1,43 @@
1
+ import * as Y from 'yjs';
2
+ export interface YjsUpdateRecord {
3
+ id: string;
4
+ update: Uint8Array;
5
+ }
6
+ export interface YjsSnapshot {
7
+ state: Uint8Array;
8
+ baselineId: string | null;
9
+ }
10
+ export type YjsUpdateListener = (record: YjsUpdateRecord) => void;
11
+ export interface YjsUpdateStore {
12
+ loadSnapshot(): Promise<YjsSnapshot | null>;
13
+ loadUpdatesAfter(baselineId: string | null): Promise<YjsUpdateRecord[]>;
14
+ appendUpdate(update: Uint8Array): Promise<YjsUpdateRecord>;
15
+ subscribeUpdates(onUpdate: YjsUpdateListener): () => void;
16
+ saveSnapshotAndCompact(state: Uint8Array, newBaselineId: string): Promise<void>;
17
+ }
18
+ export declare class YjsProvider {
19
+ private readonly doc;
20
+ private readonly store;
21
+ private unsubscribeStore;
22
+ private updateHandler;
23
+ private loaded;
24
+ private subscribed;
25
+ private stopped;
26
+ constructor(doc: Y.Doc, store: YjsUpdateStore);
27
+ /** Phase 1: snapshot + initial updates → Y.Doc. No remote
28
+ * subscription yet. Callers that intend to attach a Y.Text observer
29
+ * (e.g. a disk-write debouncer here, yCollab on the web) should
30
+ * install that observer between `loadInitial()` and
31
+ * `startSubscription()` so the first remote update routes through
32
+ * to it instead of mutating Y.Text silently in the gap (DOU-204). */
33
+ loadInitial(): Promise<void>;
34
+ /** Phase 2: subscribe to remote updates and push local Y.Doc
35
+ * mutations back to the store. */
36
+ startSubscription(): void;
37
+ /** Convenience: load + subscribe in one call. Use only when no
38
+ * view-layer observer needs to attach between the phases. */
39
+ start(): Promise<void>;
40
+ stop(): void;
41
+ compact(): Promise<void>;
42
+ }
43
+ //# sourceMappingURL=yjs-provider.d.ts.map
package/yjs-provider.js CHANGED
@@ -3,83 +3,87 @@
3
3
  // (echo handling, snapshot replay, compaction semantics, two-phase
4
4
  // start lifecycle from DOU-204). API kept identical so the two copies
5
5
  // can be unified into a shared workspace package in a follow-up.
6
-
7
6
  import * as Y from 'yjs';
8
-
9
7
  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;
8
+ doc;
9
+ store;
10
+ unsubscribeStore = null;
11
+ updateHandler = null;
12
+ loaded = false;
13
+ subscribed = false;
14
+ stopped = false;
15
+ constructor(doc, store) {
16
+ this.doc = doc;
17
+ this.store = store;
71
18
  }
72
- if (this.updateHandler) {
73
- this.doc.off('update', this.updateHandler);
74
- this.updateHandler = null;
19
+ /** Phase 1: snapshot + initial updates → Y.Doc. No remote
20
+ * subscription yet. Callers that intend to attach a Y.Text observer
21
+ * (e.g. a disk-write debouncer here, yCollab on the web) should
22
+ * install that observer between `loadInitial()` and
23
+ * `startSubscription()` so the first remote update routes through
24
+ * to it instead of mutating Y.Text silently in the gap (DOU-204). */
25
+ async loadInitial() {
26
+ if (this.loaded)
27
+ throw new Error('YjsProvider.loadInitial() called twice');
28
+ this.loaded = true;
29
+ const snapshot = await this.store.loadSnapshot();
30
+ if (snapshot)
31
+ Y.applyUpdate(this.doc, snapshot.state, this);
32
+ const initial = await this.store.loadUpdatesAfter(snapshot?.baselineId ?? null);
33
+ for (const record of initial)
34
+ Y.applyUpdate(this.doc, record.update, this);
35
+ }
36
+ /** Phase 2: subscribe to remote updates and push local Y.Doc
37
+ * mutations back to the store. */
38
+ startSubscription() {
39
+ if (!this.loaded)
40
+ throw new Error('YjsProvider.startSubscription() before loadInitial()');
41
+ if (this.subscribed)
42
+ throw new Error('YjsProvider.startSubscription() called twice');
43
+ this.subscribed = true;
44
+ this.unsubscribeStore = this.store.subscribeUpdates((record) => {
45
+ if (this.stopped)
46
+ return;
47
+ Y.applyUpdate(this.doc, record.update, this);
48
+ });
49
+ this.updateHandler = (update, origin) => {
50
+ if (this.stopped)
51
+ return;
52
+ if (origin === this)
53
+ return;
54
+ this.store.appendUpdate(update).catch((err) => {
55
+ // Log the full error so non-Error throws and stack traces
56
+ // survive into ops dashboards instead of being silently
57
+ // truncated to a `.message` string.
58
+ console.error('YjsProvider: appendUpdate failed:', err);
59
+ });
60
+ };
61
+ this.doc.on('update', this.updateHandler);
62
+ }
63
+ /** Convenience: load + subscribe in one call. Use only when no
64
+ * view-layer observer needs to attach between the phases. */
65
+ async start() {
66
+ await this.loadInitial();
67
+ this.startSubscription();
68
+ }
69
+ stop() {
70
+ this.stopped = true;
71
+ if (this.unsubscribeStore) {
72
+ this.unsubscribeStore();
73
+ this.unsubscribeStore = null;
74
+ }
75
+ if (this.updateHandler) {
76
+ this.doc.off('update', this.updateHandler);
77
+ this.updateHandler = null;
78
+ }
79
+ }
80
+ async compact() {
81
+ const state = Y.encodeStateAsUpdate(this.doc);
82
+ const records = await this.store.loadUpdatesAfter(null);
83
+ const last = records[records.length - 1];
84
+ if (!last)
85
+ return;
86
+ await this.store.saveSnapshotAndCompact(state, last.id);
75
87
  }
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
88
  }
89
+ //# sourceMappingURL=yjs-provider.js.map