@doubling/compound-sync 1.4.1 → 1.5.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.
@@ -1,88 +1,116 @@
1
- // Dual-write helpers for the sync daemon and integration tests.
1
+ // Cloud-side file persistence for the sync daemon and integration tests.
2
2
  //
3
3
  // Each function takes its dependencies (filesRef, storage, orgId, etc.)
4
4
  // as parameters so the logic is importable into a test harness that
5
5
  // provides emulator-backed Firebase handles. The sync daemon (sync.js)
6
- // is the production caller and supplies real-Firebase handles.
6
+ // is the production caller and supplies real Firebase handles.
7
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';
8
+ // A "file" is a Firestore metadata document at
9
+ // orgs/{orgId}/files/{fileId}
10
+ // plus a Storage blob at
11
+ // orgs/{orgId}/files/{fileId}/current.{ext}
12
+ // where {ext} is derived from the file's path so Storage can serve the
13
+ // correct Content-Type header automatically.
14
+ //
15
+ // Ordering on writes:
16
+ // 1. Write/update the Firestore metadata document.
17
+ // (Storage rules require the metadata doc to exist before they
18
+ // authorize the blob upload.)
19
+ // 2. Upload the blob to Cloud Storage.
20
+ // Storage failures are returned in the result, not thrown, so the
21
+ // caller can decide whether to surface as a soft warning and retry.
22
+ //
23
+ // Ordering on deletes:
24
+ // 1. Delete the Storage blob (while metadata still exists so the
25
+ // Storage rule can authorize).
26
+ // 2. Delete the Firestore metadata document.
27
+
18
28
  import {
19
29
  doc, getDocs, setDoc, updateDoc, deleteDoc, query, where,
20
30
  } from 'firebase/firestore';
21
31
  import {
22
- ref as storageRef, uploadString, deleteObject, getBytes,
32
+ ref as storageRef, uploadBytes, deleteObject, getBytes,
23
33
  } from 'firebase/storage';
24
34
 
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
- }
35
+ import {
36
+ storagePathFor,
37
+ extFromPath,
38
+ contentByteSize,
39
+ computeContentHash,
40
+ toBytes,
41
+ } from './storage-helpers.js';
42
+
43
+ // Re-export pure helpers so callers (and existing tests) can import
44
+ // either from here or directly from storage-helpers.js.
45
+ export {
46
+ storagePathFor,
47
+ extFromPath,
48
+ contentByteSize,
49
+ computeContentHash,
50
+ isTextMimeType,
51
+ mimeTypeFromExt,
52
+ } from './storage-helpers.js';
34
53
 
35
54
  // ---- Storage primitives ----
36
55
 
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',
56
+ export async function uploadBlob({ storage, orgId, fileId, ext, data, mimeType }) {
57
+ const blobPath = storagePathFor(orgId, fileId, ext);
58
+ const payload = toBytes(data);
59
+ await uploadBytes(storageRef(storage, blobPath), payload, {
60
+ contentType: mimeType || 'application/octet-stream',
41
61
  });
42
62
  }
43
63
 
44
- export async function deleteBlob({ storage, orgId, fileId }) {
45
- const blobPath = storagePathFor(orgId, fileId);
64
+ export async function deleteBlob({ storage, orgId, fileId, ext }) {
65
+ const blobPath = storagePathFor(orgId, fileId, ext);
46
66
  try {
47
67
  await deleteObject(storageRef(storage, blobPath));
48
68
  } catch (err) {
49
69
  if (err && err.code === 'storage/object-not-found') {
50
- return; // tolerate missing blob (older files written before PR 2)
70
+ return; // tolerate missing blob
51
71
  }
52
72
  throw err;
53
73
  }
54
74
  }
55
75
 
56
76
  /**
57
- * Read file content from Cloud Storage. Throws on any failure
58
- * (missing blob, permission error, network). The Firestore content
59
- * field is no longer written or read as of PR 6, so the read path
60
- * has no fallback. Backfill (PR 4) is a prerequisite.
77
+ * Read a blob from Cloud Storage as raw bytes. Throws on any failure
78
+ * (missing blob, permission error, network).
61
79
  */
62
- export async function readFileContent({ storage, orgId, fileId }) {
63
- const blobPath = storagePathFor(orgId, fileId);
64
- const bytes = await getBytes(storageRef(storage, blobPath));
65
- return new TextDecoder('utf-8').decode(new Uint8Array(bytes));
80
+ export async function readBlob({ storage, orgId, fileId, ext }) {
81
+ const blobPath = storagePathFor(orgId, fileId, ext);
82
+ const buf = await getBytes(storageRef(storage, blobPath));
83
+ return Buffer.from(buf);
66
84
  }
67
85
 
68
- // ---- Core dual-write ----
86
+ /**
87
+ * Read a blob and decode as UTF-8 text. Convenience for text/markdown
88
+ * callers.
89
+ */
90
+ export async function readBlobAsText({ storage, orgId, fileId, ext }) {
91
+ const buf = await readBlob({ storage, orgId, fileId, ext });
92
+ return buf.toString('utf-8');
93
+ }
94
+
95
+ // ---- High-level coordinator ----
69
96
 
70
97
  /**
71
- * Write a file's content to Cloud Storage at
72
- * orgs/{orgId}/files/{fileId}/current.md and update the Firestore
73
- * metadata doc with storagePath/contentHash/size. As of PR 6 the
74
- * Firestore `content` field is no longer written; the blob is the
75
- * sole source of file content.
98
+ * Push a file's content to the cloud: upsert the Firestore metadata
99
+ * doc and upload the blob.
100
+ *
101
+ * Args:
102
+ * - data: the file's content. String, Buffer, or Uint8Array.
103
+ * - mimeType: the file's mime type. Stored on the metadata doc and
104
+ * used as the blob's Content-Type. Optional; if absent
105
+ * for an existing file the existing mime is preserved,
106
+ * and for new files defaults to 'text/markdown' to keep
107
+ * legacy callers working.
76
108
  *
77
109
  * Returns one of:
78
110
  * { action: 'skipped', reason: 'shared-scope' }
79
111
  * { action: 'noop', fileId }
80
112
  * { action: 'created', fileId, fileData, storageError? }
81
113
  * { action: 'updated', fileId, storageError? }
82
- *
83
- * `storageError` is populated when the Firestore write succeeded but
84
- * the Storage upload failed. Callers should log and retry; the next
85
- * save attempts the upload again.
86
114
  */
87
115
  export async function pushFileToCloud({
88
116
  filesRef,
@@ -92,7 +120,8 @@ export async function pushFileToCloud({
92
120
  scope,
93
121
  teamId,
94
122
  docPath,
95
- content,
123
+ data,
124
+ mimeType,
96
125
  now = new Date().toISOString(),
97
126
  }) {
98
127
  if (scope === 'shared-with-me' || scope === 'shared-by-me') {
@@ -116,12 +145,14 @@ export async function pushFileToCloud({
116
145
  snapshot = await getDocs(query(filesRef, where('path', '==', docPath)));
117
146
  }
118
147
 
119
- const contentHash = computeContentHash(content);
120
- const size = Buffer.byteLength(content || '', 'utf-8');
148
+ const ext = extFromPath(docPath);
149
+ const contentHash = computeContentHash(data);
150
+ const size = contentByteSize(data);
121
151
 
122
152
  if (!snapshot || snapshot.empty) {
123
153
  const newDocRef = doc(filesRef);
124
154
  const fileId = newDocRef.id;
155
+ const resolvedMime = mimeType || 'text/markdown';
125
156
  const fileData = {
126
157
  path: docPath,
127
158
  createdAt: now,
@@ -130,14 +161,15 @@ export async function pushFileToCloud({
130
161
  teamId: scope === 'team' ? (teamId || null) : null,
131
162
  ownerId: scope === 'private' ? userId : null,
132
163
  sharedWith: [],
133
- storagePath: storagePathFor(orgId, fileId),
164
+ storagePath: storagePathFor(orgId, fileId, ext),
134
165
  contentHash,
135
166
  size,
167
+ mimeType: resolvedMime,
136
168
  };
137
169
  await setDoc(newDocRef, fileData);
138
170
  let storageError = null;
139
171
  try {
140
- await uploadBlob({ storage, orgId, fileId, content });
172
+ await uploadBlob({ storage, orgId, fileId, ext, data, mimeType: resolvedMime });
141
173
  } catch (err) {
142
174
  storageError = err;
143
175
  }
@@ -149,15 +181,17 @@ export async function pushFileToCloud({
149
181
  if (existing.contentHash === contentHash) {
150
182
  return { action: 'noop', fileId: docSnap.id };
151
183
  }
184
+ const resolvedMime = mimeType || existing.mimeType || 'text/markdown';
152
185
  await updateDoc(docSnap.ref, {
153
186
  updatedAt: now,
154
- storagePath: storagePathFor(orgId, docSnap.id),
187
+ storagePath: storagePathFor(orgId, docSnap.id, ext),
155
188
  contentHash,
156
189
  size,
190
+ mimeType: resolvedMime,
157
191
  });
158
192
  let storageError = null;
159
193
  try {
160
- await uploadBlob({ storage, orgId, fileId: docSnap.id, content });
194
+ await uploadBlob({ storage, orgId, fileId: docSnap.id, ext, data, mimeType: resolvedMime });
161
195
  } catch (err) {
162
196
  storageError = err;
163
197
  }
@@ -170,7 +204,6 @@ export async function pushFileToCloud({
170
204
  * Returns one of:
171
205
  * { action: 'skipped', reason: 'shared-scope' }
172
206
  * { action: 'not-found' }
173
- * { action: 'scope-mismatch' }
174
207
  * { action: 'deleted', fileId }
175
208
  *
176
209
  * Blob deletion tolerates `storage/object-not-found`.
@@ -188,9 +221,6 @@ export async function deleteFileFromCloud({
188
221
  return { action: 'skipped', reason: 'shared-scope' };
189
222
  }
190
223
 
191
- // The list query must mirror the Firestore rule's per-branch
192
- // constraints (scope + ownerId for private, scope + teamId for team)
193
- // so the rules engine can statically authorize it.
194
224
  let snapshot;
195
225
  if (scope === 'private') {
196
226
  snapshot = await getDocs(query(
@@ -215,12 +245,10 @@ export async function deleteFileFromCloud({
215
245
  }
216
246
  const docSnap = snapshot.docs[0];
217
247
  const fileId = docSnap.id;
248
+ const ext = extFromPath(docPath);
218
249
  // Delete the Storage blob FIRST while the metadata doc still exists,
219
250
  // because the Storage rule's authorization check looks up the doc.
220
- // After Firestore deletion the rule would deny (firestore.get fails).
221
- // deleteBlob tolerates storage/object-not-found for files that were
222
- // written before PR 2 and so have no blob.
223
- await deleteBlob({ storage, orgId, fileId });
251
+ await deleteBlob({ storage, orgId, fileId, ext });
224
252
  await deleteDoc(docSnap.ref);
225
253
  return { action: 'deleted', fileId };
226
254
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/compound-sync",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Bidirectional sync between Compound and local markdown files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,13 +10,14 @@
10
10
  "sync.js",
11
11
  "config.js",
12
12
  "paths.js",
13
- "dual-write.js",
13
+ "files.js",
14
+ "storage-helpers.js",
14
15
  "README.md"
15
16
  ],
16
17
  "scripts": {
17
18
  "sync": "node sync.js",
18
- "test:unit": "node --test config.test.js paths.test.js",
19
- "test:integration": "firebase emulators:exec --only firestore,storage 'node --test dual-write.test.js'",
19
+ "test:unit": "node --test config.test.js paths.test.js storage-helpers.test.js",
20
+ "test:integration": "firebase emulators:exec --only firestore,storage 'node --test files.test.js'",
20
21
  "test": "npm run test:unit && npm run test:integration"
21
22
  },
22
23
  "dependencies": {
@@ -0,0 +1,88 @@
1
+ // Pure Node-side storage helpers. No Firebase imports.
2
+ //
3
+ // Mirrors public/js/storage-helpers.js but uses Node-native APIs
4
+ // (Buffer, crypto.createHash) instead of the browser equivalents
5
+ // (Uint8Array, crypto.subtle). Function names and return types
6
+ // match so that callers on either side can be reasoned about
7
+ // uniformly.
8
+ //
9
+ // Storage layout convention:
10
+ // orgs/{orgId}/files/{fileId}/current.{ext}
11
+
12
+ import crypto from 'crypto';
13
+
14
+ export function extFromPath(path) {
15
+ const name = (((path == null ? '' : path) + '').split('/').pop()) || '';
16
+ const idx = name.lastIndexOf('.');
17
+ if (idx <= 0 || idx === name.length - 1) return 'bin';
18
+ return name.slice(idx + 1).toLowerCase();
19
+ }
20
+
21
+ export function storagePathFor(orgId, fileId, ext) {
22
+ return `orgs/${orgId}/files/${fileId}/current.${ext || 'bin'}`;
23
+ }
24
+
25
+ export function contentByteSize(data) {
26
+ if (data == null) return 0;
27
+ if (typeof data === 'string') return Buffer.byteLength(data, 'utf-8');
28
+ if (Buffer.isBuffer(data)) return data.length;
29
+ if (data instanceof Uint8Array) return data.byteLength;
30
+ if (data instanceof ArrayBuffer) return data.byteLength;
31
+ return Buffer.byteLength(String(data), 'utf-8');
32
+ }
33
+
34
+ export function computeContentHash(data) {
35
+ const buf = toBytes(data);
36
+ return crypto.createHash('sha256').update(buf).digest('hex');
37
+ }
38
+
39
+ export function toBytes(data) {
40
+ if (data == null) return Buffer.alloc(0);
41
+ if (Buffer.isBuffer(data)) return data;
42
+ if (data instanceof Uint8Array) return Buffer.from(data);
43
+ if (data instanceof ArrayBuffer) return Buffer.from(data);
44
+ return Buffer.from(String(data), 'utf-8');
45
+ }
46
+
47
+ // Files with these mime types are treated as inline-readable text by
48
+ // the sync daemon: read with utf-8 encoding from disk, written with
49
+ // utf-8 encoding to disk. Everything else is treated as binary and
50
+ // transferred as raw bytes.
51
+ //
52
+ // Missing mime type is treated as text for backward compatibility
53
+ // with markdown files written before mimeType was tracked.
54
+ export function isTextMimeType(mimeType) {
55
+ if (mimeType == null || mimeType === '') return true;
56
+ return typeof mimeType === 'string' && mimeType.startsWith('text/');
57
+ }
58
+
59
+ const EXT_TO_MIME = {
60
+ md: 'text/markdown',
61
+ markdown: 'text/markdown',
62
+ txt: 'text/plain',
63
+ csv: 'text/csv',
64
+ html: 'text/html',
65
+ htm: 'text/html',
66
+ css: 'text/css',
67
+ js: 'text/javascript',
68
+ json: 'application/json',
69
+ pdf: 'application/pdf',
70
+ png: 'image/png',
71
+ jpg: 'image/jpeg',
72
+ jpeg: 'image/jpeg',
73
+ gif: 'image/gif',
74
+ webp: 'image/webp',
75
+ svg: 'image/svg+xml',
76
+ bmp: 'image/bmp',
77
+ mp3: 'audio/mpeg',
78
+ wav: 'audio/wav',
79
+ mp4: 'video/mp4',
80
+ webm: 'video/webm',
81
+ mov: 'video/quicktime',
82
+ zip: 'application/zip',
83
+ };
84
+
85
+ export function mimeTypeFromExt(ext) {
86
+ if (ext == null) return 'application/octet-stream';
87
+ return EXT_TO_MIME[String(ext).toLowerCase()] || 'application/octet-stream';
88
+ }
package/sync.js CHANGED
@@ -32,7 +32,18 @@ import os from 'os';
32
32
  import http from 'http';
33
33
  import { exec } from 'child_process';
34
34
  import { fileURLToPath } from 'url';
35
- import { pushFileToCloud, deleteFileFromCloud, readFileContent } from './dual-write.js';
35
+ import {
36
+ pushFileToCloud,
37
+ deleteFileFromCloud,
38
+ readBlob,
39
+ readBlobAsText,
40
+ extFromPath,
41
+ isTextMimeType,
42
+ mimeTypeFromExt,
43
+ } from './files.js';
44
+
45
+ // Max file size enforced before upload. Mirrored in storage.rules.
46
+ const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
36
47
  import { resolveConfigPath, loadConfig, saveConfig } from './config.js';
37
48
  import {
38
49
  scopeFromLocalPath,
@@ -659,12 +670,24 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
659
670
  ensureDir(localFilePath);
660
671
  suppressLocal.add(localFilePath);
661
672
 
662
- const content = await readFileContent({
663
- storage,
664
- orgId,
665
- fileId: change.doc.id,
666
- });
667
- fs.writeFileSync(localFilePath, content || '', 'utf-8');
673
+ const ext = extFromPath(docPath);
674
+ if (isTextMimeType(data.mimeType)) {
675
+ const content = await readBlobAsText({
676
+ storage,
677
+ orgId,
678
+ fileId: change.doc.id,
679
+ ext,
680
+ });
681
+ fs.writeFileSync(localFilePath, content || '', 'utf-8');
682
+ } else {
683
+ const bytes = await readBlob({
684
+ storage,
685
+ orgId,
686
+ fileId: change.doc.id,
687
+ ext,
688
+ });
689
+ fs.writeFileSync(localFilePath, bytes);
690
+ }
668
691
  lastKnownUpdate.set(trackingKey, remoteUpdated);
669
692
  setTimeout(() => suppressLocal.delete(localFilePath), 1000);
670
693
  }
@@ -719,7 +742,7 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
719
742
  }
720
743
  }
721
744
 
722
- async function pushToFirestore(filePath, content) {
745
+ async function pushToFirestore(filePath, data, mimeType) {
723
746
  const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
724
747
  const trackingKey = `${scope}:${docPath}`;
725
748
  const now = new Date().toISOString();
@@ -733,12 +756,13 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
733
756
  scope,
734
757
  teamId,
735
758
  docPath,
736
- content,
759
+ data,
760
+ mimeType,
737
761
  now,
738
762
  });
739
763
 
740
764
  if (result.action === 'created') {
741
- console.log(` [${orgId}] [local → firestore] CREATE ${docPath} (${scope})`);
765
+ console.log(` [${orgId}] [local → firestore] CREATE ${docPath} (${scope}, ${mimeType})`);
742
766
  } else if (result.action === 'updated') {
743
767
  console.log(` [${orgId}] [local → firestore] UPDATE ${docPath} (${scope})`);
744
768
  }
@@ -771,13 +795,32 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
771
795
  if (suppressLocal.has(filePath)) return;
772
796
 
773
797
  const rel = path.relative(LOCAL_PATH, filePath);
774
- if (!rel.endsWith('.md') && !rel.endsWith('.txt')) return;
775
-
776
798
  if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return;
777
799
  if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return;
778
800
 
779
- const content = fs.readFileSync(filePath, 'utf-8');
780
- pushToFirestore(filePath, content).catch(err => console.error(` [${orgId}] Push error: ${err.message}`));
801
+ let stat;
802
+ try {
803
+ stat = fs.statSync(filePath);
804
+ } catch (err) {
805
+ return; // disappeared between event and read
806
+ }
807
+ if (!stat.isFile()) return;
808
+ if (stat.size > MAX_UPLOAD_BYTES) {
809
+ console.error(` [${orgId}] [skip] ${rel} exceeds ${MAX_UPLOAD_BYTES} byte cap (${stat.size} bytes)`);
810
+ return;
811
+ }
812
+
813
+ const ext = extFromPath(rel);
814
+ const mimeType = mimeTypeFromExt(ext);
815
+ // Text files: read as a UTF-8 string so future text-aware features
816
+ // (line-level diffing, conflict markers) can work. Binary files:
817
+ // read as a Buffer so bytes round-trip without corruption.
818
+ const data = isTextMimeType(mimeType)
819
+ ? fs.readFileSync(filePath, 'utf-8')
820
+ : fs.readFileSync(filePath);
821
+ pushToFirestore(filePath, data, mimeType).catch(err =>
822
+ console.error(` [${orgId}] Push error: ${err.message}`)
823
+ );
781
824
  }
782
825
 
783
826
  function startLocalWatcher() {