import { VAULT_SYNC_CODES } from "../errors/codes.js"; import { VaultSyncError } from "../errors/vault-sync-error.js"; import { getVaultSyncInstruments } from "../otel/instruments.js"; import type { MergeStrategy, VaultHistoryEntry, VaultItem, VectorClockState, } from "../types/types.js"; import { compare, lwwTotalOrder, merge } from "../vector-clock/vector-clock.js"; /** * Permitted merge strategies. Exposed for runtime checks and exhaustive * `switch` statements in consumers. */ export const MERGE_STRATEGIES: readonly MergeStrategy[] = [ "lww", "append", "atomic", ] as const; function clockUnion( a: VectorClockState, b: VectorClockState ): VectorClockState { return merge(a, b); } function chooseWinner(local: VaultItem, remote: VaultItem): VaultItem { const order = lwwTotalOrder(local, remote); if (order === -1) return local; if (order === 1) return remote; // Identical clock+updatedAt+nodeId but possibly different content (a // pathological case from anomalous inputs). Pick deterministically by // canonical-string content so commutativity is preserved. const localCanonical = canonicalString(local); const remoteCanonical = canonicalString(remote); return localCanonical <= remoteCanonical ? local : remote; } function canonicalString(item: VaultItem): string { return [ item.title, item.password, item.folderId ?? "", String(item.deleted ?? false), ].join(""); } function lwwMerge(local: VaultItem, remote: VaultItem): VaultItem { const winner = chooseWinner(local, remote); const mergedClock = clockUnion(local.clock, remote.clock); const mergedHistory = appendHistory(local.history, remote.history); const result: VaultItem = { ...winner, clock: mergedClock, history: mergedHistory, }; if (local.deleted === true || remote.deleted === true) { return { ...result, deleted: true }; } return result; } function pickHistoryEntry( a: VaultHistoryEntry, b: VaultHistoryEntry ): VaultHistoryEntry { // Deterministic, commutative selection when two sides claim the same // historyId with different content: prefer the entry with the higher // timestamp; on timestamp ties, prefer the lexicographically smaller note. if (a.timestamp !== b.timestamp) { return a.timestamp > b.timestamp ? a : b; } if (a.note < b.note) return a; if (a.note > b.note) return b; return a; } function appendHistory( left: readonly VaultHistoryEntry[], right: readonly VaultHistoryEntry[] ): readonly VaultHistoryEntry[] { const seen = new Map(); for (const entry of left) { const existing = seen.get(entry.historyId); seen.set( entry.historyId, existing === undefined ? entry : pickHistoryEntry(existing, entry) ); } for (const entry of right) { const existing = seen.get(entry.historyId); seen.set( entry.historyId, existing === undefined ? entry : pickHistoryEntry(existing, entry) ); } const out = Array.from(seen.values()); out.sort((a, b) => { if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp; return a.historyId < b.historyId ? -1 : 1; }); return out; } function appendMerge(local: VaultItem, remote: VaultItem): VaultItem { const winner = chooseWinner(local, remote); return { ...winner, clock: clockUnion(local.clock, remote.clock), history: appendHistory(local.history, remote.history), deleted: local.deleted === true || remote.deleted === true ? true : undefined, // Preserve declared shape (no extra fields) } as VaultItem; } function atomicMerge(local: VaultItem, remote: VaultItem): VaultItem { const relation = compare(local.clock, remote.clock); if (relation === "concurrent") { const conflictingFields: string[] = []; if (local.folderId !== remote.folderId) conflictingFields.push("folderId"); if (local.title !== remote.title) conflictingFields.push("title"); if (local.password !== remote.password) conflictingFields.push("password"); if ((local.deleted ?? false) !== (remote.deleted ?? false)) conflictingFields.push("deleted"); if (conflictingFields.length > 0) { getVaultSyncInstruments().mergeOutcomeTotal.add(1, { status: "conflict", }); throw new VaultSyncError( VAULT_SYNC_CODES["MERGE.CONFLICT_UNRESOLVED"], "error.vault-sync.merge.conflict-unresolved", { context: { fieldCount: conflictingFields.length } } ); } } if (relation === "<") { return { ...remote, clock: clockUnion(local.clock, remote.clock), history: appendHistory(local.history, remote.history), }; } if (relation === ">") { return { ...local, clock: clockUnion(local.clock, remote.clock), history: appendHistory(local.history, remote.history), }; } // Identical clocks → identical state by causal definition. return { ...local, clock: clockUnion(local.clock, remote.clock), history: appendHistory(local.history, remote.history), }; } /** * Merge two divergent copies of a vault item using the supplied strategy. * * The merge is commutative and idempotent for the `"lww"` and `"append"` * strategies. The `"atomic"` strategy throws * `VaultSyncError(MERGE.CONFLICT_UNRESOLVED)` when the two clocks are * concurrent and any atomic-protected field differs. * * @throws VaultSyncError(MERGE.STRATEGY_UNKNOWN) — strategy not in {lww, append, atomic} * @throws VaultSyncError(MERGE.CONFLICT_UNRESOLVED) — atomic strategy with concurrent edits */ export function mergeVaultItems( local: VaultItem, remote: VaultItem, strategy: MergeStrategy ): VaultItem { if (local.id !== remote.id) { throw new VaultSyncError( VAULT_SYNC_CODES["MERGE.CONFLICT_UNRESOLVED"], "error.vault-sync.merge.conflict-unresolved", { context: { reason: "id mismatch" } } ); } const start = Date.now(); try { let merged: VaultItem; switch (strategy) { case "lww": merged = lwwMerge(local, remote); break; case "append": merged = appendMerge(local, remote); break; case "atomic": merged = atomicMerge(local, remote); break; default: { const exhaustive: never = strategy; throw new VaultSyncError( VAULT_SYNC_CODES["MERGE.STRATEGY_UNKNOWN"], "error.vault-sync.merge.strategy-unknown", { context: { strategy: String(exhaustive) } } ); } } const i = getVaultSyncInstruments(); i.mergeDurationMs.record(Date.now() - start); i.mergeOutcomeTotal.add(1, { status: "success" }); return normalizeItem(merged); } catch (err) { if ( err instanceof VaultSyncError && err.code !== VAULT_SYNC_CODES["MERGE.CONFLICT_UNRESOLVED"] ) { getVaultSyncInstruments().mergeOutcomeTotal.add(1, { status: "rejected", }); } throw err; } } /** * Validate that `strategy` is recognised. Returns the strategy if valid; * throws `VaultSyncError(MERGE.STRATEGY_UNKNOWN)` otherwise. Useful when a * caller has accepted user input that has not yet been Zod-validated. */ export function assertMergeStrategy(value: string): MergeStrategy { if ((MERGE_STRATEGIES as readonly string[]).includes(value)) { return value as MergeStrategy; } throw new VaultSyncError( VAULT_SYNC_CODES["MERGE.STRATEGY_UNKNOWN"], "error.vault-sync.merge.strategy-unknown", { context: { strategy: value } } ); } function normalizeItem(item: VaultItem): VaultItem { if (item.deleted === undefined) { const { deleted: _omit, ...rest } = item; return rest as VaultItem; } return item; }