mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
a0fb90d0a8
commit
7f953aae0f
6 changed files with 769 additions and 14 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue