@doubling/compound-sync 1.4.0 → 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.
- package/config.js +8 -0
- package/{dual-write.js → files.js} +90 -62
- package/package.json +5 -4
- package/storage-helpers.js +88 -0
- package/sync.js +61 -18
package/config.js
CHANGED
|
@@ -93,3 +93,11 @@ export function loadConfig(absPath) {
|
|
|
93
93
|
const raw = JSON.parse(fs.readFileSync(absPath, 'utf-8'));
|
|
94
94
|
return normalizeConfig(raw);
|
|
95
95
|
}
|
|
96
|
+
|
|
97
|
+
// Write a config object to disk. Creates the parent directory chain if
|
|
98
|
+
// it doesn't already exist (so callers can target paths like
|
|
99
|
+
// ~/.config/compound-sync/work.json without manual mkdir).
|
|
100
|
+
export function saveConfig(absPath, configObject) {
|
|
101
|
+
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
102
|
+
fs.writeFileSync(absPath, JSON.stringify(configObject, null, 2) + '\n');
|
|
103
|
+
}
|
|
@@ -1,88 +1,116 @@
|
|
|
1
|
-
//
|
|
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
|
|
6
|
+
// is the production caller and supplies real Firebase handles.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
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,
|
|
32
|
+
ref as storageRef, uploadBytes, deleteObject, getBytes,
|
|
23
33
|
} from 'firebase/storage';
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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,
|
|
38
|
-
const blobPath = storagePathFor(orgId, fileId);
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
70
|
+
return; // tolerate missing blob
|
|
51
71
|
}
|
|
52
72
|
throw err;
|
|
53
73
|
}
|
|
54
74
|
}
|
|
55
75
|
|
|
56
76
|
/**
|
|
57
|
-
* Read
|
|
58
|
-
* (missing blob, permission error, network).
|
|
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
|
|
63
|
-
const blobPath = storagePathFor(orgId, fileId);
|
|
64
|
-
const
|
|
65
|
-
return
|
|
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
|
-
|
|
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
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
-
|
|
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
|
|
120
|
-
const
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
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,8 +32,19 @@ 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 {
|
|
36
|
-
|
|
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;
|
|
47
|
+
import { resolveConfigPath, loadConfig, saveConfig } from './config.js';
|
|
37
48
|
import {
|
|
38
49
|
scopeFromLocalPath,
|
|
39
50
|
teamFolderName,
|
|
@@ -355,9 +366,9 @@ async function setupConfig() {
|
|
|
355
366
|
orgEntries.push({ orgId: org.id, localPath: resolvedPath });
|
|
356
367
|
}
|
|
357
368
|
|
|
358
|
-
// Save config (no credentials stored)
|
|
359
|
-
|
|
360
|
-
|
|
369
|
+
// Save config (no credentials stored). saveConfig creates parent dirs
|
|
370
|
+
// so paths like ~/.config/compound-sync/work.json work without manual mkdir.
|
|
371
|
+
saveConfig(CONFIG_PATH, { projectId, orgs: orgEntries });
|
|
361
372
|
console.log('');
|
|
362
373
|
console.log(`Config saved to ${CONFIG_PATH}`);
|
|
363
374
|
console.log('');
|
|
@@ -659,12 +670,24 @@ async function setupOrgSync({ orgId, localPath: rawLocalPath }) {
|
|
|
659
670
|
ensureDir(localFilePath);
|
|
660
671
|
suppressLocal.add(localFilePath);
|
|
661
672
|
|
|
662
|
-
const
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
780
|
-
|
|
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() {
|