Files
suaveplan/packages/security-stack/security/vault-sync/src/merge/merge.ts
2026-05-27 23:47:53 +01:00

250 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, VaultHistoryEntry>();
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;
}