@doubling/compound-sync 1.2.0 → 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.
Files changed (3) hide show
  1. package/dual-write.js +213 -0
  2. package/package.json +15 -3
  3. package/sync.js +35 -112
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.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Bidirectional sync between Compound and local markdown files",
5
5
  "type": "module",
6
- "bin": "./sync.js",
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
@@ -20,10 +20,9 @@ import {
20
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
+ import { getStorage } from 'firebase/storage';
24
24
  import { initializeAppCheck, CustomProvider } from 'firebase/app-check';
25
25
  import chokidar from 'chokidar';
26
- import crypto from 'crypto';
27
26
  import fs from 'fs';
28
27
  import path from 'path';
29
28
  import readline from 'readline';
@@ -31,6 +30,7 @@ import os from 'os';
31
30
  import http from 'http';
32
31
  import { exec } from 'child_process';
33
32
  import { fileURLToPath } from 'url';
33
+ import { pushFileToCloud, deleteFileFromCloud } from './dual-write.js';
34
34
 
35
35
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
36
 
@@ -498,43 +498,15 @@ const LOCAL_PATH = expandHome(config.localPath || path.join(os.homedir(), 'Sync'
498
498
 
499
499
  const filesRef = collection(db, `orgs/${ORG_ID}/files`);
500
500
 
501
- // --- Cloud Storage for file blobs ---
501
+ // --- Cloud Storage handle ---
502
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.
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.
509
507
 
510
508
  const storage = getStorage(app);
511
509
 
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
-
538
510
  // --- Load teams ---
539
511
 
540
512
  const teams = new Map();
@@ -738,72 +710,25 @@ async function pushToFirestore(filePath, content) {
738
710
  const now = new Date().toISOString();
739
711
  lastKnownUpdate.set(trackingKey, now);
740
712
 
741
- if (scope === 'shared-with-me' || scope === 'shared-by-me') return;
742
-
743
- let snapshot;
744
- if (scope === 'private') {
745
- snapshot = await getDocs(query(filesRef,
746
- where('scope', '==', 'private'),
747
- where('ownerId', '==', USER_ID),
748
- where('path', '==', docPath)
749
- ));
750
- } else if (scope === 'team' && teamId) {
751
- snapshot = await getDocs(query(filesRef,
752
- where('scope', '==', 'team'),
753
- where('teamId', '==', teamId),
754
- where('path', '==', docPath)
755
- ));
756
- } else {
757
- snapshot = await getDocs(query(filesRef, where('path', '==', docPath)));
758
- }
759
-
760
- const contentHash = computeContentHash(content);
761
- const size = Buffer.byteLength(content || '', 'utf-8');
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
+ });
762
724
 
763
- if (!snapshot || snapshot.empty) {
725
+ if (result.action === 'created') {
764
726
  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;
769
- const fileData = {
770
- path: docPath,
771
- content,
772
- createdAt: now,
773
- updatedAt: now,
774
- scope,
775
- teamId: scope === 'team' ? (teamId || null) : null,
776
- ownerId: scope === 'private' ? USER_ID : null,
777
- sharedWith: [],
778
- storagePath: storagePathFor(fileId),
779
- contentHash,
780
- size,
781
- };
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
- }
790
- } else {
791
- const docSnap = snapshot.docs[0];
792
- const existing = docSnap.data();
793
- if (existing.content === content && existing.contentHash === contentHash) return;
727
+ } else if (result.action === 'updated') {
794
728
  console.log(` [local → firestore] UPDATE ${docPath} (${scope})`);
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
- }
729
+ }
730
+ if (result.storageError) {
731
+ console.error(` [storage] upload failed for ${docPath}: ${result.storageError.message}`);
807
732
  }
808
733
  }
809
734
 
@@ -812,20 +737,18 @@ async function deleteFromFirestore(filePath) {
812
737
  const trackingKey = `${scope}:${docPath}`;
813
738
  lastKnownUpdate.delete(trackingKey);
814
739
 
815
- if (scope === 'shared-with-me' || scope === 'shared-by-me') return;
816
-
817
- const snapshot = await getDocs(query(filesRef, where('path', '==', docPath)));
818
- if (!snapshot.empty) {
819
- const data = snapshot.docs[0].data();
820
- if ((data.scope || 'team') === scope) {
821
- console.log(` [local → firestore] DELETE ${docPath} (${scope})`);
822
- const fileId = snapshot.docs[0].id;
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);
828
- }
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
+
750
+ if (result.action === 'deleted') {
751
+ console.log(` [local firestore] DELETE ${docPath} (${scope})`);
829
752
  }
830
753
  }
831
754