Skip to content
Merged
7 changes: 7 additions & 0 deletions changelogs/v2.6.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
## What's new

### Fixes
- **Cairn now warns when another device is too old to sync deletions correctly.** Device Sync stamps a protocol version on the changes it writes, so a device running a newer version can tell when one of your other devices hasn't been updated. When that happens, Settings → Sync (and the mobile Sync screen) show a "device needs updating" hint — because until the older device is updated, a note you delete can reappear from it. Existing setups are unaffected: devices on the same version see nothing new.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
- **Callouts now render inline in the editor.** In the note editor's live preview, `> [!note]`-style callouts show as the same styled callout you see in reading mode, and click into raw Markdown to edit. This was implemented earlier but switched off because the cursor landed in the wrong place when you clicked near or below a callout — text after one was effectively unselectable. The callout now mounts with its real height before the editor measures the surrounding lines, so positions below it stay accurate and you can edit around a callout normally.
- **A note deleted on another device is no longer offered for restore twice.** When you delete a note on one device and edit that same note on another before it has seen the deletion, Device Sync keeps your edit as a conflict copy — and previously it *also* listed the note as "deleted on another device" with a Restore button. Restoring then left you with two identical notes to clean up. Cairn now recognises that your edit is already safe in the conflict copy and points you to the conflicts view to resolve it, instead of offering a redundant restore. Notes deleted with no competing edit still show Restore as before.
- **Agent tool approvals read correctly on Windows and Linux.** The confirmation shown before the agent runs a tool described where the action reaches as "this Mac" regardless of platform; it now says "this device."
24 changes: 24 additions & 0 deletions docs/plans/sync-lifecycle-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,30 @@ log stays a desktop diagnostic — on mobile the recovery path is what carries t

## 6. Risks & notes

### Wire-protocol versioning (✅ shipped v2.6.2)

The oplog now carries a `v` field (`SYNC_PROTOCOL_VERSION`, currently **1**), stamped at the
export boundary (`exportOplog`), validated by the shared guard, and read by `applyRemote` into a
per-peer record in `sync_state` (`peer_protocol:<deviceId>`, monotonic). `listPeerProtocols()`
reports any peer whose version differs; a peer that is **behind** may not honour deletes, and both
desktop (Settings → Sync) and mobile (Sync screen) surface a "device needs updating" hint.

**Skew contract:**
- A missing `v` means a pre-2.6.2 build → treated as protocol **1**. All 2.6.1 and earlier entries
read cleanly; no rewrite.
- **Bump `SYNC_PROTOCOL_VERSION` only** for a wire change a peer must understand to stay convergent
(a new causal field, a changed reconciliation rule). Additive optional fields an older peer can
safely ignore — as `observed`/`tombstone` were — do **not** need a bump.
- **Degradation when a peer predates a version:** delete-wins is only enforced on devices at that
version, so a mixed fleet is as weak as its oldest member — a behind peer can re-broadcast a note
deleted elsewhere. The new hint makes this *visible and actionable* (update the old device) rather
than silent, which was the whole point of doing this while only two shapes existed in the wild.
- **Downgrade:** an older build ignores `v` (and `sync_row_base` / `sync_oplog.observed`), reverting
to wall-clock reconciliation. Structurally safe (mobile's migration loop no-ops at a higher
`user_version`; extra columns ignored) but delete-wins is not enforced on that device.

---

- ~~Changing desktop to soft-delete means **every desktop live query must exclude
tombstones**~~ — **resolved in Phase 2**: the list/search reads already filtered
`deleted_at IS NULL`; only the by-id reads needed the guard. No ghost rows.
Expand Down
3 changes: 3 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,9 @@ const api = {
// Retry the .md write for a restore whose DB half already succeeded.
repairNoteFile: (id: string) =>
invoke<{ repaired: boolean; reason?: string; fileError?: string }>("sync:repairNoteFile", { id }),
// Peer devices on a different sync protocol version (behind = too old to honour deletes).
peerProtocols: () =>
invoke<Array<{ deviceId: string; version: number; behind: boolean }>>("sync:peerProtocols"),
},

// ── AI write lock events ──────────────────────
Expand Down
8 changes: 8 additions & 0 deletions electron/sync/desktop-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,14 @@ export function listSyncActivity(db: Database.Database, limit = 100) {
return getDesktopEngine(db).listSyncActivity(limit);
}

/**
* Peer devices whose sync protocol version differs from this build's — used to
* prompt the user to update a device that is too old to honour deletes.
*/
export function listPeerProtocols(db: Database.Database) {
return getDesktopEngine(db).listPeerProtocols();
}

/**
* Notes tombstoned by another device that can genuinely be brought back, newest
* first, plus the true total (which may exceed the returned page).
Expand Down
4 changes: 4 additions & 0 deletions electron/sync/sync-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
listRestorableNotes,
restoreDeletedNote,
repairNoteFile,
listPeerProtocols,
type ConflictResolveDeps,
} from "./desktop-sync";

Expand Down Expand Up @@ -130,6 +131,9 @@ export function registerSyncHandlers(
(((_e: never, args?: { limit?: number }) => wrap(() => listSyncActivity(ctx.db, args?.limit ?? 100))) as never),
);

// Peers on an older (or newer) sync protocol than this build.
register("sync:peerProtocols", (() => wrap(() => listPeerProtocols(ctx.db))) as never);

// Notes a peer deleted that can still be brought back.
register(
"sync:listRestorable",
Expand Down
1 change: 1 addition & 0 deletions mobile/app/restore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const REFUSAL_TEXT: Record<string, string> = {
orphaned: "Its project was deleted too. Restore the project first.",
"self-deleted": "This device deleted that note, so it isn't offered for recovery.",
"no-delete-record": "Sync has no record of that deletion, so it can't be undone from here.",
"preserved-as-copy": "Your edit is kept as a conflict copy — resolve it from the Conflicts screen instead.",
};

function relativeTime(iso: string | null): string {
Expand Down
25 changes: 23 additions & 2 deletions mobile/app/sync.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useCallback, useMemo, useState } from "react";
import { View, Text, Pressable, StyleSheet, ActivityIndicator, ScrollView } from "react-native";
import { Stack, useFocusEffect, useRouter } from "expo-router";
import { GitMerge, ChevronRight, Trash2 } from "lucide-react-native";
import { GitMerge, ChevronRight, Trash2, ArrowUpCircle } from "lucide-react-native";
import { iCloudAvailable, syncFolderLabel } from "@/sync/folder";
import { requestSync } from "@/sync/controller";
import { useSyncStatus } from "@/sync/useSyncStatus";
import { restorableCount } from "@/db/queries";
import { restorableCount, stalePeerCount } from "@/db/queries";
import { EmbeddingsCard } from "@/components/EmbeddingsCard";
import { SectionLabel } from "@/components/SectionLabel";
import { ICON_CHECK } from "@/components/toolbar-icons";
Expand All @@ -30,6 +30,7 @@ export default function SyncScreen() {
const { state, pending, conflicts, lastResult: last } = useSyncStatus();
const [available, setAvailable] = useState<boolean | null>(null);
const [restorable, setRestorable] = useState(0);
const [stalePeers, setStalePeers] = useState(0);
const styles = useMemo(() => makeStyles(t), [t]);
const busy = state === "syncing";

Expand All @@ -43,6 +44,12 @@ export default function SyncScreen() {
console.warn("[cairn] restorableCount failed", err);
setRestorable(0);
}
try {
setStalePeers(stalePeerCount());
} catch (err) {
console.warn("[cairn] stalePeerCount failed", err);
setStalePeers(0);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}, []);

useFocusEffect(useCallback(() => refresh(), [refresh]));
Expand Down Expand Up @@ -134,6 +141,20 @@ export default function SyncScreen() {
</Pressable>
)}

{stalePeers > 0 && (
<View style={[styles.conflictRow, styles.restoreRowActive]}>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
<ArrowUpCircle size={18} color={t.warning} />
<View style={{ flex: 1 }}>
<Text style={[styles.conflictTitle, { color: t.textPrimary }]}>
{stalePeers === 1 ? "Another device needs updating" : `${stalePeers} devices need updating`}
</Text>
<Text style={styles.conflictHelp}>
Update Cairn there so deletions sync correctly — until then a deleted note can reappear.
</Text>
</View>
</View>
)}

<Text style={styles.note}>
Bidirectional, offline-first. Edits made on this phone and the desktop reconcile via the
shared sync engine. Body conflicts are kept as a &quot;conflicted copy&quot; note, never lost.
Expand Down
4 changes: 4 additions & 0 deletions mobile/changelogs/v0.1.6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## Fixes

- **A "device needs updating" hint when a paired device is too old.** Device Sync now tracks each device's sync protocol version, so if one of your devices is on an older version that can't honour deletions correctly, the Sync screen tells you — until it's updated, a note you delete can reappear from it. Devices on the same version see nothing new.
- **A note deleted on another device is no longer offered for restore twice.** When another device deleted a note you had edited offline, the Sync screen used to show both a "deleted on another device" restore prompt and a sync conflict for the same note — and restoring left two identical copies. Your edit is already kept as a conflict copy, so Cairn now sends you to the Conflicts screen to resolve it instead of offering a redundant restore. Notes deleted with no competing edit still offer Restore as before.
5 changes: 5 additions & 0 deletions mobile/src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,11 @@ export function listSyncActivity(limit = 50): SyncActivityRow[] {
return getEngine().listSyncActivity(limit);
}

/** Peers on an older sync protocol than this build — too old to honour deletes. */
export function stalePeerCount(): number {
return getEngine().listPeerProtocols().filter((p) => p.behind).length;
}

/**
* Resolve a conflict by keeping the CONFLICT-COPY's body: overwrite the
* original note with the copy's content, then delete the copy. If the original
Expand Down
152 changes: 151 additions & 1 deletion shared/sync/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import path from "path";
import { applySchema } from "../../electron/db/schema";
import { SYNCABLE_TABLES } from "./schema";
import * as q from "../../electron/db/queries";
import { SyncEngine, SYNC_ACTIVITY_LIMIT } from "./engine";
import { SyncEngine, SYNC_ACTIVITY_LIMIT, SYNC_PROTOCOL_VERSION } from "./engine";
import { writeOplogFile, readPeerOplogs } from "./transport";
import { compareHlc } from "./hlc";

Expand Down Expand Up @@ -1210,6 +1210,62 @@ describe("sync engine — trigger→drain capture of real queries.ts writes", ()
syncFolder(dir, A.engine, B.engine);
}

it("does not offer restore for a row already preserved by a live conflict copy", () => {
// The on-device repro (v2.6.1 hardware test): delete on A, edit the same
// note on B while B hasn't observed the delete. B's delete-won path
// tombstones the row (keeping its body) AND clones it into a conflict
// copy — so the tombstone and the copy hold the SAME content. Offering
// restore then duplicates the note. Suppress it; the edit is safe in the
// copy, so point the user at conflict resolution instead.
const clkA = clockFrom(14_760_000);
const clkB = clockFrom(14_760_000);
const A = makeDevice("A", clkA.now);
const B = makeDevice("B", clkB.now);
seedNoteOnBoth(A, B);

clkA.advance(1);
q.deleteNote(A.db, "n1");
A.engine.drainPending();
clkB.advance(50);
q.updateNote(B.db, "n1", { content: "phone kept editing" });
B.engine.drainPending();
syncFolder(dir, A.engine, B.engine);

// B has both a tombstoned n1 and a live conflict copy of it.
const copy = B.db
.prepare("SELECT id, content FROM notes WHERE id LIKE 'n1_conflict_%' AND deleted_at IS NULL")
.get() as { id: string; content: string } | undefined;
expect(copy?.content).toBe("phone kept editing");
const tombstoned = B.db.prepare("SELECT content FROM notes WHERE id = 'n1'").get() as { content: string };
expect(tombstoned.content).toBe("phone kept editing"); // same body → redundant restore

// So restore is refused on B (the losing device) and n1 is not listed.
expect(B.engine.listRestorable("notes").rows.map((r) => r.entity_id)).not.toContain("n1");
expect(B.engine.restoreDeleted("notes", "n1")).toEqual({
restored: false,
reason: "preserved-as-copy",
});
});

it("still offers restore when the delete won cleanly with no divergent copy", () => {
// Plain peer delete, no competing local edit → no conflict copy exists, so
// restore is the only way back and must still be offered.
const clkA = clockFrom(14_780_000);
const clkB = clockFrom(14_780_000);
const A = makeDevice("A", clkA.now);
const B = makeDevice("B", clkB.now);
seedNoteOnBoth(A, B);

clkA.advance(5);
q.deleteNote(A.db, "n1");
A.engine.drainPending();
syncFolder(dir, A.engine, B.engine);

expect(B.db.prepare("SELECT COUNT(*) c FROM notes WHERE id LIKE 'n1_conflict_%'").get()).toEqual({ c: 0 });
expect(B.engine.listRestorable("notes").rows.map((r) => r.entity_id)).toContain("n1");
expect(B.engine.restoreDeleted("notes", "n1").restored).toBe(true);
});

it("logs an applied outcome for a peer edit that lands cleanly", () => {
const clkA = clockFrom(14_000_000);
const clkB = clockFrom(14_000_000);
Expand Down Expand Up @@ -1611,4 +1667,98 @@ describe("sync engine — trigger→drain capture of real queries.ts writes", ()
expect((B.db.prepare("SELECT COUNT(*) c FROM notes WHERE id='n1'").get() as { c: number }).c).toBe(1);
});
});

describe("sync protocol versioning", () => {
function seed(engine: SyncEngine) {
q.createWorkspace(engine.db as never, { id: "ws1", name: "WS" });
q.createProject(engine.db as never, { id: "p1", workspaceId: "ws1", name: "P" });
q.createNote(engine.db as never, { id: "n1", projectId: "p1", workspaceId: "ws1", title: "N", content: "x" });
engine.drainPending();
}

it("stamps every exported entry with the current protocol version", () => {
const A = makeDevice("A", clockFrom(15_000_000).now);
seed(A.engine);
const ops = A.engine.exportOplog();
expect(ops.length).toBeGreaterThan(0);
expect(ops.every((o) => o.v === SYNC_PROTOCOL_VERSION)).toBe(true);
});

it("records a legacy (unversioned) peer's version as 1", () => {
const A = makeDevice("A", clockFrom(15_100_000).now);
const B = makeDevice("B", clockFrom(15_100_000).now);
seed(A.engine);

// Simulate an older peer by stripping the version off its entries — this
// is exactly what a pre-versioning (v2.6.1) build's oplog looks like.
const legacyOps = A.engine.exportOplog().map(({ v, ...rest }) => {
void v;
return rest;
});
B.engine.applyRemote(legacyOps);

// The version IS recorded internally as 1. Whether it surfaces in
// listPeerProtocols depends on the current protocol: it only appears when
// it DIFFERS from this build. While SYNC_PROTOCOL_VERSION === 1 a v1 peer
// is in step, so the list is empty; after a future bump the same peer
// would surface as `behind`. Assert whichever the current version implies,
// so this test stays honest across a bump instead of hard-coding 1.
const peers = B.engine.listPeerProtocols();
if (SYNC_PROTOCOL_VERSION === 1) {
expect(peers).toEqual([]);
} else {
expect(peers).toEqual([{ deviceId: "A", version: 1, behind: true }]);
}
});

it("flags a peer whose recorded version is below this build (simulated bump)", () => {
// Can't change SYNC_PROTOCOL_VERSION at runtime, so drive listPeerProtocols'
// classification directly: write a peer record one version BELOW current
// and confirm it is reported as behind.
const B = makeDevice("B", clockFrom(15_150_000).now);
const behind = SYNC_PROTOCOL_VERSION - 1;
if (behind < 1) {
// At v1 there is no valid "below" version, so there is nothing to flag.
expect(B.engine.listPeerProtocols()).toEqual([]);
return;
}
B.db.prepare("INSERT INTO sync_state (key, value) VALUES (?, ?)").run("peer_protocol:OLD", String(behind));
expect(B.engine.listPeerProtocols()).toContainEqual({ deviceId: "OLD", version: behind, behind: true });
});

it("does not flag a peer on the same protocol version", () => {
const A = makeDevice("A", clockFrom(15_200_000).now);
const B = makeDevice("B", clockFrom(15_200_000).now);
seed(A.engine);
B.engine.applyRemote(A.engine.exportOplog()); // current-version entries

// Same version → nothing to warn about.
expect(B.engine.listPeerProtocols()).toEqual([]);
});

it("keeps the highest version seen, so a stale leftover file can't mask an upgrade", () => {
const A = makeDevice("A", clockFrom(15_300_000).now);
const B = makeDevice("B", clockFrom(15_300_000).now);
seed(A.engine);

const current = A.engine.exportOplog();
// Peer upgrades (writes v = current), THEN an old cached file (v absent = 1)
// is re-read. Monotonic tracking must not downgrade the record to 1.
B.engine.applyRemote(current);
B.engine.applyRemote(current.map(({ v, ...rest }) => { void v; return rest; }));

// Never regressed below the current version → no false "behind" warning.
expect(B.engine.listPeerProtocols().some((p) => p.behind)).toBe(false);
});

it("does not record its own device as a peer", () => {
const A = makeDevice("A", clockFrom(15_400_000).now);
const B = makeDevice("B", clockFrom(15_400_000).now);
seed(A.engine);
// Round-trip so A re-applies its own gossiped ops.
B.engine.applyRemote(A.engine.exportOplog());
A.engine.applyRemote(B.engine.exportOplog());
expect(A.engine.listPeerProtocols().map((p) => p.deviceId)).not.toContain("A");
});
});
});
Loading