Skip to content

Add sync tombstones to propagate sub-table deletions across clients#424

Merged
tomcardoso merged 4 commits into
mainfrom
feature/sync-tombstones
Jun 6, 2026
Merged

Add sync tombstones to propagate sub-table deletions across clients#424
tomcardoso merged 4 commits into
mainfrom
feature/sync-tombstones

Conversation

@tomcardoso

Copy link
Copy Markdown
Owner

Summary

  • Adds a sync_tombstones table (both local and shared schemas) that records the row ID and table name whenever a sub-table row is explicitly deleted
  • contacts:remove-tag and contacts:update write tombstones for every deleted email/phone/link/handle/tag row
  • Sync engine pulls tombstones from shared before merging, pushes them to shared during the push phase, and GCs entries older than 90 days
  • mergeSubTablesFromShared now filters local-only rows against local tombstones so deletions from other clients aren't resurrected
  • Also fixes the tag push path: it was additive-only (unlike emails/phones/etc.), so tag deletions never reached shared — changed to full DELETE+INSERT consistent with all other sub-tables
  • 6 new tests covering tombstone push/pull, resurrection prevention, and the re-add-after-delete edge case

What's new

Sync now respects explicit deletions. Previously, if you removed a tag (or phone, email, link, or handle) on one client and synced, the deleted item could silently reappear on your next sync from another client. This is fixed with a tombstone table: each deletion is recorded by row ID, and the sync engine uses those records to filter out resurrected rows during merges. Tombstones are shared across all clients and automatically cleaned up after 90 days.

Test plan

  • All 473 existing tests pass
  • New test: tombstone written on contacts:remove-tag
  • New test: no tombstone written when tag doesn't exist
  • New test: local tombstones pushed to shared on sync
  • New test: shared tombstones pulled to local on sync
  • New test: deleted tag on client A not resurrected on client B
  • New test: re-added email (new UUID) not blocked by old tombstone

Closes #422

🤖 Generated with Claude Code

…ents

Closes #422

Previously, sub-table deletions (tags in particular) were silently
resurrected on other clients because the pull-side merge was additive-only.
Tombstones give each deletion a durable identity that survives beyond the
deleted row itself.

Key changes:
- schema.ts / shared-schema.ts: add sync_tombstones (table_name, row_id, deleted_at)
- contacts:remove-tag: SELECT row id before DELETE, then INSERT tombstone
- contacts:update: snapshot row IDs before each sub-table DELETE; insert
  tombstones for any rows not present in the re-inserted set
- engine.ts — pullTombstones: copy shared tombstones to local (phase 2)
- engine.ts — pushTombstones: copy local tombstones to shared + GC (phase 3)
- engine.ts — loadLocalTombstones: build per-table Set map once per pull
- engine.ts — mergeSubTablesFromShared: filter local-only rows against tombstones
- engine.ts — pushSubTablesToShared tags: changed to full DELETE+INSERT (was
  additive-only) so tag deletions actually reach shared before tombstones can filter them
- GC: tombstones older than 90 days are pruned from both DBs on each sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces deletion tombstones for contact sub-tables (emails/phones/links/handles/tags) so that explicit deletions propagate across clients and aren’t resurrected by the additive pull-merge strategy. It also fixes tag syncing to use full replace semantics (DELETE + INSERT) like the other sub-tables, and adds tests covering tombstone push/pull and resurrection prevention.

Changes:

  • Add sync_tombstones table to both local and shared schemas, plus basic TTL garbage collection.
  • Write tombstones on sub-table deletions (notably contacts:update for sub-tables and contacts:remove-tag for tags).
  • Sync engine now pulls/pushes tombstones and uses them to filter tombstoned local-only rows during sub-table merges; tag push is changed to DELETE+INSERT.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/test/tags.test.ts Adds tests verifying tombstones are written (or not) for tag removals.
src/test/sync-engine.test.ts Adds sync tests for tombstone push/pull and resurrection prevention scenarios.
src/main/sync/engine.ts Adds tombstone pull/push + GC and filters local-only merge rows by tombstones; fixes tag push semantics.
src/main/ipc/contacts.ts Writes tombstones for deleted sub-table rows in contacts:update and contacts:remove-tag.
src/main/database/shared-schema.ts Adds sync_tombstones table + index to shared schema SQL.
src/main/database/shared-db.ts Shared DB migration scaffolding area touched; needs tombstone migration behavior for existing DBs.
src/main/database/schema.ts Adds sync_tombstones table + index to local schema SQL.
src/main/database/index.ts Local migrations comment updated; needs an actual migration for existing DBs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/main/database/index.ts
Comment thread src/main/database/shared-db.ts
Comment thread src/main/sync/engine.ts
Comment on lines +103 to +118
function pullTombstones(local: Database.Database, shared: Database.Database): void {
const rows = shared.prepare('SELECT id, table_name, row_id, deleted_at FROM sync_tombstones').all() as {
id: string; table_name: string; row_id: string; deleted_at: number;
}[];
const insert = local.prepare('INSERT OR IGNORE INTO sync_tombstones (id, table_name, row_id, deleted_at) VALUES (?, ?, ?, ?)');
for (const r of rows) insert.run(r.id, r.table_name, r.row_id, r.deleted_at);
}

function pushTombstones(local: Database.Database, shared: Database.Database, now: number): void {
const rows = local.prepare('SELECT id, table_name, row_id, deleted_at FROM sync_tombstones').all() as {
id: string; table_name: string; row_id: string; deleted_at: number;
}[];
const insert = shared.prepare('INSERT OR IGNORE INTO sync_tombstones (id, table_name, row_id, deleted_at) VALUES (?, ?, ?, ?)');
for (const r of rows) insert.run(r.id, r.table_name, r.row_id, r.deleted_at);
shared.prepare('DELETE FROM sync_tombstones WHERE deleted_at < ?').run(now - 90 * 24 * 3600);
}
Comment thread src/main/ipc/contacts.ts Outdated
.map((r) => [`${r.type}:${r.handle}`, r.id])
);

const tombstone = stmt(db, 'INSERT OR IGNORE INTO sync_tombstones (id, table_name, row_id, deleted_at) VALUES (?, ?, ?, ?)');
Comment thread src/main/ipc/contacts.ts Outdated
Comment on lines +801 to +805
const row = db.prepare('SELECT id FROM contact_tags WHERE contact_id = ? AND tag = ?').get(contactId, normalized) as { id: string } | undefined;
if (!row) return;
db.prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, normalized);
db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId);
db.prepare('INSERT OR IGNORE INTO sync_tombstones (id, table_name, row_id, deleted_at) VALUES (?, ?, ?, ?)').run(uuidv4(), 'contact_tags', row.id, now);
Addresses review feedback on PR #424.

- Drop the separate `id` (UUID) column; use PRIMARY KEY (table_name, row_id)
  so the table's natural identity matches its semantic identity. Removes the
  dual-constraint problem that prevented a clean UPSERT.
- Drop the now-redundant UNIQUE index on (table_name, row_id).
- All tombstone writes (IPC handlers + sync engine) now use
  INSERT ... ON CONFLICT(table_name, row_id) DO UPDATE SET deleted_at = MAX(...)
  instead of INSERT OR IGNORE, so a later deletion from a long-offline client
  correctly extends the TTL rather than being silently dropped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@tomcardoso tomcardoso added the claude-review Trigger Claude code review label Jun 6, 2026
// Wrap in a transaction to establish the correct pattern: when real migration
// DDL blocks are added, each block must stamp user_version atomically with its
// DDL so a mid-migration crash cannot leave the DB partially migrated (fixes #207).
// No migration blocks yet — new DBs receive all schema via initDatabase → LOCAL_SCHEMA_DDL_SQL.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing migration block — this will break existing databases.

sync_tombstones is added to LOCAL_SCHEMA_DDL_SQL, but that SQL only runs for brand-new databases via initDatabase. Existing users whose user_version is already at DB_VERSION will hit the return guard above and skip this function entirely, so their databases will never get the table. Any call to INSERT INTO sync_tombstones or SELECT FROM sync_tombstones will throw no such table: sync_tombstones at runtime.

DB_VERSION needs to be bumped and a migration block needs to be added here:

Suggested change
// No migration blocks yet — new DBs receive all schema via initDatabase → LOCAL_SCHEMA_DDL_SQL.
// Migration to DB_VERSION N: add sync_tombstones for deletion propagation.
if (version < /* new DB_VERSION */) {
db.transaction(() => {
db.prepare(`
CREATE TABLE IF NOT EXISTS sync_tombstones (
table_name TEXT NOT NULL,
row_id TEXT NOT NULL,
deleted_at INTEGER NOT NULL,
PRIMARY KEY (table_name, row_id)
)
`).run();
db.pragma(`user_version = /* new DB_VERSION */`);
})();
}

The DB_VERSION constant (wherever it's defined) must also be incremented to match.

@@ -85,16 +85,6 @@ function runSharedMigrations(db: Database.Database): void {
if (version < 1) {
db.pragma('user_version = 1');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing migration for the shared DB. Same issue as the local DB: sync_tombstones was added to SHARED_SCHEMA_SQL, but that only runs when a new shared DB is first created. Existing shared databases will never get the table, causing runtime crashes for any client that tries to sync tombstones.

A new if (version < 2) block is needed here (and the shared user_version must be bumped to match):

Suggested change
}
if (version < 2) {
db.transaction(() => {
db.prepare(`
CREATE TABLE IF NOT EXISTS sync_tombstones (
table_name TEXT NOT NULL,
row_id TEXT NOT NULL,
deleted_at INTEGER NOT NULL,
PRIMARY KEY (table_name, row_id)
)
`).run();
db.pragma('user_version = 2');
})();
}

Note: the example migration template that was deleted from this file in this PR contained an important warning about using INSERT OR REPLACE instead of ON CONFLICT … WHERE value > due to SQLite's lexicographic comparison for min_app_version. Worth retaining that comment somewhere if it's no longer inline here.

Comment thread src/main/sync/engine.ts
const sharedEmailValues = new Set(sharedEmails.map((e) => e.email));
const localOnlyEmails = localEmails.filter((e) => !sharedEmailValues.has(e.email));
const emailTombstones = tombstones.get('contact_emails') ?? new Set<string>();
const localOnlyEmails = localEmails.filter((e) => !sharedEmailValues.has(e.email) && !emailTombstones.has(e.id));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tombstone filtering only covers local-only rows — shared rows aren't checked.

This filter correctly prevents a local deletion from being resurrected by a local-only row, but it doesn't protect against the concurrent-edit resurrection case:

  1. Client A and B both have alice@example.com (ID email-1), synced.
  2. A deletes it and writes a tombstone for email-1.
  3. Before A syncs, B edits the contact (name change) and syncs — B's push re-inserts alice@example.com into shared.
  4. A syncs: pullTombstones brings in nothing new (no shared tombstone), then mergeSubTablesFromShared finds alice@example.com in sharedEmailsnot in localOnlyEmails. The tombstone filter never fires. Email resurrected.

The fix is to also filter sharedEmails against local tombstones:

Suggested change
const localOnlyEmails = localEmails.filter((e) => !sharedEmailValues.has(e.email) && !emailTombstones.has(e.id));
const emailTombstones = tombstones.get('contact_emails') ?? new Set<string>();
const filteredSharedEmails = sharedEmails.filter((e) => !emailTombstones.has(e.id));
const localOnlyEmails = localEmails.filter((e) => !sharedEmailValues.has(e.email) && !emailTombstones.has(e.id));

and use filteredSharedEmails in place of sharedEmails in mergedEmails. The same pattern applies to phones, links, handles, and tags below. The same fix is needed in the test — the resurrection test only exercises the case where A syncs before B, not the concurrent-edit order.

Comment thread src/main/sync/engine.ts
INSERT INTO sync_tombstones (table_name, row_id, deleted_at) VALUES (?, ?, ?)
ON CONFLICT(table_name, row_id) DO UPDATE SET deleted_at = MAX(deleted_at, excluded.deleted_at)
`);
for (const r of rows) upsert.run(r.table_name, r.row_id, r.deleted_at);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TTL magic number duplicated. 90 * 24 * 3600 also appears as TOMBSTONE_TTL in syncProject (for the local GC). Extract a module-level constant so both GC sites stay in sync:

Suggested change
for (const r of rows) upsert.run(r.table_name, r.row_id, r.deleted_at);
for (const r of rows) upsert.run(r.table_name, r.row_id, r.deleted_at);
shared.prepare('DELETE FROM sync_tombstones WHERE deleted_at < ?').run(now - TOMBSTONE_TTL_SECONDS);

With const TOMBSTONE_TTL_SECONDS = 90 * 24 * 3600; at the top of the file, replacing the inline constant in syncProject as well.

row_id TEXT NOT NULL,
deleted_at INTEGER NOT NULL,
PRIMARY KEY (table_name, row_id)
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding an index on deleted_at to keep the GC query fast as tombstones accumulate:

Suggested change
);
);
CREATE INDEX IF NOT EXISTS idx_sync_tombstones_deleted_at ON sync_tombstones(deleted_at);

DELETE FROM sync_tombstones WHERE deleted_at < ? is a table scan without it. The same index should be added to SHARED_SCHEMA_SQL in shared-schema.ts.

Comment thread src/main/sync/engine.ts Outdated
const tagRows = local
.prepare('SELECT * FROM contact_tags WHERE contact_id = ?')
.all(contactId) as { id: string; tag: string; created_at: number }[];
const insertSharedTag = shared.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

INSERT OR IGNORE is now redundant. The DELETE two lines above just wiped all tags for this contact, so there's nothing to conflict with. Can be simplified to plain INSERT:

Suggested change
const insertSharedTag = shared.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)');
const insertSharedTag = shared.prepare('INSERT INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)');


// ---------------------------------------------------------------------------
// Tombstone sync (#422)
// ---------------------------------------------------------------------------

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tombstone tests for contacts:remove-tag are covered in tags.test.ts, but there are no tests for the tombstone writes in contacts:update for emails, phones, links, and handles. That path writes tombstones via the snapshot-before-delete approach in contacts.ts and is the primary deletion mechanism for those sub-tables. Worth adding at least one test (e.g. delete an email via contacts:update and verify sync_tombstones receives a row with table_name = 'contact_emails' and the correct row_id).

tomcardoso and others added 2 commits June 5, 2026 23:38
- Filter shared rows against local tombstones before merging, not just
  local-only rows. Fixes the concurrent-edit resurrection case: if client B
  pushes a contact that re-includes a tombstoned shared row (same ID), it
  now gets excluded rather than landing in the merged set.
- Extract TOMBSTONE_TTL_SECONDS as a module-level constant; was duplicated
  between pushTombstones and syncProject's phase-4 GC.
- Add idx_sync_tombstones_deleted_at to both schemas so the GC DELETE query
  doesn't require a full table scan.
- Change INSERT OR IGNORE → INSERT in pushSubTablesToShared tag loop; the
  preceding DELETE already cleared the contact's rows so the guard is redundant.
- Add contacts:update tombstone test verifying that deleting emails/phones/
  links/handles writes the correct sync_tombstones rows.
- Add concurrent-edit resurrection test (tombstoned shared row, not just
  local-only row, is filtered out).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- contacts:update: kept sub-table rows must not be tombstoned — regression
  guard for the snapshot-diff logic (only deleted IDs should appear in
  sync_tombstones, not surviving ones)
- GC: tombstones older than 90 days are pruned from both local (phase-4
  transaction) and shared (pushTombstones); fresh tombstones survive
- UPSERT MAX(deleted_at): when the same row_id arrives from shared with a
  newer deleted_at, pullTombstones updates the local entry rather than
  keeping the stale timestamp

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@tomcardoso tomcardoso merged commit 03babd9 into main Jun 6, 2026
1 check passed
@tomcardoso tomcardoso deleted the feature/sync-tombstones branch June 6, 2026 03:46
@tomcardoso tomcardoso mentioned this pull request Jun 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude-review Trigger Claude code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sync: tombstone table for sub-table deletions

2 participants