@doubling/compound-sync 1.7.1 → 1.9.5

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,607 @@
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
+ // Start the per-org sync loop. Returns { stop } to unsubscribe all
44
+ // listeners and close the watcher (used by tests and by graceful
45
+ // shutdown). `localPath` must already be absolute/expanded.
46
+ export async function startOrgSync({
47
+ db,
48
+ storage,
49
+ userId,
50
+ orgId,
51
+ localPath,
52
+ maxUploadBytes = DEFAULT_MAX_UPLOAD_BYTES,
53
+ statePath,
54
+ }) {
55
+ const LOCAL_PATH = localPath;
56
+ const filesRef = collection(db, `orgs/${orgId}/files`);
57
+ // Where the per-folder content baseline is persisted. Default: a
58
+ // dotfile in the synced folder (ignored by the watcher), so it travels
59
+ // with the folder and needs no extra wiring from the caller.
60
+ const STATE_PATH = statePath ?? path.join(LOCAL_PATH, '.compound-sync-state.json');
61
+
62
+ // Teardown handles: every onSnapshot unsub plus the chokidar watcher.
63
+ const unsubscribers = [];
64
+ let watcher = null;
65
+
66
+ const lastKnownUpdate = new Map();
67
+ // Content hash of each file as of the last successful sync (push or
68
+ // pull), keyed by trackingKey. This "baseline" decides pull/push
69
+ // direction by CONTENT rather than filesystem mtime (which an editor
70
+ // like Obsidian bumps without a content change). Loaded from
71
+ // STATE_PATH so it survives restarts; persisted on every change.
72
+ const lastSyncedHash = new Map(Object.entries(loadSyncState(STATE_PATH)));
73
+ function persistBaseline() {
74
+ saveSyncState(STATE_PATH, Object.fromEntries(lastSyncedHash));
75
+ }
76
+ function setBaseline(key, hash) { lastSyncedHash.set(key, hash); persistBaseline(); }
77
+ function deleteBaseline(key) { lastSyncedHash.delete(key); persistBaseline(); }
78
+ const suppressLocal = new Set();
79
+ // Per-team Firestore-listener unsubscribe handles, keyed by teamId.
80
+ const teamUnsubscribers = new Map();
81
+ // Hoisted forward-decl: setupTeam closes over handleFirestoreChange,
82
+ // which is defined later in this function. Wired below.
83
+ let setupTeam = () => {};
84
+ function teardownTeam(teamId, teamData) {
85
+ const unsub = teamUnsubscribers.get(teamId);
86
+ if (unsub) {
87
+ try { unsub(); } catch (e) { /* ignore */ }
88
+ teamUnsubscribers.delete(teamId);
89
+ const folder = teamData && teamData.name ? teamFolderName(teamData.name) : teamId;
90
+ console.log(` [${orgId}] Stopped listening: ${folder}`);
91
+ }
92
+ }
93
+ const teamRegistry = new TeamRegistry({
94
+ onSetup: (teamId, teamData) => setupTeam(teamId, teamData),
95
+ onTeardown: teardownTeam,
96
+ });
97
+ // scopeFromLocalPath expects a Map<teamName, teamId>; the registry
98
+ // exposes one matching that shape.
99
+ const teamIdsByName = teamRegistry.teamIdsByName;
100
+
101
+ console.log('');
102
+ console.log(` [${orgId}] Local: ${LOCAL_PATH}`);
103
+
104
+ function ensureDir(filePath) {
105
+ const dir = path.dirname(filePath);
106
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
107
+ }
108
+
109
+ function updateSharedByMeSymlink(fileDoc, realPath) {
110
+ const sharedWith = fileDoc.sharedWith || [];
111
+ const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, fileDoc.path);
112
+
113
+ if (sharedWith.length > 0) {
114
+ ensureDir(symlinkPath);
115
+ const relativeTo = path.relative(path.dirname(symlinkPath), realPath);
116
+ try {
117
+ if (fs.existsSync(symlinkPath) || fs.lstatSync(symlinkPath).isSymbolicLink()) {
118
+ fs.unlinkSync(symlinkPath);
119
+ }
120
+ } catch (e) { /* doesn't exist */ }
121
+ try {
122
+ fs.symlinkSync(relativeTo, symlinkPath);
123
+ console.log(` [${orgId}] [symlink] ${fileDoc.path} → Shared by Me/`);
124
+ } catch (e) {
125
+ if (e.code !== 'EEXIST') console.error(` [${orgId}] Symlink error: ${e.message}`);
126
+ }
127
+ } else {
128
+ try {
129
+ if (fs.lstatSync(symlinkPath).isSymbolicLink()) {
130
+ fs.unlinkSync(symlinkPath);
131
+ console.log(` [${orgId}] [symlink] removed Shared by Me/${fileDoc.path}`);
132
+ }
133
+ } catch (e) { /* doesn't exist */ }
134
+ }
135
+ }
136
+
137
+ // Static (user-level) folders. Team folders are created per-team by
138
+ // setupTeam below as soon as the registry sees each teamspace.
139
+ function ensureFolderStructure() {
140
+ const privatePath = path.join(LOCAL_PATH, PRIVATE_FOLDER);
141
+ if (!fs.existsSync(privatePath)) fs.mkdirSync(privatePath, { recursive: true });
142
+
143
+ const sharedWithMePath = path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER);
144
+ if (!fs.existsSync(sharedWithMePath)) fs.mkdirSync(sharedWithMePath, { recursive: true });
145
+
146
+ const sharedByMePath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER);
147
+ if (!fs.existsSync(sharedByMePath)) fs.mkdirSync(sharedByMePath, { recursive: true });
148
+ }
149
+
150
+ ensureFolderStructure();
151
+
152
+ async function handleFirestoreChange(change, getLocalPath) {
153
+ const data = change.doc.data();
154
+ const docPath = data.path;
155
+ if (!docPath) return;
156
+
157
+ const localFilePath = getLocalPath(data);
158
+ const trackingKey = `${data.scope || 'team'}:${docPath}`;
159
+ const isFolder = data.type === 'folder';
160
+
161
+ if (change.type === 'removed') {
162
+ if (isFolder) {
163
+ // Best-effort directory removal. The daemon's own file-delete
164
+ // events should have cleared all children by the time we get
165
+ // here; if not, rmdirSync throws ENOTEMPTY and we keep the
166
+ // directory rather than risk losing data the user hasn't seen.
167
+ try {
168
+ suppressLocal.add(localFilePath);
169
+ fs.rmdirSync(localFilePath);
170
+ console.log(` [${orgId}] [firestore → local] RMDIR ${docPath}`);
171
+ setTimeout(() => suppressLocal.delete(localFilePath), 1000);
172
+ } catch (err) {
173
+ suppressLocal.delete(localFilePath);
174
+ if (err.code !== 'ENOENT' && err.code !== 'ENOTEMPTY') {
175
+ console.error(` [${orgId}] [firestore → local] RMDIR ${docPath} failed: ${err.message}`);
176
+ }
177
+ }
178
+ } else if (fs.existsSync(localFilePath)) {
179
+ console.log(` [${orgId}] [firestore → local] DELETE ${docPath}`);
180
+ suppressLocal.add(localFilePath);
181
+ fs.unlinkSync(localFilePath);
182
+ setTimeout(() => suppressLocal.delete(localFilePath), 1000);
183
+ }
184
+ lastKnownUpdate.delete(trackingKey);
185
+ deleteBaseline(trackingKey);
186
+ return;
187
+ }
188
+
189
+ const remoteUpdated = data.updatedAt || data.createdAt || '';
190
+ const lastKnown = lastKnownUpdate.get(trackingKey);
191
+ if (lastKnown && lastKnown >= remoteUpdated) return;
192
+
193
+ if (isFolder) {
194
+ // Folder docs mirror to a local directory. No blob, no content
195
+ // comparison; if the directory doesn't exist yet, create it.
196
+ if (!fs.existsSync(localFilePath)) {
197
+ suppressLocal.add(localFilePath);
198
+ fs.mkdirSync(localFilePath, { recursive: true });
199
+ console.log(` [${orgId}] [firestore → local] MKDIR ${docPath}`);
200
+ setTimeout(() => suppressLocal.delete(localFilePath), 1000);
201
+ }
202
+ lastKnownUpdate.set(trackingKey, remoteUpdated);
203
+ return;
204
+ }
205
+
206
+ if (fs.existsSync(localFilePath)) {
207
+ const baseline = lastSyncedHash.get(trackingKey);
208
+ if (baseline !== undefined) {
209
+ // We have a record of what we last synced: decide by content,
210
+ // not mtime. (Obsidian keeps the mtime fresh, which made the old
211
+ // mtime guard wrongly skip web edits.)
212
+ const localHash = computeContentHash(fs.readFileSync(localFilePath));
213
+ if (localHash === data.contentHash) {
214
+ // Local already matches the incoming server content; nothing
215
+ // to write.
216
+ lastKnownUpdate.set(trackingKey, remoteUpdated);
217
+ setBaseline(trackingKey, data.contentHash);
218
+ return;
219
+ }
220
+ if (localHash !== baseline) {
221
+ // Local changed since our last sync AND the server changed:
222
+ // a genuine conflict. Keep the local copy rather than clobber
223
+ // it. (Writing a "conflicted copy" is the next increment.)
224
+ console.log(` [${orgId}] [conflict] ${docPath}: local and server both changed; keeping local`);
225
+ return;
226
+ }
227
+ // localHash === baseline: local is untouched since last sync, so
228
+ // the server is authoritative -> fall through and pull.
229
+ } else {
230
+ // No baseline at all: a local file this daemon has never synced
231
+ // (e.g. created locally while offline, or a folder adopted from
232
+ // elsewhere). We can't tell by content who is newer, so fall
233
+ // back to the mtime heuristic. Conflict copies (next increment)
234
+ // will replace this last-resort with a no-loss outcome.
235
+ const localMtime = fs.statSync(localFilePath).mtime.toISOString();
236
+ if (localMtime > remoteUpdated) return;
237
+ }
238
+ }
239
+
240
+ const action = change.type === 'added' ? 'CREATE' : 'UPDATE';
241
+ console.log(` [${orgId}] [firestore → local] ${action} ${docPath} (${data.scope || 'team'})`);
242
+ ensureDir(localFilePath);
243
+ suppressLocal.add(localFilePath);
244
+
245
+ const ext = extFromPath(docPath);
246
+ if (isTextMimeType(data.mimeType)) {
247
+ const content = await readBlobAsText({
248
+ storage,
249
+ orgId,
250
+ fileId: change.doc.id,
251
+ ext,
252
+ });
253
+ fs.writeFileSync(localFilePath, content || '', 'utf-8');
254
+ } else {
255
+ const bytes = await readBlob({
256
+ storage,
257
+ orgId,
258
+ fileId: change.doc.id,
259
+ ext,
260
+ });
261
+ fs.writeFileSync(localFilePath, bytes);
262
+ }
263
+ lastKnownUpdate.set(trackingKey, remoteUpdated);
264
+ setBaseline(trackingKey, data.contentHash);
265
+ setTimeout(() => suppressLocal.delete(localFilePath), 1000);
266
+ }
267
+
268
+ // Set up per-team resources: mkdir the local folder, register the
269
+ // Firestore listener for that team's files. Wired into the
270
+ // TeamRegistry; called once per teamspace as it appears.
271
+ setupTeam = (teamId, teamData) => {
272
+ const teamFolder = teamFolderName(teamData.name);
273
+ const teamPath = path.join(LOCAL_PATH, teamFolder);
274
+ if (!fs.existsSync(teamPath)) fs.mkdirSync(teamPath, { recursive: true });
275
+
276
+ const q = query(filesRef, where('scope', '==', 'team'), where('teamId', '==', teamId));
277
+ const unsubscribe = onSnapshot(q, snapshot => {
278
+ snapshot.docChanges().forEach(change => {
279
+ handleFirestoreChange(change, data =>
280
+ path.join(LOCAL_PATH, teamFolder, data.path),
281
+ ).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
282
+ });
283
+ }, err => console.error(` [${orgId}] Team "${teamFolder}" listener error:`, err));
284
+
285
+ teamUnsubscribers.set(teamId, unsubscribe);
286
+ console.log(` [${orgId}] Listening: ${teamFolder}`);
287
+ };
288
+
289
+ // Live listener on the teams collection. Initial snapshot bootstraps
290
+ // the known teamspaces; subsequent changes pick up new/renamed/
291
+ // removed teamspaces without restarting the daemon. We await the
292
+ // first snapshot so the local watcher sees a populated teamIdsByName
293
+ // before it tries to route any addDir/add events.
294
+ let teamsUnsub = null;
295
+ await new Promise((resolve, reject) => {
296
+ let firstSnapshotResolved = false;
297
+ teamsUnsub = onSnapshot(collection(db, `orgs/${orgId}/teams`), (snapshot) => {
298
+ snapshot.docChanges().forEach((change) => {
299
+ teamRegistry.applyChange({
300
+ type: change.type,
301
+ id: change.doc.id,
302
+ data: change.doc.data(),
303
+ });
304
+ });
305
+ if (!firstSnapshotResolved) {
306
+ firstSnapshotResolved = true;
307
+ console.log(` [${orgId}] Teams: ${teamRegistry.names().join(', ') || 'none'}`);
308
+ resolve();
309
+ }
310
+ }, (err) => {
311
+ console.error(` [${orgId}] Teams listener error:`, err);
312
+ if (!firstSnapshotResolved) {
313
+ firstSnapshotResolved = true;
314
+ reject(err);
315
+ }
316
+ });
317
+ });
318
+ unsubscribers.push(teamsUnsub);
319
+
320
+ function startFirestoreListeners() {
321
+ console.log(` [${orgId}] Starting Firestore listeners...`);
322
+
323
+ if (userId) {
324
+ const privateQ = query(filesRef, where('scope', '==', 'private'), where('ownerId', '==', userId));
325
+ const privateUnsub = onSnapshot(privateQ, snapshot => {
326
+ snapshot.docChanges().forEach(change => {
327
+ const data = change.doc.data();
328
+ const realPath = path.join(LOCAL_PATH, PRIVATE_FOLDER, data.path);
329
+
330
+ handleFirestoreChange(change, () => realPath)
331
+ .catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
332
+
333
+ if (change.type !== 'removed') {
334
+ updateSharedByMeSymlink(data, realPath);
335
+ } else {
336
+ const symlinkPath = path.join(LOCAL_PATH, SHARED_BY_ME_FOLDER, data.path);
337
+ try { fs.lstatSync(symlinkPath); fs.unlinkSync(symlinkPath); } catch (e) { /* ok */ }
338
+ }
339
+ });
340
+ }, err => console.error(` [${orgId}] Private files listener error:`, err));
341
+ unsubscribers.push(privateUnsub);
342
+ console.log(` [${orgId}] Listening: Private files + Shared by Me symlinks`);
343
+
344
+ // scope=='private' is required: Firestore rules only grant
345
+ // sharedWith-based reads on private files, so the query must
346
+ // constrain scope or the whole listen is denied (rules are not
347
+ // filters). See tests/rules.test.js "shared-with-me query".
348
+ const sharedQ = query(
349
+ filesRef,
350
+ where('scope', '==', 'private'),
351
+ where('sharedWith', 'array-contains', userId),
352
+ );
353
+ const sharedUnsub = onSnapshot(sharedQ, snapshot => {
354
+ snapshot.docChanges().forEach(change => {
355
+ const data = change.doc.data();
356
+ if (data.ownerId === userId) return;
357
+ handleFirestoreChange(change, () =>
358
+ path.join(LOCAL_PATH, SHARED_WITH_ME_FOLDER, data.path),
359
+ ).catch(err => console.error(` [${orgId}] Firestore change handler error:`, err && err.message));
360
+ });
361
+ }, err => console.error(` [${orgId}] Shared with me listener error:`, err));
362
+ unsubscribers.push(sharedUnsub);
363
+ console.log(` [${orgId}] Listening: Shared with Me`);
364
+ }
365
+ }
366
+
367
+ async function pushToFirestore(filePath, data, mimeType, localModifiedAt) {
368
+ const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
369
+ const trackingKey = `${scope}:${docPath}`;
370
+
371
+ // Skip if the local content is unchanged since the last sync. This
372
+ // is the push-side counterpart of the content baseline: an editor
373
+ // re-saving or touching a file (or the watcher re-emitting it on
374
+ // startup) must not push stale bytes over a newer server edit. Only
375
+ // a genuine content change proceeds.
376
+ const contentHash = computeContentHash(data);
377
+ if (lastSyncedHash.get(trackingKey) === contentHash) return;
378
+
379
+ const now = new Date().toISOString();
380
+
381
+ // Set the echo-suppression marker BEFORE writing. Our own write
382
+ // round-trips through the Firestore listener; without a marker
383
+ // already in place, that echo is treated as an incoming change and
384
+ // triggers a spurious self-pull, which (a) reads the blob before it
385
+ // is uploaded (object-not-found) and (b) adds the path to
386
+ // suppressLocal, swallowing a subsequent rename's unlink (leaving a
387
+ // duplicate doc). Pre-setting it suppresses the echo.
388
+ lastKnownUpdate.set(trackingKey, now);
389
+
390
+ const result = await pushFileToCloud({
391
+ filesRef,
392
+ storage,
393
+ orgId,
394
+ userId,
395
+ scope,
396
+ teamId,
397
+ docPath,
398
+ data,
399
+ mimeType,
400
+ now,
401
+ localModifiedAt,
402
+ });
403
+
404
+ // Record the synced content baseline whenever local matches the
405
+ // server (created/updated, or already-equal noop), so the pull side
406
+ // can tell "local unchanged" from "local edited" by content.
407
+ if (result.action === 'created' || result.action === 'updated' || result.action === 'noop') {
408
+ setBaseline(trackingKey, contentHash);
409
+ } else if (result.action === 'skipped-stale') {
410
+ // The server copy is genuinely newer; clear the pre-set marker so
411
+ // its firestore -> local snapshot is allowed through to update the
412
+ // local file.
413
+ lastKnownUpdate.delete(trackingKey);
414
+ }
415
+
416
+ if (result.action === 'created') {
417
+ console.log(` [${orgId}] [local → firestore] CREATE ${docPath} (${scope}, ${mimeType})`);
418
+ } else if (result.action === 'updated') {
419
+ console.log(` [${orgId}] [local → firestore] UPDATE ${docPath} (${scope})`);
420
+ } else if (result.action === 'skipped-stale') {
421
+ console.log(` [${orgId}] [local → firestore] SKIP ${docPath} (server copy is newer)`);
422
+ }
423
+ if (result.storageError) {
424
+ console.error(` [${orgId}] [storage] upload failed for ${docPath}: ${result.storageError.message}`);
425
+ }
426
+ }
427
+
428
+ async function deleteFromFirestore(filePath) {
429
+ const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
430
+ const trackingKey = `${scope}:${docPath}`;
431
+ lastKnownUpdate.delete(trackingKey);
432
+
433
+ const result = await deleteFileFromCloud({
434
+ filesRef,
435
+ storage,
436
+ orgId,
437
+ userId,
438
+ scope,
439
+ teamId,
440
+ docPath,
441
+ });
442
+
443
+ if (result.action === 'deleted') {
444
+ console.log(` [${orgId}] [local → firestore] DELETE ${docPath} (${scope})`);
445
+ }
446
+ }
447
+
448
+ // Determine whether a local path corresponds to a syncable directory.
449
+ // Excludes the scope-root directories themselves (Private, Shared
450
+ // with Me, Shared by Me, and the per-team roots) since those are
451
+ // managed by ensureFolderStructure, not the user.
452
+ function isSyncableLocalDir(rel) {
453
+ if (!rel) return false;
454
+ if (rel === PRIVATE_FOLDER || rel === SHARED_WITH_ME_FOLDER || rel === SHARED_BY_ME_FOLDER) {
455
+ return false;
456
+ }
457
+ if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return false;
458
+ if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return false;
459
+ const parts = rel.split(path.sep);
460
+ // A bare team root (e.g. "Engineering Teamspace") corresponds to
461
+ // the scope root, not a subdirectory. Skip these.
462
+ if (parts.length === 1 && parts[0].endsWith(' Teamspace')) return false;
463
+ return true;
464
+ }
465
+
466
+ async function pushDirToFirestore(dirPath) {
467
+ const { scope, teamId, docPath } = scopeFromLocalPath(dirPath, LOCAL_PATH, teamIdsByName);
468
+ if (!docPath) return; // scope root
469
+ const trackingKey = `${scope}:${docPath}`;
470
+ const now = new Date().toISOString();
471
+ lastKnownUpdate.set(trackingKey, now);
472
+
473
+ const result = await pushFolderToCloud({
474
+ filesRef,
475
+ scope,
476
+ teamId,
477
+ userId,
478
+ docPath,
479
+ now,
480
+ });
481
+
482
+ if (result.action === 'ensured' && result.folderId) {
483
+ console.log(` [${orgId}] [local → firestore] FOLDER ${docPath} (${scope})`);
484
+ }
485
+ }
486
+
487
+ async function deleteDirFromFirestore(dirPath) {
488
+ const { scope, teamId, docPath } = scopeFromLocalPath(dirPath, LOCAL_PATH, teamIdsByName);
489
+ if (!docPath) return;
490
+ const trackingKey = `${scope}:${docPath}`;
491
+ lastKnownUpdate.delete(trackingKey);
492
+
493
+ const result = await deleteFolderFromCloud({
494
+ filesRef,
495
+ scope,
496
+ teamId,
497
+ userId,
498
+ docPath,
499
+ });
500
+
501
+ if (result.action === 'deleted') {
502
+ console.log(` [${orgId}] [local → firestore] RMFOLDER ${docPath} (${scope})`);
503
+ }
504
+ }
505
+
506
+ function handleLocalChange(filePath) {
507
+ if (suppressLocal.has(filePath)) return;
508
+
509
+ const rel = path.relative(LOCAL_PATH, filePath);
510
+ if (rel.startsWith(SHARED_WITH_ME_FOLDER + path.sep)) return;
511
+ if (rel.startsWith(SHARED_BY_ME_FOLDER + path.sep)) return;
512
+
513
+ let stat;
514
+ try {
515
+ stat = fs.statSync(filePath);
516
+ } catch (err) {
517
+ return; // disappeared between event and read
518
+ }
519
+ if (!stat.isFile()) return;
520
+ if (stat.size > maxUploadBytes) {
521
+ console.error(` [${orgId}] [skip] ${rel} exceeds ${maxUploadBytes} byte cap (${stat.size} bytes)`);
522
+ return;
523
+ }
524
+
525
+ const ext = extFromPath(rel);
526
+ const mimeType = mimeTypeFromExt(ext);
527
+ // Text files: read as a UTF-8 string so future text-aware features
528
+ // (line-level diffing, conflict markers) can work. Binary files:
529
+ // read as a Buffer so bytes round-trip without corruption.
530
+ const data = isTextMimeType(mimeType)
531
+ ? fs.readFileSync(filePath, 'utf-8')
532
+ : fs.readFileSync(filePath);
533
+ const localModifiedAt = stat.mtime.toISOString();
534
+ pushToFirestore(filePath, data, mimeType, localModifiedAt).catch(err =>
535
+ console.error(` [${orgId}] Push error: ${err.message}`)
536
+ );
537
+ }
538
+
539
+ function handleLocalDirAdded(dirPath) {
540
+ if (suppressLocal.has(dirPath)) return;
541
+ const rel = path.relative(LOCAL_PATH, dirPath);
542
+ if (!isSyncableLocalDir(rel)) return;
543
+ pushDirToFirestore(dirPath).catch(err =>
544
+ console.error(` [${orgId}] Folder push error for ${rel}: ${err.message}`)
545
+ );
546
+ }
547
+
548
+ function handleLocalDirRemoved(dirPath) {
549
+ if (suppressLocal.has(dirPath)) return;
550
+ const rel = path.relative(LOCAL_PATH, dirPath);
551
+ if (!isSyncableLocalDir(rel)) return;
552
+ deleteDirFromFirestore(dirPath).catch(err =>
553
+ console.error(` [${orgId}] Folder delete error for ${rel}: ${err.message}`)
554
+ );
555
+ }
556
+
557
+ function startLocalWatcher() {
558
+ console.log(` [${orgId}] Watching local files...`);
559
+
560
+ watcher = chokidar.watch(LOCAL_PATH, {
561
+ ignored: [
562
+ // Dotfiles (the sync engine's own .compound-sync-state.json lives
563
+ // here, plus things like .DS_Store, .git, etc. that the user
564
+ // doesn't want syncing).
565
+ /(^|[\/\\])\./,
566
+ // node_modules: large dependency dirs, never appropriate to sync.
567
+ /node_modules/,
568
+ ],
569
+ persistent: true,
570
+ ignoreInitial: false,
571
+ followSymlinks: false,
572
+ awaitWriteFinish: {
573
+ stabilityThreshold: 500,
574
+ pollInterval: 100,
575
+ },
576
+ });
577
+
578
+ watcher.on('add', filePath => handleLocalChange(filePath));
579
+ watcher.on('change', filePath => handleLocalChange(filePath));
580
+ watcher.on('unlink', filePath => {
581
+ if (suppressLocal.has(filePath)) return;
582
+ deleteFromFirestore(filePath).catch(err => console.error(` [${orgId}] Delete error: ${err.message}`));
583
+ });
584
+ watcher.on('addDir', dirPath => handleLocalDirAdded(dirPath));
585
+ watcher.on('unlinkDir', dirPath => handleLocalDirRemoved(dirPath));
586
+
587
+ watcher.on('ready', () => console.log(` [${orgId}] Local watcher ready.`));
588
+ }
589
+
590
+ startFirestoreListeners();
591
+ startLocalWatcher();
592
+
593
+ return {
594
+ // Tear down all listeners and the watcher. Returns the watcher's
595
+ // close() promise so callers/tests can await full shutdown.
596
+ stop() {
597
+ for (const unsub of unsubscribers) {
598
+ try { unsub(); } catch (e) { /* ignore */ }
599
+ }
600
+ for (const unsub of teamUnsubscribers.values()) {
601
+ try { unsub(); } catch (e) { /* ignore */ }
602
+ }
603
+ teamUnsubscribers.clear();
604
+ return watcher ? watcher.close() : Promise.resolve();
605
+ },
606
+ };
607
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/compound-sync",
3
- "version": "1.7.1",
3
+ "version": "1.9.5",
4
4
  "description": "Bidirectional sync between Compound and local markdown files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "sync.js",
11
+ "org-sync.js",
12
+ "sync-state.js",
11
13
  "config.js",
12
14
  "paths.js",
13
15
  "files.js",
@@ -15,12 +17,14 @@
15
17
  "storage-helpers.js",
16
18
  "team-registry.js",
17
19
  "auth-persistence.js",
20
+ "pre-mint-app-check.js",
21
+ "setup-auth-with-pre-mint.js",
18
22
  "README.md"
19
23
  ],
20
24
  "scripts": {
21
25
  "sync": "node sync.js",
22
- "test:unit": "node --test config.test.js paths.test.js storage-helpers.test.js folder-chain.test.js team-registry.test.js auth-persistence.test.js",
23
- "test:integration": "firebase emulators:exec --only firestore,storage 'node --test files.test.js'",
26
+ "test:unit": "node --test config.test.js paths.test.js storage-helpers.test.js folder-chain.test.js team-registry.test.js auth-persistence.test.js pre-mint-app-check.test.js setup-auth-with-pre-mint.test.js sync-state.test.js",
27
+ "test:integration": "firebase emulators:exec --only firestore,storage 'node --test --test-concurrency=1 files.test.js org-sync.test.js'",
24
28
  "test": "npm run test:unit && npm run test:integration"
25
29
  },
26
30
  "dependencies": {