@doubling/compound-sync 1.9.5 → 1.10.2

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/files.js CHANGED
@@ -291,6 +291,13 @@ export async function pushFileToCloud({
291
291
  mimeType,
292
292
  now = new Date().toISOString(),
293
293
  localModifiedAt = null,
294
+ // The contentHash this caller saw as "current" when it last pulled.
295
+ // If the existing remote doc has a different contentHash, another
296
+ // writer landed an edit in the meantime; we refuse to overwrite and
297
+ // return action='conflict' so the caller can write a conflict copy
298
+ // and pull the new remote state. Undefined disables the check (used
299
+ // for create-from-nothing where no baseline exists).
300
+ expectedRemoteHash = undefined,
294
301
  }) {
295
302
  if (scope === 'shared-with-me' || scope === 'shared-by-me') {
296
303
  return { action: 'skipped', reason: 'shared-scope' };
@@ -372,6 +379,23 @@ export async function pushFileToCloud({
372
379
  && (existing.parentId == null ? null : existing.parentId) === parentId) {
373
380
  return { action: 'noop', fileId: docSnap.id };
374
381
  }
382
+ // Compare-and-swap (DOU-167): if the caller passed a baseline of
383
+ // what they last saw as current, refuse the push when the remote
384
+ // has moved on. The daemon will write the proposed local content
385
+ // to a sibling conflict file and pull the new remote state into
386
+ // the live path. Without this guard, a stale push silently
387
+ // overwrites another user's freshly-saved edits (data loss).
388
+ if (
389
+ expectedRemoteHash !== undefined &&
390
+ existing.contentHash !== expectedRemoteHash
391
+ ) {
392
+ return {
393
+ action: 'conflict',
394
+ fileId: docSnap.id,
395
+ remoteHash: existing.contentHash,
396
+ remoteData: { id: docSnap.id, ...existing },
397
+ };
398
+ }
375
399
  // Last-write-wins: never let an older local file overwrite a newer
376
400
  // server edit. The caller (sync daemon) passes the local file's mtime;
377
401
  // if the server doc was updated more recently, leave it for the
package/org-sync.js CHANGED
@@ -40,6 +40,36 @@ import { loadSyncState, saveSyncState } from './sync-state.js';
40
40
 
41
41
  const DEFAULT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
42
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
+
43
73
  // Start the per-org sync loop. Returns { stop } to unsubscribe all
44
74
  // listeners and close the watcher (used by tests and by graceful
45
75
  // shutdown). `localPath` must already be absolute/expanded.
@@ -243,21 +273,29 @@ export async function startOrgSync({
243
273
  suppressLocal.add(localFilePath);
244
274
 
245
275
  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
- });
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);
253
296
  fs.writeFileSync(localFilePath, content || '', 'utf-8');
254
297
  } else {
255
- const bytes = await readBlob({
256
- storage,
257
- orgId,
258
- fileId: change.doc.id,
259
- ext,
260
- });
298
+ const bytes = await readBlobWithRetry(() => readBlob(blobReadArgs), retryOpts);
261
299
  fs.writeFileSync(localFilePath, bytes);
262
300
  }
263
301
  lastKnownUpdate.set(trackingKey, remoteUpdated);
@@ -387,6 +425,12 @@ export async function startOrgSync({
387
425
  // duplicate doc). Pre-setting it suppresses the echo.
388
426
  lastKnownUpdate.set(trackingKey, now);
389
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
+
390
434
  const result = await pushFileToCloud({
391
435
  filesRef,
392
436
  storage,
@@ -399,6 +443,7 @@ export async function startOrgSync({
399
443
  mimeType,
400
444
  now,
401
445
  localModifiedAt,
446
+ expectedRemoteHash: baseline,
402
447
  });
403
448
 
404
449
  // Record the synced content baseline whenever local matches the
@@ -411,6 +456,21 @@ export async function startOrgSync({
411
456
  // its firestore -> local snapshot is allowed through to update the
412
457
  // local file.
413
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;
414
474
  }
415
475
 
416
476
  if (result.action === 'created') {
@@ -425,6 +485,38 @@ export async function startOrgSync({
425
485
  }
426
486
  }
427
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
+
428
520
  async function deleteFromFirestore(filePath) {
429
521
  const { scope, teamId, docPath } = scopeFromLocalPath(filePath, LOCAL_PATH, teamIdsByName);
430
522
  const trackingKey = `${scope}:${docPath}`;
@@ -584,11 +676,22 @@ export async function startOrgSync({
584
676
  watcher.on('addDir', dirPath => handleLocalDirAdded(dirPath));
585
677
  watcher.on('unlinkDir', dirPath => handleLocalDirRemoved(dirPath));
586
678
 
587
- watcher.on('ready', () => console.log(` [${orgId}] Local watcher ready.`));
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
+ });
588
691
  }
589
692
 
590
693
  startFirestoreListeners();
591
- startLocalWatcher();
694
+ await startLocalWatcher();
592
695
 
593
696
  return {
594
697
  // Tear down all listeners and the watcher. Returns the watcher's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doubling/compound-sync",
3
- "version": "1.9.5",
3
+ "version": "1.10.2",
4
4
  "description": "Bidirectional sync between Compound and local markdown files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "scripts": {
25
25
  "sync": "node sync.js",
26
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'",
27
+ "test:integration": "firebase emulators:exec --only firestore,storage 'node --test --test-concurrency=1 files.test.js org-sync.test.js two-user-sync.test.js'",
28
28
  "test": "npm run test:unit && npm run test:integration"
29
29
  },
30
30
  "dependencies": {
@@ -32,7 +32,7 @@
32
32
  "chokidar": "^5.0.0"
33
33
  },
34
34
  "devDependencies": {
35
- "@firebase/rules-unit-testing": "^4.0.0",
35
+ "@firebase/rules-unit-testing": "^5.0.1",
36
36
  "firebase-tools": "^15.0.0"
37
37
  },
38
38
  "publishConfig": {
@@ -65,6 +65,13 @@ const EXT_TO_MIME = {
65
65
  htm: 'text/html',
66
66
  css: 'text/css',
67
67
  js: 'text/javascript',
68
+ // Obsidian Bases (.base) files are YAML. Mapping to text/yaml here
69
+ // (in addition to the web copy) ensures the daemon uploads .base as
70
+ // a UTF-8 text blob with the right Content-Type so the web client's
71
+ // eager-load path picks up entity.content for the Bases renderer.
72
+ base: 'text/yaml',
73
+ yaml: 'text/yaml',
74
+ yml: 'text/yaml',
68
75
  json: 'application/json',
69
76
  pdf: 'application/pdf',
70
77
  png: 'image/png',