From c54128bd2773af021c97a96256dca1e55c693b6c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 29 Sep 2026 22:24:21 +0100 Subject: [PATCH] Add file changes --- .../file-operations/file-change-session.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 frontend/sync-client/src/file-operations/file-change-session.ts diff --git a/frontend/sync-client/src/file-operations/file-change-session.ts b/frontend/sync-client/src/file-operations/file-change-session.ts new file mode 100644 index 00000000..7b5a829e --- /dev/null +++ b/frontend/sync-client/src/file-operations/file-change-session.ts @@ -0,0 +1,79 @@ +import type { + DocumentId, + RelativePath, + StoredDatabase +} from "../persistence/database"; +import type { LocalChangeCheckpoint } from "../sync-operations/local-change-checkpoint"; +import { + applyLocalChange, + getUnappliedChanges, + type LocalChange +} from "../sync-operations/local-changes"; +import type { FileWrite } from "./file-operations"; + +export interface LocalChangeJournal { + readonly entries: readonly LocalChange[]; + flush: () => Promise; +} + +interface FileChangeSessionOptions { + current: StoredDatabase; + planned: StoredDatabase; + writes: Partial>; + localChanges: LocalChangeJournal; + checkpoint: LocalChangeCheckpoint; + isProtectedPath: (path: RelativePath) => boolean; +} + +/** + * Coordinates one FileOperations.applyChanges call with FileRenamer, tracking the + * working and planned identity maps, content writes, and affected paths. Local + * identity notifications invalidate the plan before the first file mutation; + * afterward, they are replayed into both maps so in-progress changes can finish + * consistently. The caller applies filesystem operations and commits the planned + * metadata; the session does not provide rollback. + */ +export class FileChangeSession { + public readonly current: StoredDatabase; + public readonly planned: StoredDatabase; + + public readonly writes: Partial>; + public readonly affectedPaths = new Set(); + private mutationsStarted = false; + + public constructor(private readonly options: FileChangeSessionOptions) { + this.current = options.current; + this.planned = options.planned; + this.writes = options.writes; + } + + public abortIfStale(): void { + if (this.mutationsStarted) { + return; + } + + this.options.checkpoint.assertUnchanged(); + } + + public beforeFileMutation(): void { + this.abortIfStale(); + this.mutationsStarted = true; + } + + public async replayLocalChanges(): Promise { + const { localChanges, isProtectedPath } = this.options; + await localChanges.flush(); + + this.abortIfStale(); + + for (const change of getUnappliedChanges( + this.planned, + localChanges.entries + )) { + for (const state of [this.current, this.planned]) { + applyLocalChange(state, change, isProtectedPath); + state.lastAppliedLocalChangeId = change.changeId; + } + } + } +}