@doubling/compound-sync 1.1.1 → 1.2.1
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/dual-write.js +213 -0
- package/package.json +15 -3
- package/sync.js +39 -44
package/dual-write.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Dual-write helpers for the sync daemon and integration tests.
|
|
2
|
+
//
|
|
3
|
+
// Each function takes its dependencies (filesRef, storage, orgId, etc.)
|
|
4
|
+
// as parameters so the logic is importable into a test harness that
|
|
5
|
+
// provides emulator-backed Firebase handles. The sync daemon (sync.js)
|
|
6
|
+
// is the production caller and supplies real-Firebase handles.
|
|
7
|
+
//
|
|
8
|
+
// Side effect ordering on writes:
|
|
9
|
+
// 1. Write the Firestore metadata document (Storage rules require
|
|
10
|
+
// the metadata doc to exist before the upload is authorized).
|
|
11
|
+
// 2. Upload the blob to Cloud Storage at the deterministic path
|
|
12
|
+
// derived from orgId + fileId.
|
|
13
|
+
// 3. Storage failures are returned in the result, not thrown, so the
|
|
14
|
+
// caller can decide whether to surface as a soft warning while the
|
|
15
|
+
// Firestore content field still holds the up-to-date value.
|
|
16
|
+
|
|
17
|
+
import crypto from 'crypto';
|
|
18
|
+
import {
|
|
19
|
+
doc, getDocs, setDoc, updateDoc, deleteDoc, query, where,
|
|
20
|
+
} from 'firebase/firestore';
|
|
21
|
+
import {
|
|
22
|
+
ref as storageRef, uploadString, deleteObject,
|
|
23
|
+
} from 'firebase/storage';
|
|
24
|
+
|
|
25
|
+
// ---- Pure helpers ----
|
|
26
|
+
|
|
27
|
+
export function computeContentHash(content) {
|
|
28
|
+
return crypto.createHash('sha256').update(content || '', 'utf-8').digest('hex');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function storagePathFor(orgId, fileId) {
|
|
32
|
+
return `orgs/${orgId}/files/${fileId}/current.md`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---- Storage primitives ----
|
|
36
|
+
|
|
37
|
+
export async function uploadBlob({ storage, orgId, fileId, content }) {
|
|
38
|
+
const blobPath = storagePathFor(orgId, fileId);
|
|
39
|
+
await uploadString(storageRef(storage, blobPath), content || '', 'raw', {
|
|
40
|
+
contentType: 'text/markdown',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function deleteBlob({ storage, orgId, fileId }) {
|
|
45
|
+
const blobPath = storagePathFor(orgId, fileId);
|
|
46
|
+
try {
|
|
47
|
+
await deleteObject(storageRef(storage, blobPath));
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if (err && err.code === 'storage/object-not-found') {
|
|
50
|
+
return; // tolerate missing blob (older files written before PR 2)
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---- Core dual-write ----
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Write a file's content to both the Firestore `content` field and a
|
|
60
|
+
* Cloud Storage blob at `orgs/{orgId}/files/{fileId}/current.md`.
|
|
61
|
+
*
|
|
62
|
+
* Returns one of:
|
|
63
|
+
* { action: 'skipped', reason: 'shared-scope' }
|
|
64
|
+
* { action: 'noop', fileId }
|
|
65
|
+
* { action: 'created', fileId, fileData, storageError? }
|
|
66
|
+
* { action: 'updated', fileId, storageError? }
|
|
67
|
+
*
|
|
68
|
+
* `storageError` is populated when the Firestore write succeeded but
|
|
69
|
+
* the Storage upload failed. Callers should log and continue; the next
|
|
70
|
+
* save will re-upload because the hash on the metadata will not match.
|
|
71
|
+
*/
|
|
72
|
+
export async function pushFileToCloud({
|
|
73
|
+
filesRef,
|
|
74
|
+
storage,
|
|
75
|
+
orgId,
|
|
76
|
+
userId,
|
|
77
|
+
scope,
|
|
78
|
+
teamId,
|
|
79
|
+
docPath,
|
|
80
|
+
content,
|
|
81
|
+
now = new Date().toISOString(),
|
|
82
|
+
}) {
|
|
83
|
+
if (scope === 'shared-with-me' || scope === 'shared-by-me') {
|
|
84
|
+
return { action: 'skipped', reason: 'shared-scope' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let snapshot;
|
|
88
|
+
if (scope === 'private') {
|
|
89
|
+
snapshot = await getDocs(query(filesRef,
|
|
90
|
+
where('scope', '==', 'private'),
|
|
91
|
+
where('ownerId', '==', userId),
|
|
92
|
+
where('path', '==', docPath)
|
|
93
|
+
));
|
|
94
|
+
} else if (scope === 'team' && teamId) {
|
|
95
|
+
snapshot = await getDocs(query(filesRef,
|
|
96
|
+
where('scope', '==', 'team'),
|
|
97
|
+
where('teamId', '==', teamId),
|
|
98
|
+
where('path', '==', docPath)
|
|
99
|
+
));
|
|
100
|
+
} else {
|
|
101
|
+
snapshot = await getDocs(query(filesRef, where('path', '==', docPath)));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const contentHash = computeContentHash(content);
|
|
105
|
+
const size = Buffer.byteLength(content || '', 'utf-8');
|
|
106
|
+
|
|
107
|
+
if (!snapshot || snapshot.empty) {
|
|
108
|
+
const newDocRef = doc(filesRef);
|
|
109
|
+
const fileId = newDocRef.id;
|
|
110
|
+
const fileData = {
|
|
111
|
+
path: docPath,
|
|
112
|
+
content,
|
|
113
|
+
createdAt: now,
|
|
114
|
+
updatedAt: now,
|
|
115
|
+
scope,
|
|
116
|
+
teamId: scope === 'team' ? (teamId || null) : null,
|
|
117
|
+
ownerId: scope === 'private' ? userId : null,
|
|
118
|
+
sharedWith: [],
|
|
119
|
+
storagePath: storagePathFor(orgId, fileId),
|
|
120
|
+
contentHash,
|
|
121
|
+
size,
|
|
122
|
+
};
|
|
123
|
+
await setDoc(newDocRef, fileData);
|
|
124
|
+
let storageError = null;
|
|
125
|
+
try {
|
|
126
|
+
await uploadBlob({ storage, orgId, fileId, content });
|
|
127
|
+
} catch (err) {
|
|
128
|
+
storageError = err;
|
|
129
|
+
}
|
|
130
|
+
return { action: 'created', fileId, fileData, storageError };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const docSnap = snapshot.docs[0];
|
|
134
|
+
const existing = docSnap.data();
|
|
135
|
+
if (existing.content === content && existing.contentHash === contentHash) {
|
|
136
|
+
return { action: 'noop', fileId: docSnap.id };
|
|
137
|
+
}
|
|
138
|
+
await updateDoc(docSnap.ref, {
|
|
139
|
+
content,
|
|
140
|
+
updatedAt: now,
|
|
141
|
+
storagePath: storagePathFor(orgId, docSnap.id),
|
|
142
|
+
contentHash,
|
|
143
|
+
size,
|
|
144
|
+
});
|
|
145
|
+
let storageError = null;
|
|
146
|
+
try {
|
|
147
|
+
await uploadBlob({ storage, orgId, fileId: docSnap.id, content });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
storageError = err;
|
|
150
|
+
}
|
|
151
|
+
return { action: 'updated', fileId: docSnap.id, storageError };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Delete a file from Firestore and the corresponding Storage blob.
|
|
156
|
+
*
|
|
157
|
+
* Returns one of:
|
|
158
|
+
* { action: 'skipped', reason: 'shared-scope' }
|
|
159
|
+
* { action: 'not-found' }
|
|
160
|
+
* { action: 'scope-mismatch' }
|
|
161
|
+
* { action: 'deleted', fileId }
|
|
162
|
+
*
|
|
163
|
+
* Blob deletion tolerates `storage/object-not-found`.
|
|
164
|
+
*/
|
|
165
|
+
export async function deleteFileFromCloud({
|
|
166
|
+
filesRef,
|
|
167
|
+
storage,
|
|
168
|
+
orgId,
|
|
169
|
+
userId,
|
|
170
|
+
scope,
|
|
171
|
+
teamId,
|
|
172
|
+
docPath,
|
|
173
|
+
}) {
|
|
174
|
+
if (scope === 'shared-with-me' || scope === 'shared-by-me') {
|
|
175
|
+
return { action: 'skipped', reason: 'shared-scope' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The list query must mirror the Firestore rule's per-branch
|
|
179
|
+
// constraints (scope + ownerId for private, scope + teamId for team)
|
|
180
|
+
// so the rules engine can statically authorize it.
|
|
181
|
+
let snapshot;
|
|
182
|
+
if (scope === 'private') {
|
|
183
|
+
snapshot = await getDocs(query(
|
|
184
|
+
filesRef,
|
|
185
|
+
where('scope', '==', 'private'),
|
|
186
|
+
where('ownerId', '==', userId),
|
|
187
|
+
where('path', '==', docPath)
|
|
188
|
+
));
|
|
189
|
+
} else if (scope === 'team' && teamId) {
|
|
190
|
+
snapshot = await getDocs(query(
|
|
191
|
+
filesRef,
|
|
192
|
+
where('scope', '==', 'team'),
|
|
193
|
+
where('teamId', '==', teamId),
|
|
194
|
+
where('path', '==', docPath)
|
|
195
|
+
));
|
|
196
|
+
} else {
|
|
197
|
+
return { action: 'not-found' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (snapshot.empty) {
|
|
201
|
+
return { action: 'not-found' };
|
|
202
|
+
}
|
|
203
|
+
const docSnap = snapshot.docs[0];
|
|
204
|
+
const fileId = docSnap.id;
|
|
205
|
+
// Delete the Storage blob FIRST while the metadata doc still exists,
|
|
206
|
+
// because the Storage rule's authorization check looks up the doc.
|
|
207
|
+
// After Firestore deletion the rule would deny (firestore.get fails).
|
|
208
|
+
// deleteBlob tolerates storage/object-not-found for files that were
|
|
209
|
+
// written before PR 2 and so have no blob.
|
|
210
|
+
await deleteBlob({ storage, orgId, fileId });
|
|
211
|
+
await deleteDoc(docSnap.ref);
|
|
212
|
+
return { action: 'deleted', fileId };
|
|
213
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@doubling/compound-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Bidirectional sync between Compound and local markdown files",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"bin":
|
|
6
|
+
"bin": {
|
|
7
|
+
"compound-sync": "./sync.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"sync.js",
|
|
11
|
+
"dual-write.js",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
7
14
|
"scripts": {
|
|
8
|
-
"sync": "node sync.js"
|
|
15
|
+
"sync": "node sync.js",
|
|
16
|
+
"test": "firebase emulators:exec --only firestore,storage 'node --test dual-write.test.js'"
|
|
9
17
|
},
|
|
10
18
|
"dependencies": {
|
|
11
19
|
"firebase": "^11.7.0",
|
|
12
20
|
"chokidar": "^4.0.0"
|
|
13
21
|
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@firebase/rules-unit-testing": "^4.0.0",
|
|
24
|
+
"firebase-tools": "^15.0.0"
|
|
25
|
+
},
|
|
14
26
|
"publishConfig": {
|
|
15
27
|
"access": "public"
|
|
16
28
|
}
|
package/sync.js
CHANGED
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
import { initializeApp } from 'firebase/app';
|
|
18
18
|
import { getAuth, signInWithEmailAndPassword, signInWithCredential, GoogleAuthProvider } from 'firebase/auth';
|
|
19
19
|
import {
|
|
20
|
-
getFirestore, collection, doc, getDocs, getDoc, addDoc, updateDoc, deleteDoc,
|
|
20
|
+
getFirestore, collection, doc, getDocs, getDoc, setDoc, addDoc, updateDoc, deleteDoc,
|
|
21
21
|
query, where, onSnapshot
|
|
22
22
|
} from 'firebase/firestore';
|
|
23
|
+
import { getStorage } from 'firebase/storage';
|
|
23
24
|
import { initializeAppCheck, CustomProvider } from 'firebase/app-check';
|
|
24
25
|
import chokidar from 'chokidar';
|
|
25
26
|
import fs from 'fs';
|
|
@@ -29,6 +30,7 @@ import os from 'os';
|
|
|
29
30
|
import http from 'http';
|
|
30
31
|
import { exec } from 'child_process';
|
|
31
32
|
import { fileURLToPath } from 'url';
|
|
33
|
+
import { pushFileToCloud, deleteFileFromCloud } from './dual-write.js';
|
|
32
34
|
|
|
33
35
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
36
|
|
|
@@ -496,6 +498,15 @@ const LOCAL_PATH = expandHome(config.localPath || path.join(os.homedir(), 'Sync'
|
|
|
496
498
|
|
|
497
499
|
const filesRef = collection(db, `orgs/${ORG_ID}/files`);
|
|
498
500
|
|
|
501
|
+
// --- Cloud Storage handle ---
|
|
502
|
+
//
|
|
503
|
+
// Dual-write file content to Cloud Storage in addition to the Firestore
|
|
504
|
+
// `content` field. Implementation lives in ./dual-write.js so it can be
|
|
505
|
+
// imported by tests and other callers without triggering the daemon's
|
|
506
|
+
// browser-auth side effects.
|
|
507
|
+
|
|
508
|
+
const storage = getStorage(app);
|
|
509
|
+
|
|
499
510
|
// --- Load teams ---
|
|
500
511
|
|
|
501
512
|
const teams = new Map();
|
|
@@ -699,44 +710,25 @@ async function pushToFirestore(filePath, content) {
|
|
|
699
710
|
const now = new Date().toISOString();
|
|
700
711
|
lastKnownUpdate.set(trackingKey, now);
|
|
701
712
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
where('scope', '==', 'team'),
|
|
714
|
-
where('teamId', '==', teamId),
|
|
715
|
-
where('path', '==', docPath)
|
|
716
|
-
));
|
|
717
|
-
} else {
|
|
718
|
-
snapshot = await getDocs(query(filesRef, where('path', '==', docPath)));
|
|
719
|
-
}
|
|
713
|
+
const result = await pushFileToCloud({
|
|
714
|
+
filesRef,
|
|
715
|
+
storage,
|
|
716
|
+
orgId: ORG_ID,
|
|
717
|
+
userId: USER_ID,
|
|
718
|
+
scope,
|
|
719
|
+
teamId,
|
|
720
|
+
docPath,
|
|
721
|
+
content,
|
|
722
|
+
now,
|
|
723
|
+
});
|
|
720
724
|
|
|
721
|
-
if (
|
|
725
|
+
if (result.action === 'created') {
|
|
722
726
|
console.log(` [local → firestore] CREATE ${docPath} (${scope})`);
|
|
723
|
-
|
|
724
|
-
path: docPath,
|
|
725
|
-
content,
|
|
726
|
-
createdAt: now,
|
|
727
|
-
updatedAt: now,
|
|
728
|
-
scope,
|
|
729
|
-
teamId: scope === 'team' ? (teamId || null) : null,
|
|
730
|
-
ownerId: scope === 'private' ? USER_ID : null,
|
|
731
|
-
sharedWith: [],
|
|
732
|
-
};
|
|
733
|
-
await addDoc(filesRef, fileData);
|
|
734
|
-
} else {
|
|
735
|
-
const docSnap = snapshot.docs[0];
|
|
736
|
-
const existing = docSnap.data();
|
|
737
|
-
if (existing.content === content) return;
|
|
727
|
+
} else if (result.action === 'updated') {
|
|
738
728
|
console.log(` [local → firestore] UPDATE ${docPath} (${scope})`);
|
|
739
|
-
|
|
729
|
+
}
|
|
730
|
+
if (result.storageError) {
|
|
731
|
+
console.error(` [storage] upload failed for ${docPath}: ${result.storageError.message}`);
|
|
740
732
|
}
|
|
741
733
|
}
|
|
742
734
|
|
|
@@ -745,15 +737,18 @@ async function deleteFromFirestore(filePath) {
|
|
|
745
737
|
const trackingKey = `${scope}:${docPath}`;
|
|
746
738
|
lastKnownUpdate.delete(trackingKey);
|
|
747
739
|
|
|
748
|
-
|
|
740
|
+
const result = await deleteFileFromCloud({
|
|
741
|
+
filesRef,
|
|
742
|
+
storage,
|
|
743
|
+
orgId: ORG_ID,
|
|
744
|
+
userId: USER_ID,
|
|
745
|
+
scope,
|
|
746
|
+
teamId,
|
|
747
|
+
docPath,
|
|
748
|
+
});
|
|
749
749
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
const data = snapshot.docs[0].data();
|
|
753
|
-
if ((data.scope || 'team') === scope) {
|
|
754
|
-
console.log(` [local → firestore] DELETE ${docPath} (${scope})`);
|
|
755
|
-
await deleteDoc(snapshot.docs[0].ref);
|
|
756
|
-
}
|
|
750
|
+
if (result.action === 'deleted') {
|
|
751
|
+
console.log(` [local → firestore] DELETE ${docPath} (${scope})`);
|
|
757
752
|
}
|
|
758
753
|
}
|
|
759
754
|
|