@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 +24 -0
- package/org-sync.js +118 -15
- package/package.json +3 -3
- package/storage-helpers.js +7 -0
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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.
|
|
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": "^
|
|
35
|
+
"@firebase/rules-unit-testing": "^5.0.1",
|
|
36
36
|
"firebase-tools": "^15.0.0"
|
|
37
37
|
},
|
|
38
38
|
"publishConfig": {
|
package/storage-helpers.js
CHANGED
|
@@ -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',
|