/
raiden
/
obsidian-git-encrypt
Обзор
Документация
Войти
/
raiden
/
obsidian-git-encrypt
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/sync/changePlanner.ts
253 строки
9 KB
Robert Kuzhin
feat: orchestrate recoverable two-device sync
09 авг 2026, 12:16
09 авг 2026, 12:16
7b0e3b9
Код
Авторство
О чём код?
import type { Manifest, ManifestEntry } from "../mirror/manifestTypes.js"; import type { PlannedAction, ReconciliationPlan, SyncConflict } from "./syncTypes.js"; interface SelectedEntry { readonly entry: ManifestEntry; readonly source: "local" | "remote" | "equal"; } export function planChanges( base: Manifest, local: Manifest, remote: Manifest, ): PlannedAction[] { assertCompatibleManifests(base, local, remote); const actions: PlannedAction[] = []; const selected = new Map<string, SelectedEntry>(); const allFileIds = new Set([ ...Object.keys(base.files), ...Object.keys(local.files), ...Object.keys(remote.files), ]); for (const fileId of [...allFileIds].sort()) { const baseEntry = base.files[fileId]; const localEntry = local.files[fileId]; const remoteEntry = remote.files[fileId]; const localChanged = !entriesEqual(localEntry, baseEntry); const remoteChanged = !entriesEqual(remoteEntry, baseEntry); if (entriesEqual(localEntry, remoteEntry)) { if (localEntry !== undefined) selected.set(fileId, { entry: localEntry, source: "equal" }); continue; } if (!localChanged && remoteChanged) { if (remoteEntry !== undefined) selected.set(fileId, { entry: remoteEntry, source: "remote" }); actions.push(...remoteActions(baseEntry, localEntry, remoteEntry)); continue; } if (localChanged && !remoteChanged) { if (localEntry !== undefined) selected.set(fileId, { entry: localEntry, source: "local" }); actions.push(...localActions(baseEntry, localEntry, remoteEntry)); continue; } const conflict: SyncConflict = { kind: "concurrent-change", fileId, ...(baseEntry === undefined ? {} : { base: baseEntry }), ...(localEntry === undefined ? {} : { local: localEntry }), ...(remoteEntry === undefined ? {} : { remote: remoteEntry }), }; actions.push({ type: "conflict", conflict }); } return replacePortablePathCollisions(actions, selected, local, remote); } export function planReconciliation( base: Manifest, local: Manifest, remote: Manifest, ): ReconciliationPlan { const actions = planChanges(base, local, remote); const conflicts = actions .filter((action) => action.type === "conflict") .map((action) => action.conflict); if (conflicts.length > 0) return { actions, conflicts }; const files: Record<string, ManifestEntry> = {}; const allFileIds = new Set([ ...Object.keys(base.files), ...Object.keys(local.files), ...Object.keys(remote.files), ]); for (const fileId of [...allFileIds].sort()) { const baseEntry = base.files[fileId]; const localEntry = local.files[fileId]; const remoteEntry = remote.files[fileId]; const selected = entriesEqual(localEntry, remoteEntry) ? localEntry : entriesEqual(localEntry, baseEntry) ? remoteEntry : localEntry; if (selected !== undefined) files[fileId] = selected; } const mergedManifest = selectManifestRevision(local, remote, files); return { actions, conflicts, mergedManifest }; } function remoteActions( base: ManifestEntry | undefined, local: ManifestEntry | undefined, remote: ManifestEntry | undefined, ): PlannedAction[] { if (remote === undefined || remote.deleted === true) { const oldPath = activePath(local) ?? activePath(base); const fileId = remote?.fileId ?? local?.fileId ?? base?.fileId; return [ ...(oldPath === undefined ? [] : [{ type: "delete-from-vault" as const, path: oldPath }]), ...(fileId === undefined ? [] : [{ type: "delete-from-mirror" as const, fileId }]), ]; } const result: PlannedAction[] = [ { type: "decrypt-to-vault", fileId: remote.fileId, path: remote.path }, ]; const oldPath = activePath(local) ?? activePath(base); if (oldPath !== undefined && oldPath !== remote.path) { result.push({ type: "delete-from-vault", path: oldPath }); } return result; } function localActions( base: ManifestEntry | undefined, local: ManifestEntry | undefined, remote: ManifestEntry | undefined, ): PlannedAction[] { if (local !== undefined && local.deleted !== true) { return [{ type: "encrypt-from-vault", fileId: local.fileId, path: local.path }]; } const fileId = remote?.fileId ?? base?.fileId; return fileId === undefined ? [] : [{ type: "delete-from-mirror", fileId }]; } function replacePortablePathCollisions( actions: readonly PlannedAction[], selected: ReadonlyMap<string, SelectedEntry>, local: Manifest, remote: Manifest, ): PlannedAction[] { const activeByPath = new Map<string, { readonly fileId: string; readonly selected: SelectedEntry }>(); const collisions: SyncConflict[] = []; const conflictedIds = new Set<string>(); for (const [fileId, candidate] of selected) { if (candidate.entry.deleted === true) continue; const pathKey = portablePathKey(candidate.entry.path); const previous = activeByPath.get(pathKey); if (previous === undefined) { activeByPath.set(pathKey, { fileId, selected: candidate }); continue; } if (previous.fileId === fileId) continue; const localCandidate = pickSideEntry(local, previous.fileId, fileId); const remoteCandidate = pickSideEntry(remote, previous.fileId, fileId); const conflict: SyncConflict = { kind: "path-collision", ...(localCandidate === undefined ? {} : { local: localCandidate, localFileId: localCandidate.fileId, }), ...(remoteCandidate === undefined ? {} : { remote: remoteCandidate, remoteFileId: remoteCandidate.fileId, }), }; collisions.push(conflict); conflictedIds.add(previous.fileId); conflictedIds.add(fileId); } const safeActions = actions.filter((action) => { const fileId = actionFileId(action); return fileId === undefined || !conflictedIds.has(fileId); }); return [...safeActions, ...collisions.map((conflict) => ({ type: "conflict" as const, conflict }))]; } function actionFileId(action: PlannedAction): string | undefined { switch (action.type) { case "decrypt-to-vault": case "encrypt-from-vault": case "delete-from-mirror": return action.fileId; case "delete-from-vault": case "conflict": return undefined; } } function pickSideEntry(manifest: Manifest, firstId: string, secondId: string): ManifestEntry | undefined { const first = manifest.files[firstId]; if (first !== undefined && first.deleted !== true) return first; const second = manifest.files[secondId]; return second !== undefined && second.deleted !== true ? second : undefined; } function entriesEqual(left: ManifestEntry | undefined, right: ManifestEntry | undefined): boolean { if (left === right) return true; if (left === undefined || right === undefined) return false; return ( left.fileId === right.fileId && left.path === right.path && left.plaintextHash === right.plaintextHash && left.objectVersion === right.objectVersion && left.size === right.size && left.modifiedAt === right.modifiedAt && left.deleted === right.deleted ); } function selectManifestRevision( local: Manifest, remote: Manifest, files: Readonly<Record<string, ManifestEntry>>, ): Manifest { const localMatches = fileMapsEqual(files, local.files); const remoteMatches = fileMapsEqual(files, remote.files); if (localMatches && remoteMatches) return local.revision >= remote.revision ? local : remote; if (localMatches) return local; if (remoteMatches) return remote; return { formatVersion: 1, vaultId: local.vaultId, revision: Math.max(local.revision, remote.revision) + 1, files, }; } function fileMapsEqual( left: Readonly<Record<string, ManifestEntry>>, right: Readonly<Record<string, ManifestEntry>>, ): boolean { const leftIds = Object.keys(left).sort(); const rightIds = Object.keys(right).sort(); return ( leftIds.length === rightIds.length && leftIds.every((fileId, index) => fileId === rightIds[index] && entriesEqual(left[fileId], right[fileId]), ) ); } function activePath(entry: ManifestEntry | undefined): string | undefined { return entry === undefined || entry.deleted === true ? undefined : entry.path; } function portablePathKey(value: string): string { return value.normalize("NFC").toLocaleLowerCase("en-US"); } function assertCompatibleManifests(base: Manifest, local: Manifest, remote: Manifest): void { if ( base.formatVersion !== 1 || local.formatVersion !== 1 || remote.formatVersion !== 1 || base.vaultId !== local.vaultId || base.vaultId !== remote.vaultId ) { throw new TypeError("ChangePlanner requires compatible manifests from the same vault"); } }