@doubling/compound-sync 1.7.1 → 1.10.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.
package/org-sync.js ADDED
@@ -0,0 +1,710 @@
1
+ // Per-org sync loop, extracted from sync.js so it can be driven in
2
+ // tests against the Firebase emulator (org-sync.test.js, DOU-156)
3
+ // instead of only by launching the daemon. `startOrgSync` wires the
4
+ // Firestore listeners (pull) and the chokidar watcher (push) for one
5
+ // org given injected Firebase handles, and returns a `stop()` that
6
+ // tears down every listener and the watcher.
7
+ //
8
+ // This is a faithful extraction of the former in-sync.js `setupOrgSync`:
9
+ // module globals it used (db, storage, USER_ID, MAX_UPLOAD_BYTES) are
10
+ // now parameters, and the localPath is expected pre-resolved by the
11
+ // caller. Behavior is otherwise unchanged (including the known
12
+ // mtime-based decision quirks DOU-154 will rework, those stay so the
13
+ // scenario tests can pin them red first).
14
+
15
+ import chokidar from 'chokidar';
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import { collection, query, where, onSnapshot } from 'firebase/firestore';
19
+ import {
20
+ pushFileToCloud,
21
+ deleteFileFromCloud,
22
+ pushFolderToCloud,
23
+ deleteFolderFromCloud,
24
+ readBlob,
25
+ readBlobAsText,
26
+ extFromPath,
27
+ isTextMimeType,
28
+ mimeTypeFromExt,
29
+ computeContentHash,
30
+ } from './files.js';
31
+ import {
32
+ scopeFromLocalPath,
33
+ teamFolderName,
34
+ PRIVATE_FOLDER,
35
+ SHARED_WITH_ME_FOLDER,
36
+ SHARED_BY_ME_FOLDER,
37
+ } from './paths.js';
38
+ import { TeamRegistry } from './team-registry.js';
39
+ import { loadSyncState, saveSyncState } from './sync-state.js';
40
+
41
+ const DEFAULT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
42
+
43
+ // Retry a blob download until either the bytes appear at all (covers
44
+ // the create race: doc written before blob upload) AND the content
45
+ // hash matches the metadata's `contentHash` (covers the update race:
46
+ // the blob still has the old bytes when the new doc is already
47
+ // visible). Backs off 100/200/400/800 ms (1.5s total) across both
48
+ // failure modes. Other errors throw immediately.
49
+ async function readBlobWithRetry(read, { expectedHash = null, maxAttempts = 5 } = {}) {
50
+ let delay = 100;
51
+ let lastErr;
52
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
53
+ try {
54
+ const content = await read();
55
+ if (!expectedHash || computeContentHash(content) === expectedHash) {
56
+ return content;
57
+ }
58
+ lastErr = new Error(
59
+ `blob hash does not match metadata contentHash (attempt ${attempt}/${maxAttempts})`,
60
+ );
61
+ } catch (err) {
62
+ if (err?.code !== 'storage/object-not-found') throw err;
63
+ lastErr = err;
64
+ }
65
+ if (attempt < maxAttempts) {
66
+ await new Promise((resolve) => setTimeout(resolve, delay));
67
+ delay *= 2;
68
+ }
69
+ }
70
+ throw lastErr;
71
+ }
72
+
73
+ // Start the per-org sync loop. Returns { stop } to unsubscribe all
74
+ // listeners and close the watcher (used by tests and by graceful
75
+ // shutdown). `localPath` must already be absolute/expanded.
76
+ export async function startOrgSync({
77
+ db,
78
+ storage,
79
+ userId,
80
+ orgId,
81
+ localPath,
82
+ maxUploadBytes = DEFAULT_MAX_UPLOAD_BYTES,
83
+ statePath,
84
+ }) {
85
+ const LOCAL_PATH = localPath;
86
+ const filesRef = collection(db, `orgs/${orgId}/files`);
87
+ // Where the per-folder content baseline is persisted. Default: a
88
+ // dotfile in the synced folder (ignored by the watcher), so it travels
89
+ // with the folder and needs no extra wiring from the caller.
90
+ const STATE_PATH = statePath ?? path.join(LOCAL_PATH, '.compound-sync-state.json');
91
+
92
+ // Teardown handles: every onSnapshot unsub plus the chokidar watcher.
93
+ const unsubscribers = [];
94
+ let watcher = null;
95
+
96
+ const lastKnownUpdate = new Map();
97
+ // Content hash of each file as of the last successful sync (push or
98
+ // pull), keyed by trackingKey. This "baseline" decides pull/push
99
+ // direction by CONTENT rather than filesystem mtime (which an editor
100
+ // like Obsidian bumps without a content change). Loaded from
101
+ // STATE_PATH so it survives restarts; persisted on every change.
102
+ const lastSyncedHash = new Map(Object.entries(loadSyncState(STATE_PATH)));
103
+ function persistBaseline() {
104
+ saveSyncState(STATE_PATH, Object.fromEntries(lastSyncedHash));
105
+ }
106
+ function setBaseline(key, hash) { lastSyncedHash.set(key, hash); persistBaseline(); }
107
+ function deleteBaseline(key) { lastSyncedHash.delete(key); persistBaseline(); }
108
+ const suppressLocal = new Set();
109
+ // Per-team Firestore-listener unsubscribe handles, keyed by teamId.
110
+ const teamUnsubscribers = new Map();
111
+ // Hoisted forward-decl: setupTeam closes over handleFirestoreChange,
112
+ // which is defined later in this function. Wired below.
113
+ let setupTeam = () => {};
114
+ function teardownTeam(teamId, teamData) {
115
+ const unsub = teamUnsubscribers.get(teamId);
116
+ if (unsub) {
117
+ try { unsub(); } catch (e) { /* ignore */ }
118
+ teamUnsubscribers.delete(teamId);
119
+ const folder = teamData && teamData.name ? teamFolderName(teamData.name) : teamId;
120
+ console.log(` [${orgId}] Stopped listening: ${folder}`);
121
+ }
122
+ }
123
+ const teamRegistry = new TeamRegistry({
124
+ onSetup: (teamId, teamData) => setupTeam(teamId, teamData),
125
+ onTeardown: teardownTeam,
126
+ });
127
+ // scopeFromLocalPath expects a Map<teamName, teamId>; the registry
128
+ // exposes one matching that shape.
129
+ const teamIdsByName = teamRegistry.teamIdsByName;
130
+
131
+ console.log('');
132
+ console.log(` [${orgId}] Local: ${LOCAL_PATH}`);
133
+
134
+ function ensureDir(filePath) {
135
+ const dir = path.dirname(filePath);
136
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
137
+ }
138
+
139
+ function updateSharedByMeSymlink(fileDoc, realPath) {
140
+ const sharedWith = fileDoc.sharedWith || [];
141
+ const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, fileDoc.path);
142
+
143
+ if (sharedWith.length > 0) {
144
+ ensureDir(symlinkPath);
145
+ const relativeTo = path.relative(path.dirname(symlinkPath), realPath);
146
+ try {
147
+ if (fs.existsSync(symlinkPath) || fs.lstatSync(symlinkPath).isSymbolicLink()) {
148
+ fs.unlinkSync(symlinkPath);
149
+ }
150
+ } catch (e) { /* doesn't exist */ }
151
+ try {
152
+ fs.symlinkSync(relativeTo, symlinkPath);
153
+ console.log(` [${orgId}] [symlink] ${fileDoc.path} → Shared by Me/`);
154
+ } catch (e) {
155
+ if (e.code !== 'EEXIST') console.error(` [${orgId}] Symlink error: ${e.message}`);
156
+ }
157
+ } else {
158
+ try {
159
+ if (fs.lstatSync(symlinkPath).isSymbolicLink()) {
160
+ fs.unlinkSync(symlinkPath);
161
+ console.log(` [${orgId}] [symlink] removed Shared by Me/${fileDoc.path}`);
162
+ }
163
+ } catch (e) { /* doesn't exist */ }
164
+ }
165
+ }
166
+
167
+ // Static (user-level) folders. Team folders are created per-team by
168
+ // setupTeam below as soon as the registry sees each teamspace.
169
+ function ensureFolderStructure() {
170
+ const privatePath = path.join(LOCAL_PATH, PRIVATE_FOLDER);
171
+ if (!fs.existsSync(privatePath)) fs.mkdirSync(privatePath, { recursive: true });
172
+
173
+ const sharedWithMePath = path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER);
174
+ if (!fs.existsSync(sharedWithMePath)) fs.mkdirSync(sharedWithMePath, { recursive: true });
175
+
176
+ const sharedByMePath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER);
177
+ if (!fs.existsSync(sharedByMePath)) fs.mkdirSync(sharedByMePath, { recursive: true });
178
+ }
179
+
180
+ ensureFolderStructure();
181
+
182
+ async function handleFirestoreChange(change, getLocalPath) {
183
+ const data = change.doc.data();
184
+ const docPath = data.path;
185
+ if (!docPath) return;
186
+
187
+ const localFilePath = getLocalPath(data);
188
+ const trackingKey = `${data.scope || 'team'}:${docPath}`;
189
+ const isFolder = data.type === 'folder';
190
+
191
+ if (change.type === 'removed') {
192
+ if (isFolder) {
193
+ // Best-effort directory removal. The daemon's own file-delete
194
+ // events should have cleared all children by the time we get
195
+ // here; if not, rmdirSync throws ENOTEMPTY and we keep the
196
+ // directory rather than risk losing data the user hasn't seen.
197
+ try {
198
+ suppressLocal.add(localFilePath);
199
+ fs.rmdirSync(localFilePath);
200
+ console.log(` [${orgId}] [firestore → local] RMDIR ${docPath}`);
201
+ setTimeout(() => suppressLocal.delete(localFilePath), 1000);
202
+ } catch (err) {
203
+ suppressLocal.delete(localFilePath);
204
+ if (err.code !== 'ENOENT' && err.code !== 'ENOTEMPTY') {
205
+ console.error(` [${orgId}] [firestore → local] RMDIR ${docPath} failed: ${err.message}`);
206
+ }
207
+ }
208
+ } else if (fs.existsSync(localFilePath)) {
209
+ console.log(` [${orgId}] [firestore → local] DELETE ${docPath}`);
210
+ suppressLocal.add(localFilePath);
211
+ fs.unlinkSync(localFilePath);
212
+ setTimeout(() => suppressLocal.delete(localFilePath), 1000);
213
+ }
214
+ lastKnownUpdate.delete(trackingKey);
215
+ deleteBaseline(trackingKey);
216
+ return;
217
+ }
218
+
219
+ const remoteUpdated = data.updatedAt || data.createdAt || '';
220
+ const lastKnown = lastKnownUpdate.get(trackingKey);
221
+ if (lastKnown && lastKnown >= remoteUpdated) return;
222
+
223
+ if (isFolder) {
224
+ // Folder docs mirror to a local directory. No blob, no content
225
+ // comparison; if the directory doesn't exist yet, create it.
226
+ if (!fs.existsSync(localFilePath)) {
227
+ suppressLocal.add(localFilePath);
228
+ fs.mkdirSync(localFilePath, { recursive: true });
229
+ console.log(` [${orgId}] [firestore → local] MKDIR ${docPath}`);
230
+ setTimeout(() => suppressLocal.delete(localFilePath), 1000);
231
+ }
232
+ lastKnownUpdate.set(trackingKey, remoteUpdated);
233
+ return;
234
+ }
235
+
236
+ if (fs.existsSync(localFilePath)) {
237
+ const baseline = lastSyncedHash.get(trackingKey);
238
+ if (baseline !== undefined) {
239
+ // We have a record of what we last synced: decide by content,
240
+ // not mtime. (Obsidian keeps the mtime fresh, which made the old
241
+ // mtime guard wrongly skip web edits.)
242
+ const localHash = computeContentHash(fs.readFileSync(localFilePath));
243
+ if (localHash === data.contentHash) {
244
+ // Local already matches the incoming server content; nothing
245
+ // to write.
246
+ lastKnownUpdate.set(trackingKey, remoteUpdated);
247
+ setBaseline(trackingKey, data.contentHash);
248
+ return;
249
+ }
250
+ if (localHash !== baseline) {
251
+ // Local changed since our last sync AND the server changed:
252
+ // a genuine conflict. Keep the local copy rather than clobber
253
+ // it. (Writing a "conflicted copy" is the next increment.)
254
+ console.log(` [${orgId}] [conflict] ${docPath}: local and server both changed; keeping local`);
255
+ return;
256
+ }
257
+ // localHash === baseline: local is untouched since last sync, so
258
+ // the server is authoritative -> fall through and pull.
259
+ } else {
260
+ // No baseline at all: a local file this daemon has never synced
261
+ // (e.g. created locally while offline, or a folder adopted from
262
+ // elsewhere). We can't tell by content who is newer, so fall
263
+ // back to the mtime heuristic. Conflict copies (next increment)
264
+ // will replace this last-resort with a no-loss outcome.
265
+ const localMtime = fs.statSync(localFilePath).mtime.toISOString();
266
+ if (localMtime > remoteUpdated) return;
267
+ }
268
+ }
269
+
270
+ const action = change.type === 'added' ? 'CREATE' : 'UPDATE';
271
+ console.log(` [${orgId}] [firestore → local] ${action} ${docPath} (${data.scope || 'team'})`);
272
+ ensureDir(localFilePath);
273
+ suppressLocal.add(localFilePath);
274
+
275
+ const ext = extFromPath(docPath);
276
+ // Extension is used as a fallback authority when the stored
277
+ // mimeType claims non-text but the extension maps to a text mime
278
+ // in EXT_TO_MIME. This heals files uploaded before their extension
279
+ // was registered as text (e.g. .base files uploaded prior to the
280
+ // text/yaml mapping landing): on the next pull the daemon writes
281
+ // them as UTF-8 so text-aware features (line diffing, conflict
282
+ // markers) can apply.
283
+ const extDerivedMime = mimeTypeFromExt(ext);
284
+ const isText = isTextMimeType(data.mimeType) || isTextMimeType(extDerivedMime);
285
+ // Storage rules require the Firestore doc to exist before they
286
+ // authorize a blob upload, so writers must create the doc first
287
+ // and upload second. That leaves a window where the metadata is
288
+ // visible (this listener fires) but the blob is not yet uploaded.
289
+ // Retry on object-not-found with exponential backoff covers that
290
+ // race; on a persistent miss we fall through to the original
291
+ // catch upstream.
292
+ const blobReadArgs = { storage, orgId, fileId: change.doc.id, ext };
293
+ const retryOpts = { expectedHash: data.contentHash };
294
+ if (isText) {
295
+ const content = await readBlobWithRetry(() => readBlobAsText(blobReadArgs), retryOpts);
296
+ fs.writeFileSync(localFilePath, content || '', 'utf-8');
297
+ } else {
298
+ const bytes = await readBlobWithRetry(() => readBlob(blobReadArgs), retryOpts);
299
+ fs.writeFileSync(localFilePath, bytes);
300
+ }
301
+ lastKnownUpdate.set(trackingKey, remoteUpdated);
302
+ setBaseline(trackingKey, data.contentHash);
303
+ setTimeout(() => suppressLocal.delete(localFilePath), 1000);
304
+ }
305
+
306
+ // Set up per-team resources: mkdir the local folder, register the
307
+ // Firestore listener for that team's files. Wired into the
308
+ // TeamRegistry; called once per teamspace as it appears.
309
+ setupTeam = (teamId, teamData) => {
310
+ const teamFolder = teamFolderName(teamData.name);
311
+ const teamPath = path.join(LOCAL_PATH, teamFolder);
312
+ if (!fs.existsSync(teamPath)) fs.mkdirSync(teamPath, { recursive: true });
313
+
314
+ const q = query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId));
315
+ const unsubscribe = onSnapshot(q, snapshot => {
316
+ snapshot.docChanges().forEach(change => {
317
+ handleFirestoreChange(change, data =>
318
+ path.join(LOCAL_PATH, teamFolder, data.path),
319
+ ).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
320
+ });
321
+ }, err => console.error(` [${orgId}] Team "${teamFolder}" listener error:`, err));
322
+
323
+ teamUnsubscribers.set(teamId, unsubscribe);
324
+ console.log(` [${orgId}] Listening: ${teamFolder}`);
325
+ };
326
+
327
+ // Live listener on the teams collection. Initial snapshot bootstraps
328
+ // the known teamspaces; subsequent changes pick up new/renamed/
329
+ // removed teamspaces without restarting the daemon. We await the
330
+ // first snapshot so the local watcher sees a populated teamIdsByName
331
+ // before it tries to route any addDir/add events.
332
+ let teamsUnsub = null;
333
+ await new Promise((resolve, reject) => {
334
+ let firstSnapshotResolved = false;
335
+ teamsUnsub = onSnapshot(collection(db, `orgs/${orgId}/teams`), (snapshot) => {
336
+ snapshot.docChanges().forEach((change) => {
337
+ teamRegistry.applyChange({
338
+ type: change.type,
339
+ id: change.doc.id,
340
+ data: change.doc.data(),
341
+ });
342
+ });
343
+ if (!firstSnapshotResolved) {
344
+ firstSnapshotResolved = true;
345
+ console.log(` [${orgId}] Teams: ${teamRegistry.names().join(', ') || 'none'}`);
346
+ resolve();
347
+ }
348
+ }, (err) => {
349
+ console.error(` [${orgId}] Teams listener error:`, err);
350
+ if (!firstSnapshotResolved) {
351
+ firstSnapshotResolved = true;
352
+ reject(err);
353
+ }
354
+ });
355
+ });
356
+ unsubscribers.push(teamsUnsub);
357
+
358
+ function startFirestoreListeners() {
359
+ console.log(` [${orgId}] Starting Firestore listeners...`);
360
+
361
+ if (userId) {
362
+ const privateQ = query(filesRef, where('scope', '==', 'private'), where('ownerId', '==', userId));
363
+ const privateUnsub = onSnapshot(privateQ, snapshot => {
364
+ snapshot.docChanges().forEach(change => {
365
+ const data = change.doc.data();
366
+ const realPath = path.join(LOCAL_PATH, PRIVATE_FOLDER, data.path);
367
+
368
+ handleFirestoreChange(change, () => realPath)
369
+ .catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
370
+
371
+ if (change.type !== 'removed') {
372
+ updateSharedByMeSymlink(data, realPath);
373
+ } else {
374
+ const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, data.path);
375
+ try { fs.lstatSync(symlinkPath); fs.unlinkSync(symlinkPath); } catch (e) { /* ok */ }
376
+ }
377
+ });
378
+ }, err => console.error(` [${orgId}] Private files listener error:`, err));
379
+ unsubscribers.push(privateUnsub);
380
+ console.log(` [${orgId}] Listening: Private files + Shared by Me symlinks`);
381
+
382
+ // scope=='private' is required: Firestore rules only grant
383
+ // sharedWith-based reads on private files, so the query must
384
+ // constrain scope or the whole listen is denied (rules are not
385
+ // filters). See tests/rules.test.js "shared-with-me query".
386
+ const sharedQ = query(
387
+ filesRef,
388
+ where('scope', '==', 'private'),
389
+ where('sharedWith', 'array-contains', userId),
390
+ );
391
+ const sharedUnsub = onSnapshot(sharedQ, snapshot => {
392
+ snapshot.docChanges().forEach(change => {
393
+ const data = change.doc.data();
394
+ if (data.ownerId === userId) return;
395
+ handleFirestoreChange(change, () =>
396
+ path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER, data.path),
397
+ ).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
398
+ });
399
+ }, err => console.error(` [${orgId}] Shared with me listener error:`, err));
400
+ unsubscribers.push(sharedUnsub);
401
+ console.log(` [${orgId}] Listening: Shared with Me`);
402
+ }
403
+ }
404
+
405
+ async function pushToFirestore(filePath, data, mimeType, localModifiedAt) {
406
+ const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
407
+ const trackingKey = `${scope}:${docPath}`;
408
+
409
+ // Skip if the local content is unchanged since the last sync. This
410
+ // is the push-side counterpart of the content baseline: an editor
411
+ // re-saving or touching a file (or the watcher re-emitting it on
412
+ // startup) must not push stale bytes over a newer server edit. Only
413
+ // a genuine content change proceeds.
414
+ const contentHash = computeContentHash(data);
415
+ if (lastSyncedHash.get(trackingKey) === contentHash) return;
416
+
417
+ const now = new Date().toISOString();
418
+
419
+ // Set the echo-suppression marker BEFORE writing. Our own write
420
+ // round-trips through the Firestore listener; without a marker
421
+ // already in place, that echo is treated as an incoming change and
422
+ // triggers a spurious self-pull, which (a) reads the blob before it
423
+ // is uploaded (object-not-found) and (b) adds the path to
424
+ // suppressLocal, swallowing a subsequent rename's unlink (leaving a
425
+ // duplicate doc). Pre-setting it suppresses the echo.
426
+ lastKnownUpdate.set(trackingKey, now);
427
+
428
+ // Baseline = what we last saw as the remote contentHash for this
429
+ // path. pushFileToCloud uses it as a compare-and-swap precondition
430
+ // (DOU-167): if the remote has moved past this baseline, refuse
431
+ // the push and surface the conflict to the caller.
432
+ const baseline = lastSyncedHash.get(trackingKey);
433
+
434
+ const result = await pushFileToCloud({
435
+ filesRef,
436
+ storage,
437
+ orgId,
438
+ userId,
439
+ scope,
440
+ teamId,
441
+ docPath,
442
+ data,
443
+ mimeType,
444
+ now,
445
+ localModifiedAt,
446
+ expectedRemoteHash: baseline,
447
+ });
448
+
449
+ // Record the synced content baseline whenever local matches the
450
+ // server (created/updated, or already-equal noop), so the pull side
451
+ // can tell "local unchanged" from "local edited" by content.
452
+ if (result.action === 'created' || result.action === 'updated' || result.action === 'noop') {
453
+ setBaseline(trackingKey, contentHash);
454
+ } else if (result.action === 'skipped-stale') {
455
+ // The server copy is genuinely newer; clear the pre-set marker so
456
+ // its firestore -> local snapshot is allowed through to update the
457
+ // local file.
458
+ lastKnownUpdate.delete(trackingKey);
459
+ } else if (result.action === 'conflict') {
460
+ // Another writer landed an edit in the gap between our last sync
461
+ // and this push. Preserve the user's would-be push as a sibling
462
+ // conflict copy, pull the new remote state into the live file,
463
+ // and advance the baseline so the next push (or further edits)
464
+ // can proceed against the new ground truth.
465
+ const conflictPath = writeConflictCopy(filePath, data);
466
+ await pullRemoteIntoLocal(result.remoteData, filePath);
467
+ setBaseline(trackingKey, result.remoteHash);
468
+ lastKnownUpdate.delete(trackingKey);
469
+ console.log(
470
+ ` [${orgId}] [local → firestore] CONFLICT ${docPath}: ` +
471
+ `remote changed; local saved to ${path.basename(conflictPath)}`,
472
+ );
473
+ return;
474
+ }
475
+
476
+ if (result.action === 'created') {
477
+ console.log(` [${orgId}] [local → firestore] CREATE ${docPath} (${scope}, ${mimeType})`);
478
+ } else if (result.action === 'updated') {
479
+ console.log(` [${orgId}] [local → firestore] UPDATE ${docPath} (${scope})`);
480
+ } else if (result.action === 'skipped-stale') {
481
+ console.log(` [${orgId}] [local → firestore] SKIP ${docPath} (server copy is newer)`);
482
+ }
483
+ if (result.storageError) {
484
+ console.error(` [${orgId}] [storage] upload failed for ${docPath}: ${result.storageError.message}`);
485
+ }
486
+ }
487
+
488
+ /** Write the proposed (rejected) push to a sibling conflict file.
489
+ * Returns the new path. Suppresses chokidar so the conflict file's
490
+ * creation doesn't trigger its own push. */
491
+ function writeConflictCopy(filePath, data) {
492
+ const parsed = path.parse(filePath);
493
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
494
+ const conflictPath = path.join(parsed.dir, `${parsed.name}.conflict-${ts}${parsed.ext}`);
495
+ suppressLocal.add(conflictPath);
496
+ fs.writeFileSync(conflictPath, data);
497
+ setTimeout(() => suppressLocal.delete(conflictPath), 1000);
498
+ return conflictPath;
499
+ }
500
+
501
+ /** Pull the remote blob into the live local path after a conflict.
502
+ * Uses suppressLocal so chokidar doesn't re-emit the write. */
503
+ async function pullRemoteIntoLocal(remoteData, localFilePath) {
504
+ const ext = extFromPath(remoteData.path);
505
+ const extDerivedMime = mimeTypeFromExt(ext);
506
+ const isText = isTextMimeType(remoteData.mimeType) || isTextMimeType(extDerivedMime);
507
+ suppressLocal.add(localFilePath);
508
+ const blobReadArgs = { storage, orgId, fileId: remoteData.id, ext };
509
+ const retryOpts = { expectedHash: remoteData.contentHash };
510
+ if (isText) {
511
+ const content = await readBlobWithRetry(() => readBlobAsText(blobReadArgs), retryOpts);
512
+ fs.writeFileSync(localFilePath, content || '', 'utf-8');
513
+ } else {
514
+ const bytes = await readBlobWithRetry(() => readBlob(blobReadArgs), retryOpts);
515
+ fs.writeFileSync(localFilePath, bytes);
516
+ }
517
+ setTimeout(() => suppressLocal.delete(localFilePath), 1000);
518
+ }
519
+
520
+ async function deleteFromFirestore(filePath) {
521
+ const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
522
+ const trackingKey = `${scope}:${docPath}`;
523
+ lastKnownUpdate.delete(trackingKey);
524
+
525
+ const result = await deleteFileFromCloud({
526
+ filesRef,
527
+ storage,
528
+ orgId,
529
+ userId,
530
+ scope,
531
+ teamId,
532
+ docPath,
533
+ });
534
+
535
+ if (result.action === 'deleted') {
536
+ console.log(` [${orgId}] [local → firestore] DELETE ${docPath} (${scope})`);
537
+ }
538
+ }
539
+
540
+ // Determine whether a local path corresponds to a syncable directory.
541
+ // Excludes the scope-root directories themselves (Private, Shared
542
+ // with Me, Shared by Me, and the per-team roots) since those are
543
+ // managed by ensureFolderStructure, not the user.
544
+ function isSyncableLocalDir(rel) {
545
+ if (!rel) return false;
546
+ if (rel === PRIVATE_FOLDER || rel === SHARED_WITH_ME_FOLDER || rel === SHARED_BY_ME_FOLDER) {
547
+ return false;
548
+ }
549
+ if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return false;
550
+ if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return false;
551
+ const parts = rel.split(path.sep);
552
+ // A bare team root (e.g. "Engineering Teamspace") corresponds to
553
+ // the scope root, not a subdirectory. Skip these.
554
+ if (parts.length === 1 && parts[0].endsWith(' Teamspace')) return false;
555
+ return true;
556
+ }
557
+
558
+ async function pushDirToFirestore(dirPath) {
559
+ const { scope, teamId, docPath } = scopeFromLocalPath(dirPath, LOCAL_PATH, teamIdsByName);
560
+ if (!docPath) return; // scope root
561
+ const trackingKey = `${scope}:${docPath}`;
562
+ const now = new Date().toISOString();
563
+ lastKnownUpdate.set(trackingKey, now);
564
+
565
+ const result = await pushFolderToCloud({
566
+ filesRef,
567
+ scope,
568
+ teamId,
569
+ userId,
570
+ docPath,
571
+ now,
572
+ });
573
+
574
+ if (result.action === 'ensured' && result.folderId) {
575
+ console.log(` [${orgId}] [local → firestore] FOLDER ${docPath} (${scope})`);
576
+ }
577
+ }
578
+
579
+ async function deleteDirFromFirestore(dirPath) {
580
+ const { scope, teamId, docPath } = scopeFromLocalPath(dirPath, LOCAL_PATH, teamIdsByName);
581
+ if (!docPath) return;
582
+ const trackingKey = `${scope}:${docPath}`;
583
+ lastKnownUpdate.delete(trackingKey);
584
+
585
+ const result = await deleteFolderFromCloud({
586
+ filesRef,
587
+ scope,
588
+ teamId,
589
+ userId,
590
+ docPath,
591
+ });
592
+
593
+ if (result.action === 'deleted') {
594
+ console.log(` [${orgId}] [local → firestore] RMFOLDER ${docPath} (${scope})`);
595
+ }
596
+ }
597
+
598
+ function handleLocalChange(filePath) {
599
+ if (suppressLocal.has(filePath)) return;
600
+
601
+ const rel = path.relative(LOCAL_PATH, filePath);
602
+ if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return;
603
+ if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return;
604
+
605
+ let stat;
606
+ try {
607
+ stat = fs.statSync(filePath);
608
+ } catch (err) {
609
+ return; // disappeared between event and read
610
+ }
611
+ if (!stat.isFile()) return;
612
+ if (stat.size > maxUploadBytes) {
613
+ console.error(` [${orgId}] [skip] ${rel} exceeds ${maxUploadBytes} byte cap (${stat.size} bytes)`);
614
+ return;
615
+ }
616
+
617
+ const ext = extFromPath(rel);
618
+ const mimeType = mimeTypeFromExt(ext);
619
+ // Text files: read as a UTF-8 string so future text-aware features
620
+ // (line-level diffing, conflict markers) can work. Binary files:
621
+ // read as a Buffer so bytes round-trip without corruption.
622
+ const data = isTextMimeType(mimeType)
623
+ ? fs.readFileSync(filePath, 'utf-8')
624
+ : fs.readFileSync(filePath);
625
+ const localModifiedAt = stat.mtime.toISOString();
626
+ pushToFirestore(filePath, data, mimeType, localModifiedAt).catch(err =>
627
+ console.error(` [${orgId}] Push error: ${err.message}`)
628
+ );
629
+ }
630
+
631
+ function handleLocalDirAdded(dirPath) {
632
+ if (suppressLocal.has(dirPath)) return;
633
+ const rel = path.relative(LOCAL_PATH, dirPath);
634
+ if (!isSyncableLocalDir(rel)) return;
635
+ pushDirToFirestore(dirPath).catch(err =>
636
+ console.error(` [${orgId}] Folder push error for ${rel}: ${err.message}`)
637
+ );
638
+ }
639
+
640
+ function handleLocalDirRemoved(dirPath) {
641
+ if (suppressLocal.has(dirPath)) return;
642
+ const rel = path.relative(LOCAL_PATH, dirPath);
643
+ if (!isSyncableLocalDir(rel)) return;
644
+ deleteDirFromFirestore(dirPath).catch(err =>
645
+ console.error(` [${orgId}] Folder delete error for ${rel}: ${err.message}`)
646
+ );
647
+ }
648
+
649
+ function startLocalWatcher() {
650
+ console.log(` [${orgId}] Watching local files...`);
651
+
652
+ watcher = chokidar.watch(LOCAL_PATH, {
653
+ ignored: [
654
+ // Dotfiles (the sync engine's own .compound-sync-state.json lives
655
+ // here, plus things like .DS_Store, .git, etc. that the user
656
+ // doesn't want syncing).
657
+ /(^|[\/\\])\./,
658
+ // node_modules: large dependency dirs, never appropriate to sync.
659
+ /node_modules/,
660
+ ],
661
+ persistent: true,
662
+ ignoreInitial: false,
663
+ followSymlinks: false,
664
+ awaitWriteFinish: {
665
+ stabilityThreshold: 500,
666
+ pollInterval: 100,
667
+ },
668
+ });
669
+
670
+ watcher.on('add', filePath => handleLocalChange(filePath));
671
+ watcher.on('change', filePath => handleLocalChange(filePath));
672
+ watcher.on('unlink', filePath => {
673
+ if (suppressLocal.has(filePath)) return;
674
+ deleteFromFirestore(filePath).catch(err => console.error(` [${orgId}] Delete error: ${err.message}`));
675
+ });
676
+ watcher.on('addDir', dirPath => handleLocalDirAdded(dirPath));
677
+ watcher.on('unlinkDir', dirPath => handleLocalDirRemoved(dirPath));
678
+
679
+ // Resolve only once chokidar has finished its initial scan and
680
+ // registered inotify (or FSEvents) watches on every existing
681
+ // subdirectory. Until 'ready' fires, files written to the watched
682
+ // tree can be silently missed because the OS-level watch hasn't
683
+ // attached yet. Callers awaiting startOrgSync get a guarantee
684
+ // that any local writes after the await are seen by the daemon.
685
+ return new Promise((resolve) => {
686
+ watcher.on('ready', () => {
687
+ console.log(` [${orgId}] Local watcher ready.`);
688
+ resolve();
689
+ });
690
+ });
691
+ }
692
+
693
+ startFirestoreListeners();
694
+ await startLocalWatcher();
695
+
696
+ return {
697
+ // Tear down all listeners and the watcher. Returns the watcher's
698
+ // close() promise so callers/tests can await full shutdown.
699
+ stop() {
700
+ for (const unsub of unsubscribers) {
701
+ try { unsub(); } catch (e) { /* ignore */ }
702
+ }
703
+ for (const unsub of teamUnsubscribers.values()) {
704
+ try { unsub(); } catch (e) { /* ignore */ }
705
+ }
706
+ teamUnsubscribers.clear();
707
+ return watcher ? watcher.close() : Promise.resolve();
708
+ },
709
+ };
710
+ }