@doubling/compound-sync 1.1.0 → 1.2.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/package.json +1 -1
- package/sync.js +83 -4
package/package.json
CHANGED
package/sync.js
CHANGED
|
@@ -17,11 +17,13 @@
|
|
|
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, ref as storageRef, uploadString, deleteObject } from 'firebase/storage';
|
|
23
24
|
import { initializeAppCheck, CustomProvider } from 'firebase/app-check';
|
|
24
25
|
import chokidar from 'chokidar';
|
|
26
|
+
import crypto from 'crypto';
|
|
25
27
|
import fs from 'fs';
|
|
26
28
|
import path from 'path';
|
|
27
29
|
import readline from 'readline';
|
|
@@ -244,6 +246,13 @@ async function setupConfig() {
|
|
|
244
246
|
const authResult = await browserAuth(fbConfig);
|
|
245
247
|
const app = initializeApp(fbConfig, 'setup');
|
|
246
248
|
const auth = getAuth(app);
|
|
249
|
+
|
|
250
|
+
// Initialize App Check before re-authenticating in Node.js
|
|
251
|
+
const appCheckUrl = appCheckEndpoints[projectId];
|
|
252
|
+
if (appCheckUrl && authResult.firebaseIdToken) {
|
|
253
|
+
await initAppCheckWithIdToken(app, appCheckUrl, authResult.firebaseIdToken);
|
|
254
|
+
}
|
|
255
|
+
|
|
247
256
|
const db = getFirestore(app);
|
|
248
257
|
|
|
249
258
|
let userCredential;
|
|
@@ -489,6 +498,43 @@ const LOCAL_PATH = expandHome(config.localPath || path.join(os.homedir(), 'Sync'
|
|
|
489
498
|
|
|
490
499
|
const filesRef = collection(db, `orgs/${ORG_ID}/files`);
|
|
491
500
|
|
|
501
|
+
// --- Cloud Storage for file blobs ---
|
|
502
|
+
//
|
|
503
|
+
// PR 2 of the Storage migration: dual-write file content to Cloud
|
|
504
|
+
// Storage in addition to the Firestore `content` field. Reads still
|
|
505
|
+
// come from Firestore until PR 5 cuts over. Storage rules require the
|
|
506
|
+
// file metadata document to exist, so the ordering on create is
|
|
507
|
+
// (1) write Firestore metadata, (2) upload to Storage, (3) update
|
|
508
|
+
// Firestore with the hash/path/size fields.
|
|
509
|
+
|
|
510
|
+
const storage = getStorage(app);
|
|
511
|
+
|
|
512
|
+
function computeContentHash(content) {
|
|
513
|
+
return crypto.createHash('sha256').update(content || '', 'utf-8').digest('hex');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function storagePathFor(fileId) {
|
|
517
|
+
return `orgs/${ORG_ID}/files/${fileId}/current.md`;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function uploadBlob(fileId, content) {
|
|
521
|
+
const blobPath = storagePathFor(fileId);
|
|
522
|
+
await uploadString(storageRef(storage, blobPath), content || '', 'raw', {
|
|
523
|
+
contentType: 'text/markdown',
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function deleteBlob(fileId) {
|
|
528
|
+
const blobPath = storagePathFor(fileId);
|
|
529
|
+
try {
|
|
530
|
+
await deleteObject(storageRef(storage, blobPath));
|
|
531
|
+
} catch (err) {
|
|
532
|
+
if (err && err.code !== 'storage/object-not-found') {
|
|
533
|
+
console.error(` [storage] delete failed for ${blobPath}: ${err.message}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
492
538
|
// --- Load teams ---
|
|
493
539
|
|
|
494
540
|
const teams = new Map();
|
|
@@ -711,8 +757,15 @@ async function pushToFirestore(filePath, content) {
|
|
|
711
757
|
snapshot = await getDocs(query(filesRef, where('path', '==', docPath)));
|
|
712
758
|
}
|
|
713
759
|
|
|
760
|
+
const contentHash = computeContentHash(content);
|
|
761
|
+
const size = Buffer.byteLength(content || '', 'utf-8');
|
|
762
|
+
|
|
714
763
|
if (!snapshot || snapshot.empty) {
|
|
715
764
|
console.log(` [local → firestore] CREATE ${docPath} (${scope})`);
|
|
765
|
+
// Pre-generate a doc ref so we can compute the deterministic
|
|
766
|
+
// storagePath before the first Firestore write.
|
|
767
|
+
const newDocRef = doc(filesRef);
|
|
768
|
+
const fileId = newDocRef.id;
|
|
716
769
|
const fileData = {
|
|
717
770
|
path: docPath,
|
|
718
771
|
content,
|
|
@@ -722,14 +775,35 @@ async function pushToFirestore(filePath, content) {
|
|
|
722
775
|
teamId: scope === 'team' ? (teamId || null) : null,
|
|
723
776
|
ownerId: scope === 'private' ? USER_ID : null,
|
|
724
777
|
sharedWith: [],
|
|
778
|
+
storagePath: storagePathFor(fileId),
|
|
779
|
+
contentHash,
|
|
780
|
+
size,
|
|
725
781
|
};
|
|
726
|
-
await
|
|
782
|
+
await setDoc(newDocRef, fileData);
|
|
783
|
+
// Upload to Storage after Firestore so the Storage rule (which
|
|
784
|
+
// reads the file metadata to authorize) sees an existing doc.
|
|
785
|
+
try {
|
|
786
|
+
await uploadBlob(fileId, content);
|
|
787
|
+
} catch (err) {
|
|
788
|
+
console.error(` [storage] upload failed for ${docPath}: ${err && err.message}`);
|
|
789
|
+
}
|
|
727
790
|
} else {
|
|
728
791
|
const docSnap = snapshot.docs[0];
|
|
729
792
|
const existing = docSnap.data();
|
|
730
|
-
if (existing.content === content) return;
|
|
793
|
+
if (existing.content === content && existing.contentHash === contentHash) return;
|
|
731
794
|
console.log(` [local → firestore] UPDATE ${docPath} (${scope})`);
|
|
732
|
-
await updateDoc(docSnap.ref, {
|
|
795
|
+
await updateDoc(docSnap.ref, {
|
|
796
|
+
content,
|
|
797
|
+
updatedAt: now,
|
|
798
|
+
storagePath: storagePathFor(docSnap.id),
|
|
799
|
+
contentHash,
|
|
800
|
+
size,
|
|
801
|
+
});
|
|
802
|
+
try {
|
|
803
|
+
await uploadBlob(docSnap.id, content);
|
|
804
|
+
} catch (err) {
|
|
805
|
+
console.error(` [storage] upload failed for ${docPath}: ${err && err.message}`);
|
|
806
|
+
}
|
|
733
807
|
}
|
|
734
808
|
}
|
|
735
809
|
|
|
@@ -745,7 +819,12 @@ async function deleteFromFirestore(filePath) {
|
|
|
745
819
|
const data = snapshot.docs[0].data();
|
|
746
820
|
if ((data.scope || 'team') === scope) {
|
|
747
821
|
console.log(` [local → firestore] DELETE ${docPath} (${scope})`);
|
|
822
|
+
const fileId = snapshot.docs[0].id;
|
|
748
823
|
await deleteDoc(snapshot.docs[0].ref);
|
|
824
|
+
// Best-effort blob cleanup after the metadata is gone. Tolerates
|
|
825
|
+
// missing blobs (older files written before PR 2). Any leak is
|
|
826
|
+
// recoverable via a lifecycle rule or a cleanup script later.
|
|
827
|
+
await deleteBlob(fileId);
|
|
749
828
|
}
|
|
750
829
|
}
|
|
751
830
|
}
|