@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/sync.js +83 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/compound-sync",
3
- "version": "1.1.0",
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';
@@ -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 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
+ }
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, { 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
+ }
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
  }