fix(sync): causality-aware conflict gating to cut false conflict dialogs (#8787)

Use vector clocks to resolve file-based sync conflicts by causality instead
of always surfacing the binary USE_LOCAL/USE_REMOTE dialog:

- keep-local when local strictly dominates the remote snapshot
- apply-snapshot (no dialog) when the snapshot strictly dominates local
- dialog when clocks are concurrent and can't be auto-resolved

Cosmetic syncVersion-reset suppression is gated on the last-seen vector
clock and treated as cosmetic ONLY when the clocks are EQUAL. A GREATER_THAN
remote clock proves the writer did more work, not that this client received
the intervening ops (a snapshot can compact ops we never saw), so it now
conservatively triggers a seq-0 resync rather than being silently skipped.

CONCURRENT-snapshot auto-merge defaults OFF and, when enabled, only merges
if the retained recent ops provably bridge the whole gap to the snapshot
clock (local ⊔ recentOps ⊒ snapshot); otherwise it falls back to the
user-recoverable dialog instead of replaying only recentOps and silently
dropping compacted-only entities. Re-enabling default-on is gated on
multi-client E2E validation (SPAP-34).

SPAP-9

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
aakhter 2026-07-07 11:07:54 -04:00 committed by GitHub
parent a0fb90d0a8
commit 7f953aae0f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 769 additions and 14 deletions

View file

@ -82,7 +82,28 @@ export const FILE_BASED_SYNC_CONSTANTS = {
BACKUP_FILE: 'sync-data.json.bak',
MIGRATION_LOCK_FILE: 'migration.lock',
FILE_VERSION: 2 as const,
MAX_RECENT_OPS: 500,
// SPAP-9: raised 500 -> 2000 to shrink the gap window. A client that missed up
// to this many ops while offline can still catch up incrementally instead of
// tripping snapshotReplacement/partialTrimGap detection and forcing a full
// seq-0 resync (and the conflict dialog that path can surface). Compact ops are
// small (~150-250 bytes serialized each), so the extra 1500 retained ops add
// roughly ~0.3 MB to sync-data.json only when the buffer is actually full.
MAX_RECENT_OPS: 2000,
SYNC_VERSION_STORAGE_KEY_PREFIX: 'FILE_SYNC_VERSION_',
LEGACY_META_FILE: '__meta_',
// SPAP-9: when a seq-0 snapshot download has CONCURRENT vector clocks with the
// local client, attempt an entity-level last-write-wins merge of the remote
// recent ops instead of forcing the binary USE_LOCAL/USE_REMOTE conflict
// dialog.
//
// Default OFF (review follow-up): the merge only replays the capped `recentOps`
// buffer and never re-hydrates the compacted `snapshotState`, so a snapshot whose
// compacted base holds an entity this client never downloaded would silently drop
// that entity — turning a user-recoverable conflict dialog into permanent, global,
// undetectable data loss. `_tryConcurrentSnapshotMerge` now additionally refuses to
// merge unless the retained recentOps provably bridge the entire gap, but until that
// guard is validated by a real multi-client E2E harness (SPAP-34) we keep the
// feature opt-in and fall back to the conflict dialog. Flip to true only alongside
// that validation.
AUTO_MERGE_CONCURRENT_SNAPSHOT: false,
} as const;

View file

@ -129,4 +129,20 @@ describe('sync provider contracts', () => {
expect(data.recentOps[0].sv).toBe(7);
expect(FILE_BASED_SYNC_CONSTANTS.SYNC_FILE).toBe('sync-data.json');
});
// SPAP-9: shrink the gap window so slow-syncing clients that miss up to
// MAX_RECENT_OPS ops can still catch up via incremental download instead of
// falling into the snapshot-replacement full-resync path.
it('retains 2000 recent ops to shrink the gap window (SPAP-9)', () => {
expect(FILE_BASED_SYNC_CONSTANTS.MAX_RECENT_OPS).toBe(2000);
});
// SPAP-9 (review follow-up): concurrent-snapshot auto-merge defaults OFF. The
// merge replays only the retained recentOps and never re-hydrates the compacted
// snapshotState, so it can silently drop compacted-only entities. It stays opt-in
// (behind a gap-covers guard) until validated by a multi-client E2E harness
// (SPAP-34); the safe default is the user-recoverable conflict dialog.
it('defaults concurrent-snapshot auto-merge OFF (SPAP-9 review follow-up)', () => {
expect(FILE_BASED_SYNC_CONSTANTS.AUTO_MERGE_CONCURRENT_SNAPSHOT).toBe(false);
});
});