@doubling/compound-sync 1.12.4 → 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.
- package/auth-persistence.d.ts +22 -0
- package/auth-persistence.js +79 -83
- package/config.d.ts +13 -0
- package/config.js +55 -54
- package/files.d.ts +221 -0
- package/files.js +373 -438
- package/folder-chain.d.ts +44 -0
- package/folder-chain.js +52 -59
- package/manifest.d.ts +38 -0
- package/manifest.js +87 -89
- package/org-sync.d.ts +16 -0
- package/org-sync.js +1156 -1203
- package/package.json +23 -2
- package/paths.d.ts +1 -1
- package/pre-mint-app-check.d.ts +6 -0
- package/pre-mint-app-check.js +46 -39
- package/push-outcome.d.ts +9 -0
- package/push-outcome.js +6 -5
- package/setup-auth-with-pre-mint.d.ts +24 -0
- package/setup-auth-with-pre-mint.js +48 -55
- package/storage-helpers.d.ts +14 -0
- package/storage-helpers.js +43 -107
- package/sync-state.d.ts +4 -0
- package/sync-state.js +30 -16
- package/sync.d.ts +18 -0
- package/sync.js +464 -515
- package/team-registry.d.ts +41 -0
- package/team-registry.js +90 -81
- package/yjs-binding-state.d.ts +4 -0
- package/yjs-binding-state.js +25 -22
- package/yjs-file-binding.d.ts +32 -0
- package/yjs-file-binding.js +217 -204
- package/yjs-firestore-update-store.d.ts +13 -0
- package/yjs-firestore-update-store.js +99 -103
- package/yjs-provider.d.ts +43 -0
- package/yjs-provider.js +79 -75
|
@@ -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
|
-
|
|
9
|
+
return `${createdAt.toMillis().toString().padStart(16, '0')}_${docId}`;
|
|
27
10
|
}
|
|
28
|
-
|
|
29
11
|
function parseTimestampFromId(id) {
|
|
30
|
-
|
|
12
|
+
const [millisStr] = id.split('_');
|
|
13
|
+
return Timestamp.fromMillis(Number(millisStr));
|
|
31
14
|
}
|
|
32
|
-
|
|
33
15
|
export class FirestoreUpdateStore {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|