Skip to content

Commit 2a383f4

Browse files
dbdeveloperclaude
andcommitted
sync-bak/sync-tmp: each suffix gets one consistent meaning
Conceptual fix: before this change, `.sync-bak` was ambiguous — it meant "old bytes backed up" in atomicWriteFile's 5-step pull-replace protocol AND "new bytes staged" in ConflictStore.create's 3-step sibling registration. The same suffix encoding opposite semantics made the recovery sweep harder to reason about and trapped readers who assumed "bak" implied a backup of something pre-existing. After: - `.sync-tmp` = NEW bytes staged for a target. Ambiguous between two callsites; the recovery sweep dispatches by ownership via ConflictStore.getBySibling(finalPath): - record exists → forward-finalize (Path B sibling registration) - no record → drop as transient (Path A pull-replace artefact) - `.sync-bak` = OLD bytes backed up before an overwrite. Produced only by atomicWriteFile; ConflictStore never writes `.sync-bak` after this change. Recovery is snapshot-based with no ownership dispatch needed — simpler code path. Changes: - ConflictStore.create now stages at `<sibling>.sync-tmp.<ext>` (was `.sync-bak.<ext>`). 3-step protocol unchanged otherwise. - AtomicWriteRecovery.sweep moves the ownership-dispatch block from the `.sync-bak` loop into the `.sync-tmp` loop. `.sync-bak` handling simplifies to snapshot-based recovery only. - File-level comments in atomic-write.ts + conflict-store.ts updated to describe the new contract. - atomic-write.test.ts: N9/N9b renamed (.sync-bak → .sync-tmp in titles + stagingPathFor() arg). Existing orphan-tmp test retitled for clarity. New N9c pins the dispatch: a Path A transient .sync-tmp must drop even when ConflictStore is in the recovery constructor, as long as no record names its finalPath. - conflict-store.test.ts: 2 comments + 1 test title updated. Migration: at rest there are no `.sync-bak` staging files for ConflictStore-owned siblings (Step 3 promotes them immediately on success), so existing installations migrate transparently. The only edge case — an upgrade landing exactly during a mid-Step-3 crash window — is benign because the conservative-restore branch in the `.sync-bak` handler would still rename the leftover into place, which is the same outcome the new code would produce via the .sync-tmp branch. Tests: 526/526 unit pass (was 525, +1 for N9c). Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f9641ac commit 2a383f4

4 files changed

Lines changed: 128 additions & 77 deletions

File tree

src/sync2/atomic-write.ts

Lines changed: 51 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -205,36 +205,41 @@ interface ConflictStoreLike {
205205
}
206206

207207
// Crash-recovery sweep for `atomicWriteFile` AND for ConflictStore's
208-
// vault-level `.sync-bak` sibling staging. Runs on plugin onload
208+
// vault-level `.sync-tmp` sibling staging. Runs on plugin onload
209209
// BEFORE the engine starts touching the vault — walks the tree for
210210
// any `.sync-tmp` / `.sync-bak` leftovers and reconciles them
211211
// against the snapshot + conflict stores.
212212
//
213-
// `.sync-tmp` files: always safe to drop (transient write artifact).
213+
// Each suffix now has ONE consistent meaning (see PSEUDO-MERGE-MODE.md
214+
// §9 for the rationale):
214215
//
215-
// `.sync-bak` files: dispatch by ownership. If `conflictStore.
216-
// getBySibling(finalPath)` returns a record, we treat the staging
217-
// as a Stage 13 conflict-sibling stage (PSEUDO-MERGE-MODE.md §"Recovery
218-
// sweep на onload — vault-level `.sync-bak` sweep"):
216+
// `.sync-tmp` = NEW bytes staged for a target (existing or new).
217+
// Ambiguous between two callsites; dispatch by ownership via
218+
// conflictStore.getBySibling(finalPath):
219+
// record exists, finalPath exists → drop tmp
220+
// [Step 3 done,
221+
// orphan cleanup]
222+
// record exists, finalPath missing, SHA matches → rename(tmp →
223+
// finalPath)
224+
// [resume Step 3]
225+
// record exists, finalPath missing, SHA mismatches → drop tmp
226+
// [data integrity
227+
// > resolution;
228+
// record dropped
229+
// on next drain
230+
// Phase B]
231+
// no record (Path A transient new bytes) → drop tmp
219232
//
220-
// finalPath exists → delete bak [Step 3 done,
221-
// orphan cleanup]
222-
// finalPath missing, SHA(bak) === theirsBlobSha → rename(bak → finalPath)
223-
// [resume Step 3]
224-
// finalPath missing, SHA(bak) ≠ theirsBlobSha → delete bak (data
225-
// integrity > resolution
226-
// completeness; record
227-
// gets dropped on next
228-
// drain Phase B)
229-
//
230-
// Otherwise (no record owns finalPath) the sweep falls through to the
231-
// existing snapshot-based atomicWriteFile recovery semantics:
232-
//
233-
// finalPath missing → rename(bak → finalPath)
234-
// [restore]
235-
// finalPath exists, snapshot.remoteSha === SHA(file) → delete bak
236-
// [cleanup race]
237-
// mismatch / no snapshot → restore bak
233+
// `.sync-bak` = OLD bytes backed up before an overwrite.
234+
// Only produced by atomicWriteFile (Path A); ConflictStore never
235+
// writes `.sync-bak`. Recovery is snapshot-based, no ownership
236+
// dispatch needed:
237+
// finalPath missing → rename(bak
238+
// → finalPath)
239+
// [restore]
240+
// finalPath exists, snapshot.remoteSha === SHA(file) → delete bak
241+
// [cleanup race]
242+
// mismatch / no snapshot → restore bak
238243
//
239244
// Returns counts so main.ts can log / surface what was recovered.
240245
export class AtomicWriteRecovery {
@@ -250,49 +255,51 @@ export class AtomicWriteRecovery {
250255

251256
const { syncTmps, syncBaks } = await this.findCandidates();
252257

253-
// 1. .sync-tmp: always safe to drop. Either the write was
254-
// interrupted before promotion, or someone left stale staging.
255-
for (const { stagingPath: tmpPath } of syncTmps) {
258+
// 1. .sync-tmp: forward-direction staging. Dispatch by ownership.
259+
// Path B (ConflictStore.create) → resume Step 3 by renaming to the
260+
// final sibling path if SHA matches the record's theirsBlobSha.
261+
// Path A (atomicWriteFile transient) → drop (next sync repeats).
262+
for (const { stagingPath: tmpPath, finalPath: originalPath } of syncTmps) {
256263
try {
257-
await this.vault.adapter.remove(tmpPath);
258-
cleaned++;
259-
} catch {
260-
// Ignore individual failures; sweep is best-effort.
261-
}
262-
}
263-
264-
// 2. .sync-bak: state-driven recovery. Dispatch by ownership.
265-
for (const { stagingPath: bakPath, finalPath: originalPath } of syncBaks) {
266-
try {
267-
// Stage 13: ConflictStore owns siblings — record-bound
268-
// recovery uses theirsBlobSha as the integrity witness.
269264
const conflictRecord = this.conflictStore?.getBySibling(originalPath);
270265
if (conflictRecord !== undefined) {
271266
const fileExists = await this.vault.adapter.exists(originalPath);
272267
if (fileExists) {
273268
// Step 3 completed at some point; the staging is stale.
274-
await this.vault.adapter.remove(bakPath);
269+
await this.vault.adapter.remove(tmpPath);
275270
cleaned++;
276271
continue;
277272
}
278-
const bytes = await this.vault.adapter.readBinary(bakPath);
273+
const bytes = await this.vault.adapter.readBinary(tmpPath);
279274
const sha = await calculateGitBlobSHA(bytes);
280275
if (sha === conflictRecord.theirsBlobSha) {
281276
// Resume the interrupted Step 3.
282-
await this.vault.adapter.rename(bakPath, originalPath);
277+
await this.vault.adapter.rename(tmpPath, originalPath);
283278
restored++;
284279
} else {
285280
// SHA mismatch — disk corruption or a stale staging from
286281
// some unrelated path that happens to collide. Drop it;
287282
// the next drain Phase B drops the record on the missing
288283
// sibling.
289-
await this.vault.adapter.remove(bakPath);
284+
await this.vault.adapter.remove(tmpPath);
290285
cleaned++;
291286
}
292287
continue;
293288
}
289+
// No ConflictStore record → Path A transient. Always safe to
290+
// drop; next sync repeats the operation if still needed.
291+
await this.vault.adapter.remove(tmpPath);
292+
cleaned++;
293+
} catch {
294+
// Ignore individual failures; sweep is best-effort.
295+
}
296+
}
294297

295-
// Fall-through: atomicWriteFile-style snapshot-based recovery.
298+
// 2. .sync-bak: rollback backups, snapshot-based recovery.
299+
// Produced only by atomicWriteFile (Path A); ConflictStore never
300+
// writes .sync-bak. No ownership dispatch needed.
301+
for (const { stagingPath: bakPath, finalPath: originalPath } of syncBaks) {
302+
try {
296303
const fileExists = await this.vault.adapter.exists(originalPath);
297304
if (!fileExists) {
298305
// Crash between step 2 and step 3: only backup survived.

src/sync2/conflict-store.ts

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,26 @@ import { stagingPathFor } from "./atomic-write";
1717
// <configDir>/plugins/<self>/.conflicts/
1818
// <recordId>/
1919
// meta.json ← ConflictRecord JSON (atomically written)
20-
// sibling-content.bin ← raw bytes of the theirs side; permanent
21-
// backup (re-used by per-crash-window
22-
// recovery sweep + as authoritative
23-
// content if the user externally deleted
24-
// the vault sibling)
2520
//
2621
// The sibling file itself lives in the vault next to the original:
2722
//
2823
// <basename>.conflict-from-<deviceLabel>-<isoTs>.<ext>
24+
//
25+
// During create(), the sibling is staged at vault level as a
26+
// `.sync-tmp` pre-suffix file (e.g., `note.conflict-from-Phone-...sync-tmp.md`)
27+
// and atomically renamed to its final name. Crash-recovery is handled
28+
// by AtomicWriteRecovery.sweep() via ownership dispatch on .sync-tmp.
29+
// See PSEUDO-MERGE-MODE.md §9 for the full protocol.
2930

3031
const CONFLICTS_DIRNAME = ".conflicts";
3132
const META_FILE = "meta.json";
3233
const META_TMP_FILE = "meta.json.tmp";
3334
// Legacy: pre-Stage-13 ConflictStore wrote a `sibling-content.bin`
34-
// backup file inside each `<recordDir>` for crash recovery. Stage 13
35-
// uses vault-level `.sync-bak` staging instead (see create() below)
36-
// and load() no longer consults the backup at all. Old install dirs
37-
// may still carry a `sibling-content.bin` artifact; it's harmless
38-
// dead weight and gets removed by `delete()` along with the rest of
39-
// the recordDir.
35+
// backup file inside each `<recordDir>` for crash recovery. Removed
36+
// when staging moved to vault-level `.sync-tmp` (see create() below).
37+
// Old install dirs may still carry a `sibling-content.bin` artifact;
38+
// it's harmless dead weight and gets removed by `delete()` along with
39+
// the rest of the recordDir.
4040

4141
// Two kinds: modify-vs-modify (both sides edited) and delete-vs-modify
4242
// (local deleted, remote modified). The third theoretical kind —
@@ -258,14 +258,17 @@ export default class ConflictStore {
258258
const siblingSha = orphan?.sha ?? (await calculateGitBlobSHA(args.theirsContent));
259259

260260
const recordDir = `${this.conflictsRoot}/${id}`;
261-
const stagingPath = stagingPathFor(siblingPath, "bak");
262-
263-
// Stage 13 (Decision #29): vault-level `.sync-bak` staging
264-
// replaces the legacy `<recordDir>/sibling-content.bin` backup.
265-
// 3-step protocol per PSEUDO-MERGE-MODE.md §"3-step atomic
266-
// create protocol":
261+
const stagingPath = stagingPathFor(siblingPath, "tmp");
262+
263+
// Vault-level `.sync-tmp` staging (Decision #29; suffix corrected
264+
// post-Stage-13 to match semantics — see PSEUDO-MERGE-MODE.md §9).
265+
// `.sync-tmp` carries NEW bytes destined for a not-yet-existing
266+
// target; `.sync-bak` is reserved for backups of files that
267+
// already existed (atomicWriteFile's rollback target). Sibling
268+
// registration writes a brand-new file → tmp, not bak.
267269
//
268-
// 1. writeBinary(<sibling>.sync-bak.<ext>, theirsContent)
270+
// 3-step protocol:
271+
// 1. writeBinary(<sibling>.sync-tmp.<ext>, theirsContent)
269272
// 2. atomic write meta.json (via persistRecord — tmp + rename)
270273
// 3. atomic rename staging → siblingPath
271274
//

tests/sync2/atomic-write.test.ts

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,18 @@ import { calculateGitBlobSHA } from "../../src/utils";
3131
// 4. afterCommit() ← snapshot.recordSync, typically
3232
// 5. remove(<path>.sync-bak)
3333
//
34-
// Crash-recovery sweep:
35-
// *.sync-tmp → delete (junk)
36-
// *.sync-bak (no <file>) → restore from .sync-bak
37-
// *.sync-bak (with <file>):
38-
// file SHA matches snapshot.remoteSha → delete .sync-bak [cleanup race]
39-
// mismatch → restore from .sync-bak
40-
// no snapshot entry → restore from .sync-bak (conservative)
34+
// Crash-recovery sweep (post-Stage-13, suffix semantics corrected):
35+
// *.sync-tmp: dispatch by ownership via
36+
// ConflictStore.getBySibling
37+
// no record (Path A transient) → delete (junk)
38+
// record + finalPath exists → delete (Step 3 done, stale)
39+
// record + finalPath missing, SHA ok → rename .sync-tmp → finalPath
40+
// record + finalPath missing, SHA bad → delete (record drops later)
41+
// *.sync-bak (Path A only, no dispatch):
42+
// no <file> → restore from .sync-bak
43+
// <file> + SHA == snapshot.remoteSha → delete .sync-bak [cleanup race]
44+
// <file> + SHA mismatch → restore from .sync-bak
45+
// <file>, no snapshot entry → restore from .sync-bak (conservative)
4146

4247
function fixture(): {
4348
root: string;
@@ -176,7 +181,7 @@ describe("AtomicWriteRecovery.sweep", () => {
176181
f.cleanup();
177182
});
178183

179-
it("orphan .sync-tmp: deleted on sweep (transient write artifact)", async () => {
184+
it("orphan .sync-tmp without ConflictStore in scope: dropped (Path A transient)", async () => {
180185
fs.writeFileSync(path.join(f.root, "x.sync-tmp.md"), "partial");
181186
const recovery = new AtomicWriteRecovery(
182187
f.vault as unknown as import("obsidian").Vault,
@@ -385,7 +390,7 @@ describe("AtomicWriteRecovery SHA-verify (Stage 13) — Phase 4 wires sweep agai
385390
f.cleanup();
386391
});
387392

388-
it("N9: sweep finds .sync-bak with SHA matching record.theirsBlobSha → rename to finalPath", async () => {
393+
it("N9: sweep finds .sync-tmp with SHA matching record.theirsBlobSha → rename to finalPath", async () => {
389394
const { default: ConflictStore } = await import(
390395
"../../src/sync2/conflict-store"
391396
);
@@ -414,7 +419,7 @@ describe("AtomicWriteRecovery SHA-verify (Stage 13) — Phase 4 wires sweep agai
414419
});
415420
// Synthesize the mid-Step-3 crash state.
416421
const siblingAbs = path.join(f.root, rec.siblingPath);
417-
const stagingAbs = path.join(f.root, stagingPathFor(rec.siblingPath, "bak"));
422+
const stagingAbs = path.join(f.root, stagingPathFor(rec.siblingPath, "tmp"));
418423
fs.renameSync(siblingAbs, stagingAbs);
419424
expect(fs.existsSync(siblingAbs)).toBe(false);
420425
expect(fs.existsSync(stagingAbs)).toBe(true);
@@ -431,7 +436,7 @@ describe("AtomicWriteRecovery SHA-verify (Stage 13) — Phase 4 wires sweep agai
431436
expect(fs.readFileSync(siblingAbs, "utf8")).toBe("theirs content\n");
432437
});
433438

434-
it("N9b: sweep finds .sync-bak with SHA NOT matching record.theirsBlobSha → drop, leave record for drain Phase B", async () => {
439+
it("N9b: sweep finds .sync-tmp with SHA NOT matching record.theirsBlobSha → drop, leave record for drain Phase B", async () => {
435440
const { default: ConflictStore } = await import(
436441
"../../src/sync2/conflict-store"
437442
);
@@ -458,7 +463,7 @@ describe("AtomicWriteRecovery SHA-verify (Stage 13) — Phase 4 wires sweep agai
458463
// content (SHA differs from record.theirsBlobSha — disk
459464
// corruption / race / unrelated staging collision).
460465
const siblingAbs = path.join(f.root, rec.siblingPath);
461-
const stagingAbs = path.join(f.root, stagingPathFor(rec.siblingPath, "bak"));
466+
const stagingAbs = path.join(f.root, stagingPathFor(rec.siblingPath, "tmp"));
462467
fs.unlinkSync(siblingAbs);
463468
fs.writeFileSync(stagingAbs, "corrupted bytes");
464469

@@ -478,4 +483,39 @@ describe("AtomicWriteRecovery SHA-verify (Stage 13) — Phase 4 wires sweep agai
478483
expect(fs.existsSync(siblingAbs)).toBe(false);
479484
expect(conflictStore.get(rec.id)).toBeDefined();
480485
});
486+
487+
it("N9c: .sync-tmp at a path with no ConflictStore record → dropped as Path A transient (even when conflictStore is in scope)", async () => {
488+
// Pin the dispatch: presence of conflictStore in the recovery
489+
// constructor must NOT cause a Path A transient .sync-tmp (one
490+
// whose finalPath is just an ordinary user file, not a sibling)
491+
// to be treated as a forward-finalize candidate.
492+
const { default: ConflictStore } = await import(
493+
"../../src/sync2/conflict-store"
494+
);
495+
const conflictStore = new ConflictStore({
496+
vault: f.vault as unknown as import("obsidian").Vault,
497+
configDir: ".obsidian",
498+
selfPluginId: "github-easy-sync",
499+
});
500+
await conflictStore.load();
501+
// Place a .sync-tmp at a path that ConflictStore knows nothing
502+
// about — e.g., from a crashed atomicWriteFile pull-replace.
503+
fs.mkdirSync(path.join(f.root, "Notes"), { recursive: true });
504+
fs.writeFileSync(path.join(f.root, "Notes/regular.sync-tmp.md"), "partial");
505+
506+
const recovery = new AtomicWriteRecovery(
507+
f.vault as unknown as import("obsidian").Vault,
508+
f.store,
509+
conflictStore,
510+
);
511+
const result = await recovery.sweep();
512+
expect(result.cleaned).toBe(1);
513+
expect(result.restored).toBe(0);
514+
expect(
515+
fs.existsSync(path.join(f.root, "Notes/regular.sync-tmp.md")),
516+
).toBe(false);
517+
// The "final" path (Notes/regular.md) was never created — drop is
518+
// correct because the bytes are transient, not destined.
519+
expect(fs.existsSync(path.join(f.root, "Notes/regular.md"))).toBe(false);
520+
});
481521
});

tests/sync2/conflict-store.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,9 @@ describe("ConflictStore", () => {
181181
// meta.json persisted; tmp atomic-write artifact cleaned.
182182
expect(fs.existsSync(path.join(dir, "meta.json"))).toBe(true);
183183
expect(fs.existsSync(path.join(dir, "meta.json.tmp"))).toBe(false);
184-
// Stage 13: sibling staging lives in the vault as a `.sync-bak`
185-
// pre-suffix file, then atomically renamed to the final
184+
// Sibling staging lives in the vault as a `.sync-tmp` pre-suffix
185+
// file (NEW bytes destined for a brand-new sibling — semantically
186+
// a "tmp", not a "bak"), then atomically renamed to the final
186187
// siblingPath. No legacy `sibling-content.bin` backup inside
187188
// the recordDir.
188189
expect(fs.existsSync(path.join(dir, "sibling-content.bin"))).toBe(false);
@@ -360,7 +361,7 @@ describe("ConflictStore", () => {
360361
// from meta.json; the missing sibling becomes a drain Phase B
361362
// drop signal. (Pre-Stage-13 also had a `sibling-content.bin`
362363
// backup file inside recordDir; that artifact is gone now —
363-
// vault-level `.sync-bak` staging is the new mechanism and
364+
// vault-level `.sync-tmp` staging is the new mechanism and
364365
// it's already been renamed to the final siblingPath by the
365366
// time create() returns.)
366367
await f.store.load();

0 commit comments

Comments
 (0)