@doubling/compound-sync 1.1.1 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/sync.js +76 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/compound-sync",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Bidirectional sync between Compound and local markdown files",
5
5
  "type": "module",
6
6
  "bin": "./sync.js",
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';
@@ -496,6 +498,43 @@ 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 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
+
499
538
  // --- Load teams ---
500
539
 
501
540
  const teams = new Map();
@@ -718,8 +757,15 @@ async function pushToFirestore(filePath, content) {
718
757
  snapshot = await getDocs(query(filesRef, where('path', '==', docPath)));
719
758
  }
720
759
 
760
+ const contentHash = computeContentHash(content);
761
+ const size = Buffer.byteLength(content || '', 'utf-8');
762
+
721
763
  if (!snapshot || snapshot.empty) {
722
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;
723
769
  const fileData = {
724
770
  path: docPath,
725
771
  content,
@@ -729,14 +775,35 @@ async function pushToFirestore(filePath, content) {
729
775
  teamId: scope === 'team' ? (teamId || null) : null,
730
776
  ownerId: scope === 'private' ? USER_ID : null,
731
777
  sharedWith: [],
778
+ storagePath: storagePathFor(fileId),
779
+ contentHash,
780
+ size,
732
781
  };
733
- await addDoc(filesRef, fileData);
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
+ }
734
790
  } else {
735
791
  const docSnap = snapshot.docs[0];
736
792
  const existing = docSnap.data();
737
- if (existing.content === content) return;
793
+ if (existing.content === content && existing.contentHash === contentHash) return;
738
794
  console.log(` [local → firestore] UPDATE ${docPath} (${scope})`);
739
- await updateDoc(docSnap.ref, { content, updatedAt: now });
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
+ }
740
807
  }
741
808
  }
742
809
 
@@ -752,7 +819,12 @@ async function deleteFromFirestore(filePath) {
752
819
  const data = snapshot.docs[0].data();
753
820
  if ((data.scope || 'team') === scope) {
754
821
  console.log(` [local → firestore] DELETE ${docPath} (${scope})`);
822
+ const fileId = snapshot.docs[0].id;
755
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);
756
828
  }
757
829
  }
758
830
  }