Files
Netcatty/domain/syncMerge.ts
陈大猫 3c4746aea0 [codex] add sync reliability metadata (#1174)
* feat: add sync reliability metadata

* fix: preserve tombstones from checked remotes

* fix: keep conflict change counts typed
2026-06-01 18:40:19 +08:00

507 lines
17 KiB
TypeScript

/**
* Three-Way Merge for Cloud Sync Payloads
*
* Implements a Git-style three-way merge using a stored "base" snapshot
* (the last successfully synced payload) to detect per-entity changes
* on both the local and remote sides.
*
* Algorithm:
* For each entity (identified by `id`):
* - Only in local → local addition → keep
* - Only in remote → remote addition → keep
* - In base, removed locally → local deletion → remove (unless remote modified)
* - In base, removed remotely → remote deletion → remove (unless local modified)
* - Modified only locally → keep local version
* - Modified only remotely → keep remote version
* - Modified on both sides → prefer local (conflict logged)
*
* When no base is available (first sync), falls back to a set-union
* merge by entity ID, preferring local for duplicates.
*/
import { carryForwardSyncDeletions, getDeletedEntityIds } from './syncReliability';
import type { CloudSyncPayloadEntityKey, SyncPayload } from './sync';
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
interface MergeSummary {
added: { local: number; remote: number };
deleted: { local: number; remote: number };
modified: { local: number; remote: number; conflicts: number };
}
interface MergeResult {
payload: SyncPayload;
/** True when both sides modified the same entity (resolved by preferring local) */
hadConflicts: boolean;
summary: MergeSummary;
}
const OPTIONAL_ENTITY_KEYS = new Set<CloudSyncPayloadEntityKey>([
'identities',
'proxyProfiles',
'snippetPackages',
'portForwardingRules',
'groupConfigs',
]);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Deterministic JSON string for content comparison.
* Sorts object keys to avoid false diffs from key ordering.
*/
function fingerprint(value: unknown): string {
return JSON.stringify(value, (_key, v) => {
if (v && typeof v === 'object' && !Array.isArray(v)) {
return Object.keys(v).sort().reduce<Record<string, unknown>>((acc, k) => {
acc[k] = (v as Record<string, unknown>)[k];
return acc;
}, {});
}
return v;
});
}
function entityArray<T>(
payload: SyncPayload,
key: CloudSyncPayloadEntityKey,
fallback: T[],
): T[] {
if (
OPTIONAL_ENTITY_KEYS.has(key)
&& !Object.prototype.hasOwnProperty.call(payload, key)
) {
return fallback;
}
const value = payload[key];
return Array.isArray(value) ? value as T[] : [];
}
// ---------------------------------------------------------------------------
// Entity-array merge (hosts, keys, identities, snippets, etc.)
// ---------------------------------------------------------------------------
interface EntityMergeResult<T> {
merged: T[];
conflicts: number;
added: { local: number; remote: number };
deleted: { local: number; remote: number };
modified: { local: number; remote: number };
}
function mergeEntityArrays<T extends { id: string }>(
base: T[],
local: T[],
remote: T[],
tombstones?: { local: Set<string>; remote: Set<string> },
): EntityMergeResult<T> {
const baseMap = new Map(base.map((e) => [e.id, e]));
const localMap = new Map(local.map((e) => [e.id, e]));
const remoteMap = new Map(remote.map((e) => [e.id, e]));
const allIds = new Set([
...baseMap.keys(),
...localMap.keys(),
...remoteMap.keys(),
]);
const merged: T[] = [];
let conflicts = 0;
const added = { local: 0, remote: 0 };
const deleted = { local: 0, remote: 0 };
const modified = { local: 0, remote: 0 };
for (const id of allIds) {
const baseItem = baseMap.get(id);
const localItem = localMap.get(id);
const remoteItem = remoteMap.get(id);
const inBase = baseItem !== undefined;
const inLocal = localItem !== undefined;
const inRemote = remoteItem !== undefined;
if (!inBase && inLocal && !inRemote && tombstones?.remote.has(id)) {
// Remote explicitly records this entity as deleted. When no base is
// available, this tombstone is the only durable signal that absence is
// intentional rather than an old client omitting the entity.
deleted.remote++;
} else if (!inBase && !inLocal && inRemote && tombstones?.local.has(id)) {
deleted.local++;
} else if (!inBase && inLocal && !inRemote) {
// Local addition
merged.push(localItem);
added.local++;
} else if (!inBase && !inLocal && inRemote) {
// Remote addition
merged.push(remoteItem);
added.remote++;
} else if (!inBase && inLocal && inRemote) {
// Both added same ID — prefer local
merged.push(localItem);
if (fingerprint(localItem) !== fingerprint(remoteItem)) {
conflicts++;
}
} else if (inBase && inLocal && inRemote) {
// Exists in all three — compare changes
const localChanged = fingerprint(localItem) !== fingerprint(baseItem);
const remoteChanged = fingerprint(remoteItem) !== fingerprint(baseItem);
if (!localChanged && !remoteChanged) {
merged.push(baseItem);
} else if (localChanged && !remoteChanged) {
merged.push(localItem);
modified.local++;
} else if (!localChanged && remoteChanged) {
merged.push(remoteItem);
modified.remote++;
} else {
// Both changed — prefer local
merged.push(localItem);
if (fingerprint(localItem) !== fingerprint(remoteItem)) {
conflicts++;
}
modified.local++;
modified.remote++;
}
} else if (inBase && !inLocal && inRemote) {
// Local deleted
const remoteChanged = fingerprint(remoteItem) !== fingerprint(baseItem);
if (remoteChanged) {
// Remote modified + local deleted → keep modification (safer)
merged.push(remoteItem);
conflicts++;
} else {
deleted.local++;
}
} else if (inBase && inLocal && !inRemote) {
// Remote deleted
const localChanged = fingerprint(localItem) !== fingerprint(baseItem);
if (localChanged) {
// Local modified + remote deleted → keep modification (safer)
merged.push(localItem);
conflicts++;
} else {
deleted.remote++;
}
}
// inBase && !inLocal && !inRemote → both deleted → gone
}
return { merged, conflicts, added, deleted, modified };
}
// ---------------------------------------------------------------------------
// String-array merge (customGroups, snippetPackages)
// ---------------------------------------------------------------------------
function mergeStringArrays(
base: string[],
local: string[],
remote: string[],
tombstones?: { local: Set<string>; remote: Set<string> },
): string[] {
const baseSet = new Set(base);
const localSet = new Set(local);
const remoteSet = new Set(remote);
const result = new Set<string>();
// Start with base items, then apply additions/deletions
const allValues = new Set([...baseSet, ...localSet, ...remoteSet]);
for (const value of allValues) {
const inBase = baseSet.has(value);
const inLocal = localSet.has(value);
const inRemote = remoteSet.has(value);
if (!inBase && inLocal && !inRemote && tombstones?.remote.has(value)) {
// Remote tombstone wins over a stale local value when no base exists.
} else if (!inBase && !inLocal && inRemote && tombstones?.local.has(value)) {
// Local tombstone wins over a stale remote value when no base exists.
} else if (!inBase) {
// Addition — keep if either side added it
if (inLocal || inRemote) result.add(value);
} else {
// Was in base — keep unless both sides deleted
const localDeleted = !inLocal;
const remoteDeleted = !inRemote;
if (localDeleted && remoteDeleted) {
// Both deleted — gone
} else if (localDeleted || remoteDeleted) {
// Only one side deleted — honour the deletion
// (If the other side didn't touch it, it's still in their set from base)
} else {
result.add(value);
}
}
}
return [...result];
}
// ---------------------------------------------------------------------------
// Settings merge (flat key-value)
// ---------------------------------------------------------------------------
type SettingsObj = NonNullable<SyncPayload['settings']>;
/** Check if an array contains objects with `id` fields (for entity merge). */
function isIdArray(arr: unknown[]): boolean {
return arr.length > 0 && typeof arr[0] === 'object' && arr[0] !== null && 'id' in arr[0];
}
/** Recursively merge two plain objects against a base using three-way logic. */
function mergeSettingsDeep(
base: Record<string, unknown>,
local: Record<string, unknown>,
remote: Record<string, unknown>,
): Record<string, unknown> {
const allKeys = new Set([
...Object.keys(base),
...Object.keys(local),
...Object.keys(remote),
]);
const merged: Record<string, unknown> = {};
for (const key of allKeys) {
const bVal = base[key];
const lVal = local[key];
const rVal = remote[key];
const lChanged = fingerprint(lVal) !== fingerprint(bVal);
const rChanged = fingerprint(rVal) !== fingerprint(bVal);
if (!lChanged && !rChanged) {
if (bVal !== undefined) merged[key] = bVal;
} else if (lChanged && !rChanged) {
if (lVal !== undefined) merged[key] = lVal;
} else if (!lChanged && rChanged) {
if (rVal !== undefined) merged[key] = rVal;
} else {
// Both changed — recurse if both are plain objects, else prefer local
if (
lVal && rVal &&
typeof lVal === 'object' && !Array.isArray(lVal) &&
typeof rVal === 'object' && !Array.isArray(rVal)
) {
merged[key] = mergeSettingsDeep(
(bVal && typeof bVal === 'object' && !Array.isArray(bVal) ? bVal : {}) as Record<string, unknown>,
lVal as Record<string, unknown>,
rVal as Record<string, unknown>,
);
} else if (lVal !== undefined) {
merged[key] = lVal;
}
}
}
return merged;
}
function mergeSettings(
base: SettingsObj | undefined,
local: SettingsObj | undefined,
remote: SettingsObj | undefined,
): SettingsObj | undefined {
if (!local && !remote) return undefined;
if (!local) return remote;
if (!remote) return local;
const b = base ?? {};
const allKeys = new Set([
...Object.keys(b),
...Object.keys(local),
...Object.keys(remote),
]);
const merged: Record<string, unknown> = {};
for (const key of allKeys) {
const bVal = (b as Record<string, unknown>)[key];
const lVal = (local as Record<string, unknown>)[key];
const rVal = (remote as Record<string, unknown>)[key];
const lChanged = fingerprint(lVal) !== fingerprint(bVal);
const rChanged = fingerprint(rVal) !== fingerprint(bVal);
if (!lChanged && !rChanged) {
if (bVal !== undefined) merged[key] = bVal;
} else if (lChanged && !rChanged) {
if (lVal !== undefined) merged[key] = lVal;
} else if (!lChanged && rChanged) {
if (rVal !== undefined) merged[key] = rVal;
} else {
// Both changed — deep merge if both are plain objects, else prefer local
if (
lVal && rVal &&
typeof lVal === 'object' && !Array.isArray(lVal) &&
typeof rVal === 'object' && !Array.isArray(rVal)
) {
merged[key] = mergeSettingsDeep(
(bVal && typeof bVal === 'object' && !Array.isArray(bVal) ? bVal : {}) as Record<string, unknown>,
lVal as Record<string, unknown>,
rVal as Record<string, unknown>,
);
} else if (
Array.isArray(lVal) && Array.isArray(rVal) &&
(isIdArray(lVal) || isIdArray(rVal) || isIdArray(Array.isArray(bVal) ? bVal as unknown[] : []))
) {
// Array of objects with `id` (e.g. customTerminalThemes) — entity merge
const bArr = Array.isArray(bVal) ? bVal as Array<{ id: string }> : [];
const result = mergeEntityArrays(bArr, lVal as Array<{ id: string }>, rVal as Array<{ id: string }>);
merged[key] = result.merged;
} else if (lVal !== undefined) {
merged[key] = lVal;
}
}
}
return Object.keys(merged).length > 0 ? (merged as SettingsObj) : undefined;
}
// ---------------------------------------------------------------------------
// Main merge function
// ---------------------------------------------------------------------------
/**
* Three-way merge of sync payloads.
*
* @param base - The last successfully synced payload (null if unavailable)
* @param local - The current device's data
* @param remote - The other device's data (downloaded from cloud)
*/
export function mergeSyncPayloads(
base: SyncPayload | null,
local: SyncPayload,
remote: SyncPayload,
): MergeResult {
const emptyBase: SyncPayload = {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
snippetPackages: [],
portForwardingRules: [],
settings: undefined,
syncedAt: 0,
};
const b = base ?? emptyBase;
const summary: MergeSummary = {
added: { local: 0, remote: 0 },
deleted: { local: 0, remote: 0 },
modified: { local: 0, remote: 0, conflicts: 0 },
};
const tombstones = (entityType: CloudSyncPayloadEntityKey) => ({
local: getDeletedEntityIds(local, entityType),
remote: getDeletedEntityIds(remote, entityType),
});
// Merge each entity type
const hosts = mergeEntityArrays(b.hosts ?? [], local.hosts ?? [], remote.hosts ?? [], tombstones('hosts'));
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? [], tombstones('keys'));
const baseIdentities = b.identities ?? [];
const identities = mergeEntityArrays(
baseIdentities,
entityArray(local, 'identities', baseIdentities),
entityArray(remote, 'identities', baseIdentities),
tombstones('identities'),
);
const baseProxyProfiles = b.proxyProfiles ?? [];
const proxyProfiles = mergeEntityArrays(
baseProxyProfiles,
entityArray(local, 'proxyProfiles', baseProxyProfiles),
entityArray(remote, 'proxyProfiles', baseProxyProfiles),
tombstones('proxyProfiles'),
);
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? [], tombstones('snippets'));
const basePortForwardingRules = b.portForwardingRules ?? [];
const portForwardingRules = mergeEntityArrays(
basePortForwardingRules,
entityArray(local, 'portForwardingRules', basePortForwardingRules),
entityArray(remote, 'portForwardingRules', basePortForwardingRules),
tombstones('portForwardingRules'),
);
// Merge group configs (keyed by path — wrap with virtual id for entity merge)
type GCWithId = import('./models').GroupConfig & { id: string };
const wrapGC = (arr: import('./models').GroupConfig[] | undefined): GCWithId[] =>
(arr ?? []).map(gc => ({ ...gc, id: gc.path }));
const unwrapGC = (arr: GCWithId[]): import('./models').GroupConfig[] =>
arr.map(({ id: _id, ...rest }) => rest as import('./models').GroupConfig);
const baseGroupConfigs = b.groupConfigs ?? [];
const groupConfigsResult = mergeEntityArrays(
wrapGC(baseGroupConfigs),
wrapGC(entityArray(local, 'groupConfigs', baseGroupConfigs)),
wrapGC(entityArray(remote, 'groupConfigs', baseGroupConfigs)),
tombstones('groupConfigs'),
);
// Aggregate stats
const entityResults: Pick<EntityMergeResult<unknown>, 'added' | 'deleted' | 'modified' | 'conflicts'>[] =
[hosts, keys, identities, proxyProfiles, snippets, portForwardingRules, groupConfigsResult];
for (const r of entityResults) {
summary.added.local += r.added.local;
summary.added.remote += r.added.remote;
summary.deleted.local += r.deleted.local;
summary.deleted.remote += r.deleted.remote;
summary.modified.local += r.modified.local;
summary.modified.remote += r.modified.remote;
summary.modified.conflicts += r.conflicts;
}
// Merge string arrays
const customGroups = mergeStringArrays(
b.customGroups ?? [],
local.customGroups ?? [],
remote.customGroups ?? [],
tombstones('customGroups'),
);
const baseSnippetPackages = b.snippetPackages ?? [];
const snippetPackages = mergeStringArrays(
baseSnippetPackages,
entityArray<string>(local, 'snippetPackages', baseSnippetPackages),
entityArray<string>(remote, 'snippetPackages', baseSnippetPackages),
tombstones('snippetPackages'),
);
// Merge settings
const settings = mergeSettings(b.settings, local.settings, remote.settings);
// Deduplicate global SFTP bookmarks by path (IDs are random per device)
if (settings?.sftpGlobalBookmarks && settings.sftpGlobalBookmarks.length > 0) {
const seenPaths = new Set<string>();
settings.sftpGlobalBookmarks = settings.sftpGlobalBookmarks.filter((bm) => {
if (seenPaths.has(bm.path)) return false;
seenPaths.add(bm.path);
return true;
});
}
const groupConfigs = unwrapGC(groupConfigsResult.merged);
const payload: SyncPayload = carryForwardSyncDeletions({
hosts: hosts.merged,
keys: keys.merged,
identities: identities.merged,
proxyProfiles: proxyProfiles.merged,
snippets: snippets.merged,
customGroups,
snippetPackages,
portForwardingRules: portForwardingRules.merged,
groupConfigs,
settings,
syncedAt: Date.now(),
}, [local, remote]);
return {
payload,
hadConflicts: summary.modified.conflicts > 0,
summary,
};
}