From 0faef712882ac2c1e5b106a6fec1c5c99f0eab2e Mon Sep 17 00:00:00 2001 From: Ethan Graf Date: Thu, 18 Jun 2026 22:37:57 -0400 Subject: [PATCH] Vault integration phase 2: push-only sync engine + change detection Add the vault core sync algorithms on top of the phase 1 foundation: - change-detection.ts: split detection into local-only (steady state, no network) and remote-tree (first-open clone only). Removes the per-file remote head-comparison. - engine.ts: VaultSyncEngine. sync() does a one-time remote clone on first mount, then is push-only. Adds per-file openFile() (pull on open) and pushFile() (push on save), so steady-state cost is proportional to what the user touched rather than the whole tree. - move-detection.ts: rename detection by content similarity. - habitat-repo.ts: implement create() via createAutomergeDoc. - types.ts/snapshot.ts: supporting types and snapshot wiring. - App.tsx: TODO to run a top-level sync on first vault mount. Co-Authored-By: Claude Opus 4.8 --- src/App.tsx | 5 + src/vault/change-detection.ts | 207 +++++++++++++ src/vault/engine.test.ts | 549 ++++++++++++++++++++++++++++++++++ src/vault/engine.ts | 522 ++++++++++++++++++++++++++++++++ src/vault/habitat-repo.ts | 25 +- src/vault/move-detection.ts | 102 +++++++ src/vault/snapshot.ts | 17 +- src/vault/types.ts | 2 +- 8 files changed, 1409 insertions(+), 20 deletions(-) create mode 100644 src/vault/change-detection.ts create mode 100644 src/vault/engine.test.ts create mode 100644 src/vault/engine.ts create mode 100644 src/vault/move-detection.ts diff --git a/src/App.tsx b/src/App.tsx index 1757a77..34741ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -258,6 +258,11 @@ function AppShell() { const name = vaultPath.split('/').pop() || vaultPath; const providerId = `vault-${id}`; addOpenVault({ id, path: vaultPath, type: vaultType, name, did }); + // TODO: On first mount of a synced vault, run a top-level + // VaultSyncEngine.sync() once — it performs the one-time remote clone and + // reconciles any out-of-band changes (renames/deletes) made while the app + // was closed. Steady-state edits should then go through + // VaultSyncEngine.pushFile/openFile rather than full syncs. // Auto-switch to the newly created vault — same path as the selector. setSelectedProviderId(providerId); }, diff --git a/src/vault/change-detection.ts b/src/vault/change-detection.ts new file mode 100644 index 0000000..f6d7c21 --- /dev/null +++ b/src/vault/change-detection.ts @@ -0,0 +1,207 @@ +import type { FilesystemApi } from '../filesystem/types'; +import { join, basename, dirname } from '../filesystem/vaultFs'; +import { contentHash } from './utils/content'; +import type { + DetectedChange, + DirectoryDocument, + HabitatRepo, + HabitatUri, + SyncSnapshot, +} from './types'; +import { isFolderEntry } from './types'; + +export class VaultChangeDetector { + constructor( + private fs: FilesystemApi, + private repo: HabitatRepo, + ) {} + + /** + * Steady-state detection: local filesystem vs snapshot only. No network. + * Returns local file and directory changes (adds/edits/deletes) to push. + */ + async detectLocal( + rootPath: string, + snapshot: SyncSnapshot, + ): Promise { + const [files, dirs] = await Promise.all([ + this.scanLocalFiles(rootPath, snapshot), + this.scanLocalDirs(rootPath, snapshot), + ]); + return [...files, ...dirs]; + } + + /** + * First-open clone only: walk the entire remote tree and return every entry + * not yet in the snapshot as an "add". Expensive (one `repo.find` per + * directory) — only run once, when a vault has no local snapshot yet. + */ + async scanRemoteTree( + rootUri: HabitatUri, + snapshot: SyncSnapshot, + ): Promise { + return this.walkRemoteTree(rootUri, '', snapshot); + } + + private async walkFilesystem( + rootPath: string, + relDir: string, + ): Promise { + const base = relDir ? join(rootPath, relDir) : rootPath; + const entries = await this.fs.readdir(base); + const result: string[] = []; + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + const relPath = relDir ? join(relDir, entry.name) : entry.name; + if (entry.isDirectory) { + result.push(...(await this.walkFilesystem(rootPath, relPath))); + } else if (entry.isFile) { + result.push(relPath); + } + } + return result; + } + + private async walkFilesystemDirs( + rootPath: string, + relDir: string, + ): Promise { + const base = relDir ? join(rootPath, relDir) : rootPath; + const entries = await this.fs.readdir(base); + const result: string[] = []; + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + if (entry.isDirectory) { + const relPath = relDir ? join(relDir, entry.name) : entry.name; + result.push(relPath); + result.push(...(await this.walkFilesystemDirs(rootPath, relPath))); + } + } + return result; + } + + private async scanLocalFiles( + rootPath: string, + snapshot: SyncSnapshot, + ): Promise { + const fsFiles = await this.walkFilesystem(rootPath, ''); + const changes: DetectedChange[] = []; + + for (const relPath of fsFiles) { + const content = await this.fs.readFile(join(rootPath, relPath)); + const hash = contentHash(content); + + if (snapshot.files.has(relPath)) { + const entry = snapshot.files.get(relPath)!; + if (entry.contentHash !== hash) { + changes.push({ + type: 'edit', + path: relPath, + isDirectory: false, + uri: entry.uri, + contentHash: hash, + }); + } + } else { + changes.push({ + type: 'add', + path: relPath, + isDirectory: false, + contentHash: hash, + }); + } + } + + for (const [relPath, entry] of snapshot.files) { + if (!fsFiles.includes(relPath)) { + changes.push({ + type: 'delete', + path: relPath, + isDirectory: false, + uri: entry.uri, + }); + } + } + + return changes; + } + + private async scanLocalDirs( + rootPath: string, + snapshot: SyncSnapshot, + ): Promise { + const fsDirs = await this.walkFilesystemDirs(rootPath, ''); + const changes: DetectedChange[] = []; + + for (const relPath of fsDirs) { + if (!snapshot.directories.has(relPath)) { + changes.push({ type: 'add', path: relPath, isDirectory: true }); + } + } + + for (const [relPath] of snapshot.directories) { + if (!fsDirs.includes(relPath)) { + const entry = snapshot.directories.get(relPath)!; + changes.push({ + type: 'delete', + path: relPath, + isDirectory: true, + uri: entry.uri, + }); + } + } + + return changes; + } + + private async walkRemoteTree( + dirUri: HabitatUri, + relPath: string, + snapshot: SyncSnapshot, + ): Promise { + const result: DetectedChange[] = []; + + try { + const handle = await this.repo.find(dirUri); + const dirDoc = handle.doc(); + + for (const entry of dirDoc.docs ?? []) { + const childRel = relPath ? join(relPath, entry.name) : entry.name; + + if (isFolderEntry(entry)) { + if (!snapshot.directories.has(childRel)) { + const childHandle = await this.repo.find( + entry.url, + ); + result.push({ + type: 'add', + path: childRel, + isDirectory: true, + uri: entry.url, + head: childHandle.heads(), + }); + } + result.push( + ...(await this.walkRemoteTree(entry.url, childRel, snapshot)), + ); + } else { + if (!snapshot.files.has(childRel)) { + result.push({ + type: 'add', + path: childRel, + isDirectory: false, + uri: entry.url, + }); + } + } + } + } catch (e) { + console.warn('[ChangeDetector] walkRemoteTree failed for', dirUri, e); + } + + return result; + } +} + +// Re-export for use in engine +export { basename, dirname }; diff --git a/src/vault/engine.test.ts b/src/vault/engine.test.ts new file mode 100644 index 0000000..20b196a --- /dev/null +++ b/src/vault/engine.test.ts @@ -0,0 +1,549 @@ +import * as nodeFs from 'fs'; +import * as nodePath from 'path'; +import * as os from 'os'; +import * as Automerge from '@automerge/automerge'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { VaultSyncEngine } from './engine'; +import { VaultChangeDetector } from './change-detection'; +import { VaultSnapshotManager } from './snapshot'; +import type { + DirectoryDocument, + FileDocument, + HabitatDocHandle, + HabitatRepo, + HabitatUri, + VaultConfig, +} from './types'; +import type { FilesystemApi, FsDirent } from '../filesystem/types'; + +// --------------------------------------------------------------------------- +// Node-native FilesystemApi +// --------------------------------------------------------------------------- + +class NodeFilesystemApi implements FilesystemApi { + async readdir(dirPath: string): Promise { + const entries = nodeFs.readdirSync(dirPath, { withFileTypes: true }); + return entries.map((e) => ({ + name: e.name, + isDirectory: e.isDirectory(), + isFile: e.isFile(), + })); + } + + async readFile(filePath: string): Promise { + return nodeFs.readFileSync(filePath, 'utf-8'); + } + + async writeFile(filePath: string, content: string): Promise { + nodeFs.writeFileSync(filePath, content, 'utf-8'); + } + + async fileExists(filePath: string): Promise { + return nodeFs.existsSync(filePath); + } + + async mkdir(dirPath: string): Promise { + nodeFs.mkdirSync(dirPath, { recursive: true }); + } + + async rename(oldPath: string, newPath: string): Promise { + nodeFs.renameSync(oldPath, newPath); + } + + async deletePath(filePath: string): Promise { + nodeFs.rmSync(filePath, { recursive: true, force: true }); + } + + async copyFile(src: string, dest: string): Promise { + nodeFs.copyFileSync(src, dest); + } +} + +// --------------------------------------------------------------------------- +// In-memory HabitatRepo backed by real Automerge +// --------------------------------------------------------------------------- + +class InMemoryDocHandle implements HabitatDocHandle { + constructor( + private uri: HabitatUri, + private store: Map>, + ) {} + + private get current(): Automerge.Doc { + return this.store.get(this.uri) as Automerge.Doc; + } + + doc(): Automerge.Doc { + return this.current; + } + + heads(): string[] { + return Automerge.getHeads(this.current); + } + + change(fn: (d: T) => void): void { + const next = Automerge.change(this.current, fn as Automerge.ChangeFn); + this.store.set(this.uri, next); + } + + changeAt(heads: string[], fn: (d: T) => void): void { + const result = Automerge.changeAt( + this.current, + heads, + fn as Automerge.ChangeFn, + ); + if (result.newHeads !== null) { + this.store.set(this.uri, result.newDoc); + } + } + + view(heads: string[]): Automerge.Doc { + return Automerge.view(this.current, heads) as Automerge.Doc; + } + + on(_event: 'change', _cb: () => void): () => void { + return () => {}; + } +} + +class InMemoryHabitatRepo implements HabitatRepo { + private store = new Map>(); + private counter = 0; + + private makeUri(collection: string): HabitatUri { + return `at://did:plc:test/${collection}/${++this.counter}` as HabitatUri; + } + + private handle(uri: HabitatUri): InMemoryDocHandle { + return new InMemoryDocHandle(uri, this.store); + } + + async find(uri: HabitatUri): Promise> { + if (!this.store.has(uri)) { + throw new Error(`[InMemoryHabitatRepo] doc not found: ${uri}`); + } + return this.handle(uri); + } + + async createDirectory( + initial: DirectoryDocument, + ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle }> { + const uri = this.makeUri('network.habitat.vault.directory'); + this.store.set(uri, Automerge.from(initial as unknown as Record)); + return { uri, handle: this.handle(uri) }; + } + + async createFile( + initial: FileDocument, + ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle }> { + const uri = this.makeUri('network.habitat.vault.file'); + this.store.set(uri, Automerge.from(initial as unknown as Record)); + return { uri, handle: this.handle(uri) }; + } + + async delete(uri: HabitatUri): Promise { + this.store.delete(uri); + } + + /** Test helper: directly read a doc from the store. */ + getDoc(uri: HabitatUri): Automerge.Doc | undefined { + return this.store.get(uri) as Automerge.Doc | undefined; + } + + /** Test helper: count docs in the store. */ + docCount(): number { + return this.store.size; + } +} + +// --------------------------------------------------------------------------- +// Test fixture helpers +// --------------------------------------------------------------------------- + +function makeRootDirectoryDoc(): DirectoryDocument { + return { '@patchwork': { type: 'folder' }, docs: [], name: 'root' }; +} + +function writeFile(dir: string, relPath: string, content: string): void { + const full = nodePath.join(dir, relPath); + nodeFs.mkdirSync(nodePath.dirname(full), { recursive: true }); + nodeFs.writeFileSync(full, content, 'utf-8'); +} + +function readFile(dir: string, relPath: string): string { + return nodeFs.readFileSync(nodePath.join(dir, relPath), 'utf-8'); +} + +function fileExists(dir: string, relPath: string): boolean { + return nodeFs.existsSync(nodePath.join(dir, relPath)); +} + +// --------------------------------------------------------------------------- +// Test suite +// --------------------------------------------------------------------------- + +describe('VaultSyncEngine', () => { + let tmpDir: string; + let repo: InMemoryHabitatRepo; + let rootUri: HabitatUri; + let vaultConfig: VaultConfig; + let engine: VaultSyncEngine; + let fs: NodeFilesystemApi; + + beforeEach(async () => { + tmpDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'textile-vault-test-')); + repo = new InMemoryHabitatRepo(); + + const { uri } = await repo.createDirectory(makeRootDirectoryDoc()); + rootUri = uri; + + vaultConfig = { + name: 'Test Vault', + type: 'synced', + rootUri, + createdAt: new Date().toISOString(), + }; + + fs = new NodeFilesystemApi(); + const snapshotManager = new VaultSnapshotManager(fs); + const detector = new VaultChangeDetector(fs, repo); + engine = new VaultSyncEngine(fs, repo, snapshotManager, detector); + }); + + afterEach(() => { + nodeFs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + // ------------------------------------------------------------------------- + // No-op cases + // ------------------------------------------------------------------------- + + it('returns zeros for a local vault without touching the filesystem', async () => { + const localConfig: VaultConfig = { ...vaultConfig, type: 'local' }; + const result = await engine.sync(localConfig, tmpDir); + expect(result).toEqual({ pushed: 0, pulled: 0, moves: 0 }); + expect(nodeFs.existsSync(nodePath.join(tmpDir, '.textile'))).toBe(false); + }); + + it('syncs an empty vault with no changes and saves a snapshot', async () => { + const result = await engine.sync(vaultConfig, tmpDir); + expect(result).toEqual({ pushed: 0, pulled: 0, moves: 0 }); + expect(fileExists(tmpDir, '.textile/snapshot.json')).toBe(true); + }); + + it('second sync with no changes is idempotent', async () => { + await engine.sync(vaultConfig, tmpDir); + const docCountAfterFirst = repo.docCount(); + + const result = await engine.sync(vaultConfig, tmpDir); + expect(result).toEqual({ pushed: 0, pulled: 0, moves: 0 }); + expect(repo.docCount()).toBe(docCountAfterFirst); + }); + + // ------------------------------------------------------------------------- + // Push: local → remote + // ------------------------------------------------------------------------- + + it('pushes a new root-level file to the repo', async () => { + writeFile(tmpDir, 'hello.md', '# Hello'); + + const result = await engine.sync(vaultConfig, tmpDir); + + expect(result.pushed).toBe(1); + expect(result.pulled).toBe(0); + + // Snapshot records the file + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); + const snapshot = JSON.parse(snapshotRaw); + const fileEntry = snapshot.files.find(([p]: [string]) => p === 'hello.md'); + expect(fileEntry).toBeDefined(); + + // Repo has a file doc for it + const fileUri = fileEntry[1].uri as HabitatUri; + const doc = repo.getDoc(fileUri); + expect(doc).toBeDefined(); + expect((doc as unknown as FileDocument).content).toBe('# Hello'); + }); + + it('pushes a new file inside a new subdirectory, creating directory docs', async () => { + writeFile(tmpDir, 'notes/ideas.md', 'some ideas'); + + const result = await engine.sync(vaultConfig, tmpDir); + + expect(result.pushed).toBe(1); + + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); + const snapshot = JSON.parse(snapshotRaw); + + // Both the file and its parent directory are in the snapshot + const fileEntry = snapshot.files.find(([p]: [string]) => p === 'notes/ideas.md'); + expect(fileEntry).toBeDefined(); + const dirEntry = snapshot.directories.find(([p]: [string]) => p === 'notes'); + expect(dirEntry).toBeDefined(); + }); + + it('pushes an edit to an existing file', async () => { + writeFile(tmpDir, 'doc.md', 'original'); + await engine.sync(vaultConfig, tmpDir); + + writeFile(tmpDir, 'doc.md', 'updated'); + const result = await engine.sync(vaultConfig, tmpDir); + + expect(result.pushed).toBe(1); + + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); + const snapshot = JSON.parse(snapshotRaw); + const fileEntry = snapshot.files.find(([p]: [string]) => p === 'doc.md'); + const fileUri = fileEntry[1].uri as HabitatUri; + const doc = repo.getDoc(fileUri); + expect((doc as unknown as FileDocument).content).toBe('updated'); + }); + + it('removes a deleted file from the snapshot', async () => { + writeFile(tmpDir, 'gone.md', 'bye'); + await engine.sync(vaultConfig, tmpDir); + + nodeFs.rmSync(nodePath.join(tmpDir, 'gone.md')); + const result = await engine.sync(vaultConfig, tmpDir); + + expect(result.pushed).toBe(1); + + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); + const snapshot = JSON.parse(snapshotRaw); + const fileEntry = snapshot.files.find(([p]: [string]) => p === 'gone.md'); + expect(fileEntry).toBeUndefined(); + }); + + // ------------------------------------------------------------------------- + // Move detection + // ------------------------------------------------------------------------- + + it('detects a rename as a move, reusing the existing doc URI', async () => { + writeFile(tmpDir, 'original.md', 'content that is long enough for similarity'); + await engine.sync(vaultConfig, tmpDir); + + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); + const snapshot = JSON.parse(snapshotRaw); + const originalUri = snapshot.files.find(([p]: [string]) => p === 'original.md')[1].uri; + + // Rename: remove old, create new with same content + nodeFs.rmSync(nodePath.join(tmpDir, 'original.md')); + writeFile(tmpDir, 'renamed.md', 'content that is long enough for similarity'); + + const result = await engine.sync(vaultConfig, tmpDir); + + expect(result.moves).toBe(1); + + const snapshotRaw2 = readFile(tmpDir, '.textile/snapshot.json'); + const snapshot2 = JSON.parse(snapshotRaw2); + const renamedEntry = snapshot2.files.find(([p]: [string]) => p === 'renamed.md'); + expect(renamedEntry).toBeDefined(); + // Same URI as the original — doc was updated in place, not recreated + expect(renamedEntry[1].uri).toBe(originalUri); + // Old path is gone + expect(snapshot2.files.find(([p]: [string]) => p === 'original.md')).toBeUndefined(); + }); + + // ------------------------------------------------------------------------- + // First-open clone: remote → local (one time only) + // ------------------------------------------------------------------------- + + it('clones a pre-existing remote file to the filesystem on first sync', async () => { + // Seed a remote file + directory entry BEFORE the first sync (no snapshot). + const { uri: fileUri } = await repo.createFile({ + '@patchwork': { type: 'file' }, + name: 'remote.md', + extension: 'md', + mimeType: 'text/markdown', + content: 'from remote', + metadata: { permissions: 0o644 }, + }); + const rootHandle = await repo.find(rootUri); + rootHandle.change((d) => { + (d as unknown as DirectoryDocument).docs.push({ + name: 'remote.md', + type: 'md', + url: fileUri, + }); + }); + + const result = await engine.sync(vaultConfig, tmpDir); + + expect(result.pulled).toBe(1); + expect(fileExists(tmpDir, 'remote.md')).toBe(true); + expect(readFile(tmpDir, 'remote.md')).toBe('from remote'); + }); + + it('does not pull remote edits to tracked files during steady-state sync', async () => { + // Establish the file both locally and in the snapshot (first sync clones + // nothing; this push creates the doc + snapshot entry). + writeFile(tmpDir, 'shared.md', 'v1'); + await engine.sync(vaultConfig, tmpDir); + + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); + const snapshot = JSON.parse(snapshotRaw); + const fileUri = snapshot.files.find(([p]: [string]) => p === 'shared.md')[1].uri as HabitatUri; + + // Remote edit lands on the doc. + const handle = await repo.find(fileUri); + handle.change((d) => { + (d as unknown as FileDocument).content = 'v2 from remote'; + }); + + // A steady-state sync must not walk/pull — disk stays at v1. + const result = await engine.sync(vaultConfig, tmpDir); + + expect(result.pulled).toBe(0); + expect(readFile(tmpDir, 'shared.md')).toBe('v1'); + }); + + // ------------------------------------------------------------------------- + // Per-file pull on open + // ------------------------------------------------------------------------- + + it('openFile pulls the merged remote content to disk and advances the snapshot head', async () => { + writeFile(tmpDir, 'shared.md', 'v1'); + await engine.sync(vaultConfig, tmpDir); + + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); + const snapshot = JSON.parse(snapshotRaw); + const fileUri = snapshot.files.find(([p]: [string]) => p === 'shared.md')[1].uri as HabitatUri; + + const handle = await repo.find(fileUri); + handle.change((d) => { + (d as unknown as FileDocument).content = 'v2 from remote'; + }); + const remoteHeads = handle.heads(); + + await engine.openFile(vaultConfig, tmpDir, 'shared.md'); + + expect(readFile(tmpDir, 'shared.md')).toBe('v2 from remote'); + + const snapshotRaw2 = readFile(tmpDir, '.textile/snapshot.json'); + const snapshot2 = JSON.parse(snapshotRaw2); + const entry = snapshot2.files.find(([p]: [string]) => p === 'shared.md')[1]; + expect(entry.head).toEqual(remoteHeads); + }); + + it('openFile is a no-op for an untracked file', async () => { + await engine.sync(vaultConfig, tmpDir); + await engine.openFile(vaultConfig, tmpDir, 'never-seen.md'); + expect(fileExists(tmpDir, 'never-seen.md')).toBe(false); + }); + + // ------------------------------------------------------------------------- + // Per-file push (save/edit events) + // ------------------------------------------------------------------------- + + it('pushFile creates a new file doc and registers it in the root directory', async () => { + await engine.sync(vaultConfig, tmpDir); // establish snapshot + const docCountBefore = repo.docCount(); + + writeFile(tmpDir, 'note.md', '# Note'); + await engine.pushFile(vaultConfig, tmpDir, 'note.md'); + + // A file doc was created. + expect(repo.docCount()).toBe(docCountBefore + 1); + + const snapshot = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); + const fileEntry = snapshot.files.find(([p]: [string]) => p === 'note.md'); + expect(fileEntry).toBeDefined(); + const fileUri = fileEntry[1].uri as HabitatUri; + expect((repo.getDoc(fileUri) as unknown as FileDocument).content).toBe('# Note'); + + // The root directory doc references it. + const rootDoc = repo.getDoc(rootUri) as unknown as DirectoryDocument; + expect(rootDoc.docs.some((e) => e.url === fileUri)).toBe(true); + }); + + it('pushFile updates an existing file in place without recreating the doc', async () => { + writeFile(tmpDir, 'doc.md', 'v1'); + await engine.sync(vaultConfig, tmpDir); + + const snap1 = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); + const uriBefore = snap1.files.find(([p]: [string]) => p === 'doc.md')[1].uri; + const docCountBefore = repo.docCount(); + + writeFile(tmpDir, 'doc.md', 'v2'); + await engine.pushFile(vaultConfig, tmpDir, 'doc.md'); + + expect(repo.docCount()).toBe(docCountBefore); // no new doc + const snap2 = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); + const entry = snap2.files.find(([p]: [string]) => p === 'doc.md')[1]; + expect(entry.uri).toBe(uriBefore); // same doc, edited in place + expect((repo.getDoc(uriBefore) as unknown as FileDocument).content).toBe('v2'); + }); + + it('pushFile is a no-op when the file content is unchanged', async () => { + writeFile(tmpDir, 'doc.md', 'same'); + await engine.sync(vaultConfig, tmpDir); + + const snapBefore = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); + const headBefore = snapBefore.files.find(([p]: [string]) => p === 'doc.md')[1].head; + + await engine.pushFile(vaultConfig, tmpDir, 'doc.md'); + + const snapAfter = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); + const headAfter = snapAfter.files.find(([p]: [string]) => p === 'doc.md')[1].head; + expect(headAfter).toEqual(headBefore); // doc was not touched + }); + + it('pushFile creates intermediate directory docs for a nested new file', async () => { + await engine.sync(vaultConfig, tmpDir); + + writeFile(tmpDir, 'notes/ideas.md', 'some ideas'); + await engine.pushFile(vaultConfig, tmpDir, 'notes/ideas.md'); + + const snapshot = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); + expect(snapshot.files.find(([p]: [string]) => p === 'notes/ideas.md')).toBeDefined(); + const dirEntry = snapshot.directories.find(([p]: [string]) => p === 'notes'); + expect(dirEntry).toBeDefined(); + + // Parent dir doc references the file; root dir doc references the subdir. + const dirUri = dirEntry[1].uri as HabitatUri; + const dirDoc = repo.getDoc(dirUri) as unknown as DirectoryDocument; + expect(dirDoc.docs.some((e) => e.name === 'ideas.md')).toBe(true); + const rootDoc = repo.getDoc(rootUri) as unknown as DirectoryDocument; + expect(rootDoc.docs.some((e) => e.url === dirUri)).toBe(true); + }); + + it('pushFile is a no-op for a local vault', async () => { + const localConfig: VaultConfig = { ...vaultConfig, type: 'local' }; + writeFile(tmpDir, 'note.md', 'x'); + await engine.pushFile(localConfig, tmpDir, 'note.md'); + expect(fileExists(tmpDir, '.textile/snapshot.json')).toBe(false); + expect(repo.docCount()).toBe(1); // only the root dir from beforeEach + }); + + // ------------------------------------------------------------------------- + // Snapshot correctness + // ------------------------------------------------------------------------- + + it('snapshot head matches the repo doc heads after a push', async () => { + writeFile(tmpDir, 'tracked.md', 'hello'); + await engine.sync(vaultConfig, tmpDir); + + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); + const snapshot = JSON.parse(snapshotRaw); + const fileEntry = snapshot.files.find(([p]: [string]) => p === 'tracked.md')[1]; + const fileUri = fileEntry.uri as HabitatUri; + + const handle = await repo.find(fileUri); + expect(fileEntry.head).toEqual(handle.heads()); + }); + + it('snapshot contentHash matches the actual file content after a push', async () => { + writeFile(tmpDir, 'tracked.md', 'hello world'); + await engine.sync(vaultConfig, tmpDir); + + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); + const snapshot = JSON.parse(snapshotRaw); + const fileEntry = snapshot.files.find(([p]: [string]) => p === 'tracked.md')[1]; + + // Compute expected hash the same way the engine does + const { createHash } = await import('crypto'); + const expected = 'sha256:' + createHash('sha256').update('hello world', 'utf8').digest('hex'); + expect(fileEntry.contentHash).toBe(expected); + }); +}); diff --git a/src/vault/engine.ts b/src/vault/engine.ts new file mode 100644 index 0000000..8e94198 --- /dev/null +++ b/src/vault/engine.ts @@ -0,0 +1,522 @@ +import type { FilesystemApi } from '../filesystem/types'; +import { join, basename, dirname } from '../filesystem/vaultFs'; +import { VaultChangeDetector } from './change-detection'; +import { detectMoves } from './move-detection'; +import type { VaultSnapshotManager } from './snapshot'; +import { contentHash } from './utils/content'; +import { readDocContent } from './utils/text-diff'; +import type { + DetectedChange, + DirectoryDocument, + DirectoryEntry, + FileDocument, + HabitatRepo, + HabitatUri, + MoveCandidate, + SnapshotDirectoryEntry, + SnapshotFileEntry, + SyncSnapshot, + VaultConfig, +} from './types'; + +export interface SyncResult { + pushed: number; + /** Files written to disk. Non-zero only on the first-open clone. */ + pulled: number; + moves: number; +} + +// Pure snapshot mutation helpers — return new snapshots rather than mutating. + +function snapshotWithFile( + snap: SyncSnapshot, + path: string, + entry: SnapshotFileEntry, +): SyncSnapshot { + const files = new Map(snap.files); + files.set(path, entry); + return { ...snap, timestamp: Date.now(), files }; +} + +function snapshotWithRemovedFile(snap: SyncSnapshot, path: string): SyncSnapshot { + const files = new Map(snap.files); + files.delete(path); + return { ...snap, timestamp: Date.now(), files }; +} + +function snapshotWithDirectory( + snap: SyncSnapshot, + path: string, + entry: SnapshotDirectoryEntry, +): SyncSnapshot { + const directories = new Map(snap.directories); + directories.set(path, entry); + return { ...snap, timestamp: Date.now(), directories }; +} + +function extname(name: string): string { + const idx = name.lastIndexOf('.'); + return idx > 0 ? name.substring(idx + 1) : ''; +} + +function pathDepth(p: string): number { + if (p === '/') return 0; + return p.split('/').length; +} + +export class VaultSyncEngine { + constructor( + private fs: FilesystemApi, + private repo: HabitatRepo, + private snapshotManager: VaultSnapshotManager, + private detector: VaultChangeDetector, + ) {} + + async sync(vault: VaultConfig, rootPath: string): Promise { + if (vault.type === 'local') { + return { pushed: 0, pulled: 0, moves: 0 }; + } + + // First sync (no snapshot on disk) does a one-time clone of the remote + // tree; every subsequent sync is push-only. Remote edits to already-cloned + // files are merged when the file is opened (see `openFile`) or live over + // libp2p — never by walking the remote tree here. + const existing = await this.snapshotManager.load(rootPath); + const firstSync = existing === null; + let snap = existing ?? this.snapshotManager.createEmpty(vault.rootUri); + + let pulled = 0; + if (firstSync) { + const remote = await this.detector.scanRemoteTree(vault.rootUri, snap); + ({ snap, pulled } = await this.pull(rootPath, snap, remote)); + } + + // Push local changes (on first sync this also uploads any pre-existing + // local files, since the snapshot started empty). + const localChanges = await this.detector.detectLocal(rootPath, snap); + + const { moves, remainingChanges } = await detectMoves( + localChanges, + async (path) => { + // Deleted files no longer exist on disk — fall back to the last known + // content from the repo so move detection can compare similarity. + try { + return await this.fs.readFile(join(rootPath, path)); + } catch { + const entry = snap.files.get(path); + if (entry) { + try { + const handle = await this.repo.find(entry.uri); + return readDocContent(handle.doc() as unknown as FileDocument); + } catch { + return null; + } + } + return null; + } + }, + ); + + const { snap: pushedSnap, pushed, moved } = await this.push( + rootPath, + vault.rootUri, + snap, + remainingChanges, + moves, + ); + snap = pushedSnap; + + await this.snapshotManager.save(rootPath, snap); + + return { pushed, pulled, moves: moved }; + } + + /** + * Per-file pull invoked when a file is opened. `repo.find` loads the doc + * through the live session (merging PDS + libp2p state), so this writes the + * latest merged content back to disk and refreshes the snapshot entry. + * No-op for untracked files — remote discovery is handled by the clone. + */ + async openFile( + vault: VaultConfig, + rootPath: string, + relPath: string, + ): Promise { + if (vault.type === 'local') return; + + let snap = + (await this.snapshotManager.load(rootPath)) ?? + this.snapshotManager.createEmpty(vault.rootUri); + + const entry = snap.files.get(relPath); + if (!entry) return; + + snap = await this.pullFile( + rootPath, + { + type: 'edit', + path: relPath, + isDirectory: false, + uri: entry.uri, + }, + snap, + ); + + await this.snapshotManager.save(rootPath, snap); + } + + /** + * Push a single file (add or edit) without scanning the whole tree. Intended + * for save/edit events. Determines add-vs-edit from the snapshot, skips when + * the on-disk content is unchanged, and — for new files only — registers the + * file in its parent directory doc(s) so other clients can discover it. + * Deletes and renames still go through `sync` (they need tree-wide context). + */ + async pushFile( + vault: VaultConfig, + rootPath: string, + relPath: string, + ): Promise { + if (vault.type === 'local') return; + + let snap = + (await this.snapshotManager.load(rootPath)) ?? + this.snapshotManager.createEmpty(vault.rootUri); + + const content = await this.fs.readFile(join(rootPath, relPath)); + const hash = contentHash(content); + const existing = snap.files.get(relPath); + + // Nothing changed since the last push — skip the network round-trip. + if (existing && existing.contentHash === hash) return; + + const isAdd = !existing; + snap = await this.pushFileChange( + rootPath, + { + type: isAdd ? 'add' : 'edit', + path: relPath, + isDirectory: false, + uri: existing?.uri, + contentHash: hash, + }, + snap, + ); + + // A new file needs a directory entry so it's discoverable; edits don't + // change the listing, so skip the directory rewrite for them. + if (isAdd) { + snap = await this.updateParentDirectories([relPath], vault.rootUri, snap); + } + + await this.snapshotManager.save(rootPath, snap); + } + + // --------------------------------------------------------------------------- + // Push phase + // --------------------------------------------------------------------------- + + private async push( + rootPath: string, + rootUri: HabitatUri, + snap: SyncSnapshot, + changes: DetectedChange[], + moves: MoveCandidate[], + ): Promise<{ snap: SyncSnapshot; pushed: number; moved: number }> { + const affectedPaths: string[] = []; + + // 1. Moves — update existing doc in place to preserve CRDT history. + for (const move of moves) { + const content = await this.fs.readFile(join(rootPath, move.newPath)); + const oldEntry = snap.files.get(move.oldPath); + if (oldEntry) { + const handle = await this.repo.find(oldEntry.uri); + const ext = extname(move.newPath); + const newName = basename(move.newPath); + handle.changeAt(oldEntry.head, (d) => { + const fd = d as unknown as FileDocument; + fd.content = content; + fd.name = newName; + fd.extension = ext; + }); + snap = snapshotWithRemovedFile(snap, move.oldPath); + snap = snapshotWithFile(snap, move.newPath, { + uri: oldEntry.uri, + head: handle.heads(), + contentHash: contentHash(content), + }); + affectedPaths.push(move.oldPath, move.newPath); + } + } + + // 2. File adds and edits. + const fileChanges = changes.filter( + (c) => (c.type === 'add' || c.type === 'edit') && !c.isDirectory, + ); + for (const change of fileChanges) { + snap = await this.pushFileChange(rootPath, change, snap); + affectedPaths.push(change.path); + } + + // 3. File deletes. + const fileDeletes = changes.filter( + (c) => c.type === 'delete' && !c.isDirectory, + ); + for (const change of fileDeletes) { + snap = await this.deleteRemoteFile(change, snap); + affectedPaths.push(change.path); + } + + const pushed = fileChanges.length + fileDeletes.length; + const moved = moves.length; + + // 4. Update parent directories depth-first. + if (affectedPaths.length > 0) { + snap = await this.updateParentDirectories(affectedPaths, rootUri, snap); + } + + await this.snapshotManager.save(rootPath, snap); + return { snap, pushed, moved }; + } + + private async pushFileChange( + rootPath: string, + change: DetectedChange, + snap: SyncSnapshot, + ): Promise { + const content = await this.fs.readFile(join(rootPath, change.path)); + const hash = contentHash(content); + const name = basename(change.path); + + if (change.type === 'add') { + const { uri, handle } = await this.repo.createFile( + this.buildFileDocument(name, content), + ); + return snapshotWithFile(snap, change.path, { + uri, + head: handle.heads(), + contentHash: hash, + }); + } + + // edit + const entry = snap.files.get(change.path); + if (!entry) { + throw new Error(`[VaultEngine] no snapshot entry for edited file: ${change.path}`); + } + const handle = await this.repo.find(entry.uri); + handle.changeAt(entry.head, (d) => { + (d as unknown as FileDocument).content = content; + }); + return snapshotWithFile(snap, change.path, { + uri: entry.uri, + head: handle.heads(), + contentHash: hash, + }); + } + + private async deleteRemoteFile( + change: DetectedChange, + snap: SyncSnapshot, + ): Promise { + if (change.uri) { + await this.repo.delete(change.uri); + } + return snapshotWithRemovedFile(snap, change.path); + } + + // --------------------------------------------------------------------------- + // Directory update helpers + // --------------------------------------------------------------------------- + + private async updateParentDirectories( + affectedPaths: string[], + rootUri: HabitatUri, + snap: SyncSnapshot, + ): Promise { + const dirSet = new Set(); + + for (const path of affectedPaths) { + let d = dirname(path); + while (true) { + dirSet.add(d); + if (d === '/') break; + d = dirname(d); + } + } + + // Deepest first — children before parents. + const sorted = Array.from(dirSet).sort( + (a, b) => pathDepth(b) - pathDepth(a), + ); + + for (const dir of sorted) { + snap = await this.updateOneDirectory(dir, rootUri, snap); + } + + return snap; + } + + private async updateOneDirectory( + dirRelPath: string, + rootUri: HabitatUri, + snap: SyncSnapshot, + ): Promise { + const isRoot = dirRelPath === '/'; + const dirEntry = isRoot ? null : snap.directories.get(dirRelPath); + let dirUri: HabitatUri | undefined = isRoot ? rootUri : dirEntry?.uri; + + if (!dirUri) { + // New directory that doesn't yet exist remotely. + const name = basename(dirRelPath); + const { uri: newUri, handle } = await this.repo.createDirectory( + this.buildDirectoryDocument(name), + ); + dirUri = newUri; + snap = snapshotWithDirectory(snap, dirRelPath, { + uri: newUri, + head: handle.heads(), + }); + } + + const handle = await this.repo.find(dirUri); + const existingHeads = dirEntry?.head ?? handle.heads(); + const expected = this.buildExpectedEntries(dirRelPath, snap); + + handle.changeAt(existingHeads, (d) => { + const docs = d.docs as unknown as DirectoryEntry[]; + docs.splice(0, docs.length); + for (const e of expected) { + docs.push(e); + } + (d as unknown as DirectoryDocument).lastSyncAt = Date.now(); + }); + + // Root is always known via vault.rootUri — no need to track it in the + // snapshot. Storing '/' causes dirname('/') === '/' which makes root + // appear as its own child in buildExpectedEntries. + if (isRoot) { + return snap; + } + + return snapshotWithDirectory(snap, dirRelPath, { + uri: dirUri, + head: handle.heads(), + }); + } + + private buildExpectedEntries( + dirRelPath: string, + snap: SyncSnapshot, + ): DirectoryEntry[] { + const entries: DirectoryEntry[] = []; + + for (const [filePath, fileEntry] of snap.files) { + if (dirname(filePath) === dirRelPath) { + const name = basename(filePath); + const ext = extname(name) || 'md'; + entries.push({ name, type: ext, url: fileEntry.uri }); + } + } + + for (const [subdirPath, subdirEntry] of snap.directories) { + if (dirname(subdirPath) === dirRelPath) { + entries.push({ + name: basename(subdirPath), + type: 'folder', + url: subdirEntry.uri, + }); + } + } + + return entries; + } + + // --------------------------------------------------------------------------- + // Pull phase + // --------------------------------------------------------------------------- + + private async pull( + rootPath: string, + snap: SyncSnapshot, + remoteChanges: DetectedChange[], + ): Promise<{ snap: SyncSnapshot; pulled: number }> { + let pulled = 0; + + for (const change of remoteChanges) { + if (change.isDirectory) { + const fullPath = join(rootPath, change.path); + try { + await this.fs.mkdir(fullPath); + } catch { + // Directory may already exist. + } + if (change.uri && change.head) { + snap = snapshotWithDirectory(snap, change.path, { + uri: change.uri, + head: change.head, + }); + } + pulled++; + } else { + snap = await this.pullFile(rootPath, change, snap); + pulled++; + } + } + + return { snap, pulled }; + } + + private async pullFile( + rootPath: string, + change: DetectedChange, + snap: SyncSnapshot, + ): Promise { + if (!change.uri) return snap; + + const handle = await this.repo.find(change.uri); + const content = readDocContent(handle.doc() as unknown as FileDocument); + const fullPath = join(rootPath, change.path); + const parentDir = dirname(fullPath); + + if (parentDir && parentDir !== '/') { + const parentExists = await this.fs.fileExists(parentDir); + if (!parentExists) { + await this.fs.mkdir(parentDir); + } + } + + await this.fs.writeFile(fullPath, content); + + return snapshotWithFile(snap, change.path, { + uri: change.uri, + head: handle.heads(), + contentHash: contentHash(content), + }); + } + + // --------------------------------------------------------------------------- + // Document builders + // --------------------------------------------------------------------------- + + private buildFileDocument(name: string, content: string): FileDocument { + const ext = extname(name); + const mimeType = ext === 'md' ? 'text/markdown' : 'text/plain'; + return { + '@patchwork': { type: 'file' }, + name, + extension: ext, + mimeType, + content, + metadata: { permissions: 0o644 }, + }; + } + + private buildDirectoryDocument(name: string): DirectoryDocument { + return { + '@patchwork': { type: 'folder' }, + docs: [], + name, + }; + } +} diff --git a/src/vault/habitat-repo.ts b/src/vault/habitat-repo.ts index ba7782b..d539080 100644 --- a/src/vault/habitat-repo.ts +++ b/src/vault/habitat-repo.ts @@ -1,5 +1,4 @@ import * as Automerge from '@automerge/automerge'; - import type { HabitatDocHandle, HabitatRepo, @@ -13,6 +12,7 @@ import { HABITAT_VAULT_DIRECTORY_COLLECTION, HABITAT_VAULT_FILE_COLLECTION, } from '../habitat/config'; +import { createAutomergeDoc } from '../habitat/automergeDoc'; /** * Adapts an AutomergeDocSession into a HabitatDocHandle. @@ -58,6 +58,8 @@ class HabitatDocHandleImpl implements HabitatDocHandle { * File documents are stored in `network.habitat.vault.file` */ export class HabitatRepoImpl implements HabitatRepo { + constructor(private myDid: string) {} + async find(uri: HabitatUri): Promise> { const session = acquireDocSession(uri); await session.loadPromise; @@ -76,20 +78,19 @@ export class HabitatRepoImpl implements HabitatRepo { return this.create(initial, HABITAT_VAULT_FILE_COLLECTION); } - async create( + private async create( initial: T, collection: string, ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle }> { - // Create a new empty Automerge doc with the initial value - const doc = Automerge.from(initial as Record); - // TODO: Save the doc to Habitat PDS and get a URI - // For now, this is a stub that returns a placeholder URI - const uri = `at://did:plc:placeholder/${collection}/rkey-placeholder` as HabitatUri; - const session = acquireDocSession(uri); - // Force the session to use our new doc - // This is a simplified implementation; in reality we'd need to - // create the Habitat record first, then acquire the session. - return { uri, handle: new HabitatDocHandleImpl(session) }; + const { uri } = await createAutomergeDoc( + this.myDid, + initial as unknown as Record, + collection, + ); + const habitatUri = uri as HabitatUri; + const session = acquireDocSession(habitatUri); + await session.loadPromise; + return { uri: habitatUri, handle: new HabitatDocHandleImpl(session) }; } async delete(uri: HabitatUri): Promise { diff --git a/src/vault/move-detection.ts b/src/vault/move-detection.ts new file mode 100644 index 0000000..4591109 --- /dev/null +++ b/src/vault/move-detection.ts @@ -0,0 +1,102 @@ +import type { DetectedChange, MoveCandidate } from './types'; +import { stringSimilarity } from './utils/string-similarity'; + +const DEFAULT_THRESHOLD = 0.7; +const LARGE_FILE_BYTES = 4096; +const SAMPLE_WINDOW = 100; + +export interface MoveDetectionResult { + moves: MoveCandidate[]; + remainingChanges: DetectedChange[]; +} + +/** + * Detect renames/moves by pairing local deletions with local additions + * that have sufficiently similar content (Sørensen–Dice ≥ threshold). + * + * @param changes - All locally detected changes. + * @param getContent - Async getter for current FS content. Returns null for + * unreadable / binary files (those are skipped as non-movable). + * @param threshold - Minimum similarity score. Default 0.7. + */ +export async function detectMoves( + changes: DetectedChange[], + getContent: (path: string) => Promise, + threshold = DEFAULT_THRESHOLD, +): Promise { + const deletions = changes.filter((c) => c.type === 'delete' && !c.isDirectory); + const additions = changes.filter((c) => c.type === 'add' && !c.isDirectory); + const others = changes.filter( + (c) => (c.type !== 'delete' && c.type !== 'add') || c.isDirectory, + ); + + const deleteContents = new Map(); + const addContents = new Map(); + + await Promise.all([ + ...deletions.map(async (c) => { + deleteContents.set(c.path, await getContent(c.path).catch(() => null)); + }), + ...additions.map(async (c) => { + addContents.set(c.path, await getContent(c.path).catch(() => null)); + }), + ]); + + const claimed = new Set(); + const moves: MoveCandidate[] = []; + const matchedDeletions = new Set(); + + for (const deletion of deletions) { + const delContent = deleteContents.get(deletion.path); + if (delContent === null || delContent === undefined) continue; + + let bestScore = 0; + let bestAddition: DetectedChange | null = null; + + for (const addition of additions) { + if (claimed.has(addition.path)) continue; + const addContent = addContents.get(addition.path); + if (addContent === null || addContent === undefined) continue; + + const score = computeSimilarity(delContent, addContent); + if (score > bestScore) { + bestScore = score; + bestAddition = addition; + } + } + + if (bestScore >= threshold && bestAddition) { + moves.push({ + oldPath: deletion.path, + newPath: bestAddition.path, + similarity: bestScore, + }); + claimed.add(bestAddition.path); + matchedDeletions.add(deletion.path); + } + } + + const remainingChanges: DetectedChange[] = [ + ...others, + ...deletions.filter((d) => !matchedDeletions.has(d.path)), + ...additions.filter((a) => !claimed.has(a.path)), + ]; + + return { moves, remainingChanges }; +} + +function computeSimilarity(a: string, b: string): number { + if (a.length > LARGE_FILE_BYTES || b.length > LARGE_FILE_BYTES) { + const len = Math.min(a.length, b.length); + const scores = [0.25, 0.5, 0.75].map((pos) => { + const start = Math.floor(pos * len); + const end = Math.min(start + SAMPLE_WINDOW, len); + const wa = a.slice(start, end); + const wb = b.slice(start, end); + if (wa.length < 2 || wb.length < 2) return 0; + return stringSimilarity(wa, wb); + }); + return scores.reduce((sum, s) => sum + s, 0) / scores.length; + } + return stringSimilarity(a, b); +} diff --git a/src/vault/snapshot.ts b/src/vault/snapshot.ts index 72ac4aa..833573b 100644 --- a/src/vault/snapshot.ts +++ b/src/vault/snapshot.ts @@ -7,7 +7,8 @@ import type { } from './types'; import type { FilesystemApi } from '../filesystem/types'; -const SNAPSHOT_FILE = '.textile/snapshot.json'; +const TEXTILE_DIR = '.textile'; +const SNAPSHOT_FILE = `${TEXTILE_DIR}/snapshot.json`; /** * Manages `.textile/snapshot.json` for synced vaults. @@ -18,26 +19,28 @@ export class VaultSnapshotManager { /** * Load the snapshot from disk at the given vault root. - * Returns `null` if no snapshot exists. + * Returns `null` if no snapshot exists yet. */ async load(rootPath: string): Promise { - const snapshotPath = `${rootPath}/${SNAPSHOT_FILE}`; try { - const raw = await this.fs.readFile(snapshotPath); + const raw = await this.fs.readFile(`${rootPath}/${SNAPSHOT_FILE}`); return this.deserialize(raw); } catch { - // Snapshot doesn't exist yet return null; } } /** * Save the snapshot to disk at the given vault root. + * Creates `.textile/` if it does not exist. */ async save(rootPath: string, snapshot: SyncSnapshot): Promise { - const snapshotPath = `${rootPath}/${SNAPSHOT_FILE}`; + const textileDir = `${rootPath}/${TEXTILE_DIR}`; + if (!(await this.fs.fileExists(textileDir))) { + await this.fs.mkdir(textileDir); + } const json = this.serialize(snapshot); - await this.fs.writeFile(snapshotPath, json); + await this.fs.writeFile(`${rootPath}/${SNAPSHOT_FILE}`, json); } /** diff --git a/src/vault/types.ts b/src/vault/types.ts index ddd06d0..d128754 100644 --- a/src/vault/types.ts +++ b/src/vault/types.ts @@ -94,7 +94,7 @@ export function isFileEntry(entry: { type: unknown }): boolean { /** * Change type classification for sync operations. */ -export type ChangeType = 'add' | 'edit' | 'delete' | 'move' | 'conflict'; +export type ChangeType = 'add' | 'edit' | 'delete' | 'move'; /** * Represents a detected change during three-way comparison -- 2.51.2