A local-first note taking app
0

Configure Feed

Select the types of activity you want to include in your feed.

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 <noreply@anthropic.com>

Ethan Graf (Jun 18, 2026, 10:37 PM EDT) 0faef712 4fe1affc

+1409 -20
+5
src/App.tsx
··· 258 258 const name = vaultPath.split('/').pop() || vaultPath; 259 259 const providerId = `vault-${id}`; 260 260 addOpenVault({ id, path: vaultPath, type: vaultType, name, did }); 261 + // TODO: On first mount of a synced vault, run a top-level 262 + // VaultSyncEngine.sync() once — it performs the one-time remote clone and 263 + // reconciles any out-of-band changes (renames/deletes) made while the app 264 + // was closed. Steady-state edits should then go through 265 + // VaultSyncEngine.pushFile/openFile rather than full syncs. 261 266 // Auto-switch to the newly created vault — same path as the selector. 262 267 setSelectedProviderId(providerId); 263 268 },
+207
src/vault/change-detection.ts
··· 1 + import type { FilesystemApi } from '../filesystem/types'; 2 + import { join, basename, dirname } from '../filesystem/vaultFs'; 3 + import { contentHash } from './utils/content'; 4 + import type { 5 + DetectedChange, 6 + DirectoryDocument, 7 + HabitatRepo, 8 + HabitatUri, 9 + SyncSnapshot, 10 + } from './types'; 11 + import { isFolderEntry } from './types'; 12 + 13 + export class VaultChangeDetector { 14 + constructor( 15 + private fs: FilesystemApi, 16 + private repo: HabitatRepo, 17 + ) {} 18 + 19 + /** 20 + * Steady-state detection: local filesystem vs snapshot only. No network. 21 + * Returns local file and directory changes (adds/edits/deletes) to push. 22 + */ 23 + async detectLocal( 24 + rootPath: string, 25 + snapshot: SyncSnapshot, 26 + ): Promise<DetectedChange[]> { 27 + const [files, dirs] = await Promise.all([ 28 + this.scanLocalFiles(rootPath, snapshot), 29 + this.scanLocalDirs(rootPath, snapshot), 30 + ]); 31 + return [...files, ...dirs]; 32 + } 33 + 34 + /** 35 + * First-open clone only: walk the entire remote tree and return every entry 36 + * not yet in the snapshot as an "add". Expensive (one `repo.find` per 37 + * directory) — only run once, when a vault has no local snapshot yet. 38 + */ 39 + async scanRemoteTree( 40 + rootUri: HabitatUri, 41 + snapshot: SyncSnapshot, 42 + ): Promise<DetectedChange[]> { 43 + return this.walkRemoteTree(rootUri, '', snapshot); 44 + } 45 + 46 + private async walkFilesystem( 47 + rootPath: string, 48 + relDir: string, 49 + ): Promise<string[]> { 50 + const base = relDir ? join(rootPath, relDir) : rootPath; 51 + const entries = await this.fs.readdir(base); 52 + const result: string[] = []; 53 + for (const entry of entries) { 54 + if (entry.name.startsWith('.')) continue; 55 + const relPath = relDir ? join(relDir, entry.name) : entry.name; 56 + if (entry.isDirectory) { 57 + result.push(...(await this.walkFilesystem(rootPath, relPath))); 58 + } else if (entry.isFile) { 59 + result.push(relPath); 60 + } 61 + } 62 + return result; 63 + } 64 + 65 + private async walkFilesystemDirs( 66 + rootPath: string, 67 + relDir: string, 68 + ): Promise<string[]> { 69 + const base = relDir ? join(rootPath, relDir) : rootPath; 70 + const entries = await this.fs.readdir(base); 71 + const result: string[] = []; 72 + for (const entry of entries) { 73 + if (entry.name.startsWith('.')) continue; 74 + if (entry.isDirectory) { 75 + const relPath = relDir ? join(relDir, entry.name) : entry.name; 76 + result.push(relPath); 77 + result.push(...(await this.walkFilesystemDirs(rootPath, relPath))); 78 + } 79 + } 80 + return result; 81 + } 82 + 83 + private async scanLocalFiles( 84 + rootPath: string, 85 + snapshot: SyncSnapshot, 86 + ): Promise<DetectedChange[]> { 87 + const fsFiles = await this.walkFilesystem(rootPath, ''); 88 + const changes: DetectedChange[] = []; 89 + 90 + for (const relPath of fsFiles) { 91 + const content = await this.fs.readFile(join(rootPath, relPath)); 92 + const hash = contentHash(content); 93 + 94 + if (snapshot.files.has(relPath)) { 95 + const entry = snapshot.files.get(relPath)!; 96 + if (entry.contentHash !== hash) { 97 + changes.push({ 98 + type: 'edit', 99 + path: relPath, 100 + isDirectory: false, 101 + uri: entry.uri, 102 + contentHash: hash, 103 + }); 104 + } 105 + } else { 106 + changes.push({ 107 + type: 'add', 108 + path: relPath, 109 + isDirectory: false, 110 + contentHash: hash, 111 + }); 112 + } 113 + } 114 + 115 + for (const [relPath, entry] of snapshot.files) { 116 + if (!fsFiles.includes(relPath)) { 117 + changes.push({ 118 + type: 'delete', 119 + path: relPath, 120 + isDirectory: false, 121 + uri: entry.uri, 122 + }); 123 + } 124 + } 125 + 126 + return changes; 127 + } 128 + 129 + private async scanLocalDirs( 130 + rootPath: string, 131 + snapshot: SyncSnapshot, 132 + ): Promise<DetectedChange[]> { 133 + const fsDirs = await this.walkFilesystemDirs(rootPath, ''); 134 + const changes: DetectedChange[] = []; 135 + 136 + for (const relPath of fsDirs) { 137 + if (!snapshot.directories.has(relPath)) { 138 + changes.push({ type: 'add', path: relPath, isDirectory: true }); 139 + } 140 + } 141 + 142 + for (const [relPath] of snapshot.directories) { 143 + if (!fsDirs.includes(relPath)) { 144 + const entry = snapshot.directories.get(relPath)!; 145 + changes.push({ 146 + type: 'delete', 147 + path: relPath, 148 + isDirectory: true, 149 + uri: entry.uri, 150 + }); 151 + } 152 + } 153 + 154 + return changes; 155 + } 156 + 157 + private async walkRemoteTree( 158 + dirUri: HabitatUri, 159 + relPath: string, 160 + snapshot: SyncSnapshot, 161 + ): Promise<DetectedChange[]> { 162 + const result: DetectedChange[] = []; 163 + 164 + try { 165 + const handle = await this.repo.find<DirectoryDocument>(dirUri); 166 + const dirDoc = handle.doc(); 167 + 168 + for (const entry of dirDoc.docs ?? []) { 169 + const childRel = relPath ? join(relPath, entry.name) : entry.name; 170 + 171 + if (isFolderEntry(entry)) { 172 + if (!snapshot.directories.has(childRel)) { 173 + const childHandle = await this.repo.find<DirectoryDocument>( 174 + entry.url, 175 + ); 176 + result.push({ 177 + type: 'add', 178 + path: childRel, 179 + isDirectory: true, 180 + uri: entry.url, 181 + head: childHandle.heads(), 182 + }); 183 + } 184 + result.push( 185 + ...(await this.walkRemoteTree(entry.url, childRel, snapshot)), 186 + ); 187 + } else { 188 + if (!snapshot.files.has(childRel)) { 189 + result.push({ 190 + type: 'add', 191 + path: childRel, 192 + isDirectory: false, 193 + uri: entry.url, 194 + }); 195 + } 196 + } 197 + } 198 + } catch (e) { 199 + console.warn('[ChangeDetector] walkRemoteTree failed for', dirUri, e); 200 + } 201 + 202 + return result; 203 + } 204 + } 205 + 206 + // Re-export for use in engine 207 + export { basename, dirname };
+549
src/vault/engine.test.ts
··· 1 + import * as nodeFs from 'fs'; 2 + import * as nodePath from 'path'; 3 + import * as os from 'os'; 4 + import * as Automerge from '@automerge/automerge'; 5 + import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 6 + 7 + import { VaultSyncEngine } from './engine'; 8 + import { VaultChangeDetector } from './change-detection'; 9 + import { VaultSnapshotManager } from './snapshot'; 10 + import type { 11 + DirectoryDocument, 12 + FileDocument, 13 + HabitatDocHandle, 14 + HabitatRepo, 15 + HabitatUri, 16 + VaultConfig, 17 + } from './types'; 18 + import type { FilesystemApi, FsDirent } from '../filesystem/types'; 19 + 20 + // --------------------------------------------------------------------------- 21 + // Node-native FilesystemApi 22 + // --------------------------------------------------------------------------- 23 + 24 + class NodeFilesystemApi implements FilesystemApi { 25 + async readdir(dirPath: string): Promise<FsDirent[]> { 26 + const entries = nodeFs.readdirSync(dirPath, { withFileTypes: true }); 27 + return entries.map((e) => ({ 28 + name: e.name, 29 + isDirectory: e.isDirectory(), 30 + isFile: e.isFile(), 31 + })); 32 + } 33 + 34 + async readFile(filePath: string): Promise<string> { 35 + return nodeFs.readFileSync(filePath, 'utf-8'); 36 + } 37 + 38 + async writeFile(filePath: string, content: string): Promise<void> { 39 + nodeFs.writeFileSync(filePath, content, 'utf-8'); 40 + } 41 + 42 + async fileExists(filePath: string): Promise<boolean> { 43 + return nodeFs.existsSync(filePath); 44 + } 45 + 46 + async mkdir(dirPath: string): Promise<void> { 47 + nodeFs.mkdirSync(dirPath, { recursive: true }); 48 + } 49 + 50 + async rename(oldPath: string, newPath: string): Promise<void> { 51 + nodeFs.renameSync(oldPath, newPath); 52 + } 53 + 54 + async deletePath(filePath: string): Promise<void> { 55 + nodeFs.rmSync(filePath, { recursive: true, force: true }); 56 + } 57 + 58 + async copyFile(src: string, dest: string): Promise<void> { 59 + nodeFs.copyFileSync(src, dest); 60 + } 61 + } 62 + 63 + // --------------------------------------------------------------------------- 64 + // In-memory HabitatRepo backed by real Automerge 65 + // --------------------------------------------------------------------------- 66 + 67 + class InMemoryDocHandle<T> implements HabitatDocHandle<T> { 68 + constructor( 69 + private uri: HabitatUri, 70 + private store: Map<HabitatUri, Automerge.Doc<unknown>>, 71 + ) {} 72 + 73 + private get current(): Automerge.Doc<T> { 74 + return this.store.get(this.uri) as Automerge.Doc<T>; 75 + } 76 + 77 + doc(): Automerge.Doc<T> { 78 + return this.current; 79 + } 80 + 81 + heads(): string[] { 82 + return Automerge.getHeads(this.current); 83 + } 84 + 85 + change(fn: (d: T) => void): void { 86 + const next = Automerge.change(this.current, fn as Automerge.ChangeFn<unknown>); 87 + this.store.set(this.uri, next); 88 + } 89 + 90 + changeAt(heads: string[], fn: (d: T) => void): void { 91 + const result = Automerge.changeAt( 92 + this.current, 93 + heads, 94 + fn as Automerge.ChangeFn<unknown>, 95 + ); 96 + if (result.newHeads !== null) { 97 + this.store.set(this.uri, result.newDoc); 98 + } 99 + } 100 + 101 + view(heads: string[]): Automerge.Doc<T> { 102 + return Automerge.view(this.current, heads) as Automerge.Doc<T>; 103 + } 104 + 105 + on(_event: 'change', _cb: () => void): () => void { 106 + return () => {}; 107 + } 108 + } 109 + 110 + class InMemoryHabitatRepo implements HabitatRepo { 111 + private store = new Map<HabitatUri, Automerge.Doc<unknown>>(); 112 + private counter = 0; 113 + 114 + private makeUri(collection: string): HabitatUri { 115 + return `at://did:plc:test/${collection}/${++this.counter}` as HabitatUri; 116 + } 117 + 118 + private handle<T>(uri: HabitatUri): InMemoryDocHandle<T> { 119 + return new InMemoryDocHandle<T>(uri, this.store); 120 + } 121 + 122 + async find<T>(uri: HabitatUri): Promise<HabitatDocHandle<T>> { 123 + if (!this.store.has(uri)) { 124 + throw new Error(`[InMemoryHabitatRepo] doc not found: ${uri}`); 125 + } 126 + return this.handle<T>(uri); 127 + } 128 + 129 + async createDirectory( 130 + initial: DirectoryDocument, 131 + ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle<DirectoryDocument> }> { 132 + const uri = this.makeUri('network.habitat.vault.directory'); 133 + this.store.set(uri, Automerge.from(initial as unknown as Record<string, unknown>)); 134 + return { uri, handle: this.handle<DirectoryDocument>(uri) }; 135 + } 136 + 137 + async createFile( 138 + initial: FileDocument, 139 + ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle<FileDocument> }> { 140 + const uri = this.makeUri('network.habitat.vault.file'); 141 + this.store.set(uri, Automerge.from(initial as unknown as Record<string, unknown>)); 142 + return { uri, handle: this.handle<FileDocument>(uri) }; 143 + } 144 + 145 + async delete(uri: HabitatUri): Promise<void> { 146 + this.store.delete(uri); 147 + } 148 + 149 + /** Test helper: directly read a doc from the store. */ 150 + getDoc<T>(uri: HabitatUri): Automerge.Doc<T> | undefined { 151 + return this.store.get(uri) as Automerge.Doc<T> | undefined; 152 + } 153 + 154 + /** Test helper: count docs in the store. */ 155 + docCount(): number { 156 + return this.store.size; 157 + } 158 + } 159 + 160 + // --------------------------------------------------------------------------- 161 + // Test fixture helpers 162 + // --------------------------------------------------------------------------- 163 + 164 + function makeRootDirectoryDoc(): DirectoryDocument { 165 + return { '@patchwork': { type: 'folder' }, docs: [], name: 'root' }; 166 + } 167 + 168 + function writeFile(dir: string, relPath: string, content: string): void { 169 + const full = nodePath.join(dir, relPath); 170 + nodeFs.mkdirSync(nodePath.dirname(full), { recursive: true }); 171 + nodeFs.writeFileSync(full, content, 'utf-8'); 172 + } 173 + 174 + function readFile(dir: string, relPath: string): string { 175 + return nodeFs.readFileSync(nodePath.join(dir, relPath), 'utf-8'); 176 + } 177 + 178 + function fileExists(dir: string, relPath: string): boolean { 179 + return nodeFs.existsSync(nodePath.join(dir, relPath)); 180 + } 181 + 182 + // --------------------------------------------------------------------------- 183 + // Test suite 184 + // --------------------------------------------------------------------------- 185 + 186 + describe('VaultSyncEngine', () => { 187 + let tmpDir: string; 188 + let repo: InMemoryHabitatRepo; 189 + let rootUri: HabitatUri; 190 + let vaultConfig: VaultConfig; 191 + let engine: VaultSyncEngine; 192 + let fs: NodeFilesystemApi; 193 + 194 + beforeEach(async () => { 195 + tmpDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'textile-vault-test-')); 196 + repo = new InMemoryHabitatRepo(); 197 + 198 + const { uri } = await repo.createDirectory(makeRootDirectoryDoc()); 199 + rootUri = uri; 200 + 201 + vaultConfig = { 202 + name: 'Test Vault', 203 + type: 'synced', 204 + rootUri, 205 + createdAt: new Date().toISOString(), 206 + }; 207 + 208 + fs = new NodeFilesystemApi(); 209 + const snapshotManager = new VaultSnapshotManager(fs); 210 + const detector = new VaultChangeDetector(fs, repo); 211 + engine = new VaultSyncEngine(fs, repo, snapshotManager, detector); 212 + }); 213 + 214 + afterEach(() => { 215 + nodeFs.rmSync(tmpDir, { recursive: true, force: true }); 216 + }); 217 + 218 + // ------------------------------------------------------------------------- 219 + // No-op cases 220 + // ------------------------------------------------------------------------- 221 + 222 + it('returns zeros for a local vault without touching the filesystem', async () => { 223 + const localConfig: VaultConfig = { ...vaultConfig, type: 'local' }; 224 + const result = await engine.sync(localConfig, tmpDir); 225 + expect(result).toEqual({ pushed: 0, pulled: 0, moves: 0 }); 226 + expect(nodeFs.existsSync(nodePath.join(tmpDir, '.textile'))).toBe(false); 227 + }); 228 + 229 + it('syncs an empty vault with no changes and saves a snapshot', async () => { 230 + const result = await engine.sync(vaultConfig, tmpDir); 231 + expect(result).toEqual({ pushed: 0, pulled: 0, moves: 0 }); 232 + expect(fileExists(tmpDir, '.textile/snapshot.json')).toBe(true); 233 + }); 234 + 235 + it('second sync with no changes is idempotent', async () => { 236 + await engine.sync(vaultConfig, tmpDir); 237 + const docCountAfterFirst = repo.docCount(); 238 + 239 + const result = await engine.sync(vaultConfig, tmpDir); 240 + expect(result).toEqual({ pushed: 0, pulled: 0, moves: 0 }); 241 + expect(repo.docCount()).toBe(docCountAfterFirst); 242 + }); 243 + 244 + // ------------------------------------------------------------------------- 245 + // Push: local → remote 246 + // ------------------------------------------------------------------------- 247 + 248 + it('pushes a new root-level file to the repo', async () => { 249 + writeFile(tmpDir, 'hello.md', '# Hello'); 250 + 251 + const result = await engine.sync(vaultConfig, tmpDir); 252 + 253 + expect(result.pushed).toBe(1); 254 + expect(result.pulled).toBe(0); 255 + 256 + // Snapshot records the file 257 + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); 258 + const snapshot = JSON.parse(snapshotRaw); 259 + const fileEntry = snapshot.files.find(([p]: [string]) => p === 'hello.md'); 260 + expect(fileEntry).toBeDefined(); 261 + 262 + // Repo has a file doc for it 263 + const fileUri = fileEntry[1].uri as HabitatUri; 264 + const doc = repo.getDoc<FileDocument>(fileUri); 265 + expect(doc).toBeDefined(); 266 + expect((doc as unknown as FileDocument).content).toBe('# Hello'); 267 + }); 268 + 269 + it('pushes a new file inside a new subdirectory, creating directory docs', async () => { 270 + writeFile(tmpDir, 'notes/ideas.md', 'some ideas'); 271 + 272 + const result = await engine.sync(vaultConfig, tmpDir); 273 + 274 + expect(result.pushed).toBe(1); 275 + 276 + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); 277 + const snapshot = JSON.parse(snapshotRaw); 278 + 279 + // Both the file and its parent directory are in the snapshot 280 + const fileEntry = snapshot.files.find(([p]: [string]) => p === 'notes/ideas.md'); 281 + expect(fileEntry).toBeDefined(); 282 + const dirEntry = snapshot.directories.find(([p]: [string]) => p === 'notes'); 283 + expect(dirEntry).toBeDefined(); 284 + }); 285 + 286 + it('pushes an edit to an existing file', async () => { 287 + writeFile(tmpDir, 'doc.md', 'original'); 288 + await engine.sync(vaultConfig, tmpDir); 289 + 290 + writeFile(tmpDir, 'doc.md', 'updated'); 291 + const result = await engine.sync(vaultConfig, tmpDir); 292 + 293 + expect(result.pushed).toBe(1); 294 + 295 + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); 296 + const snapshot = JSON.parse(snapshotRaw); 297 + const fileEntry = snapshot.files.find(([p]: [string]) => p === 'doc.md'); 298 + const fileUri = fileEntry[1].uri as HabitatUri; 299 + const doc = repo.getDoc<FileDocument>(fileUri); 300 + expect((doc as unknown as FileDocument).content).toBe('updated'); 301 + }); 302 + 303 + it('removes a deleted file from the snapshot', async () => { 304 + writeFile(tmpDir, 'gone.md', 'bye'); 305 + await engine.sync(vaultConfig, tmpDir); 306 + 307 + nodeFs.rmSync(nodePath.join(tmpDir, 'gone.md')); 308 + const result = await engine.sync(vaultConfig, tmpDir); 309 + 310 + expect(result.pushed).toBe(1); 311 + 312 + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); 313 + const snapshot = JSON.parse(snapshotRaw); 314 + const fileEntry = snapshot.files.find(([p]: [string]) => p === 'gone.md'); 315 + expect(fileEntry).toBeUndefined(); 316 + }); 317 + 318 + // ------------------------------------------------------------------------- 319 + // Move detection 320 + // ------------------------------------------------------------------------- 321 + 322 + it('detects a rename as a move, reusing the existing doc URI', async () => { 323 + writeFile(tmpDir, 'original.md', 'content that is long enough for similarity'); 324 + await engine.sync(vaultConfig, tmpDir); 325 + 326 + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); 327 + const snapshot = JSON.parse(snapshotRaw); 328 + const originalUri = snapshot.files.find(([p]: [string]) => p === 'original.md')[1].uri; 329 + 330 + // Rename: remove old, create new with same content 331 + nodeFs.rmSync(nodePath.join(tmpDir, 'original.md')); 332 + writeFile(tmpDir, 'renamed.md', 'content that is long enough for similarity'); 333 + 334 + const result = await engine.sync(vaultConfig, tmpDir); 335 + 336 + expect(result.moves).toBe(1); 337 + 338 + const snapshotRaw2 = readFile(tmpDir, '.textile/snapshot.json'); 339 + const snapshot2 = JSON.parse(snapshotRaw2); 340 + const renamedEntry = snapshot2.files.find(([p]: [string]) => p === 'renamed.md'); 341 + expect(renamedEntry).toBeDefined(); 342 + // Same URI as the original — doc was updated in place, not recreated 343 + expect(renamedEntry[1].uri).toBe(originalUri); 344 + // Old path is gone 345 + expect(snapshot2.files.find(([p]: [string]) => p === 'original.md')).toBeUndefined(); 346 + }); 347 + 348 + // ------------------------------------------------------------------------- 349 + // First-open clone: remote → local (one time only) 350 + // ------------------------------------------------------------------------- 351 + 352 + it('clones a pre-existing remote file to the filesystem on first sync', async () => { 353 + // Seed a remote file + directory entry BEFORE the first sync (no snapshot). 354 + const { uri: fileUri } = await repo.createFile({ 355 + '@patchwork': { type: 'file' }, 356 + name: 'remote.md', 357 + extension: 'md', 358 + mimeType: 'text/markdown', 359 + content: 'from remote', 360 + metadata: { permissions: 0o644 }, 361 + }); 362 + const rootHandle = await repo.find<DirectoryDocument>(rootUri); 363 + rootHandle.change((d) => { 364 + (d as unknown as DirectoryDocument).docs.push({ 365 + name: 'remote.md', 366 + type: 'md', 367 + url: fileUri, 368 + }); 369 + }); 370 + 371 + const result = await engine.sync(vaultConfig, tmpDir); 372 + 373 + expect(result.pulled).toBe(1); 374 + expect(fileExists(tmpDir, 'remote.md')).toBe(true); 375 + expect(readFile(tmpDir, 'remote.md')).toBe('from remote'); 376 + }); 377 + 378 + it('does not pull remote edits to tracked files during steady-state sync', async () => { 379 + // Establish the file both locally and in the snapshot (first sync clones 380 + // nothing; this push creates the doc + snapshot entry). 381 + writeFile(tmpDir, 'shared.md', 'v1'); 382 + await engine.sync(vaultConfig, tmpDir); 383 + 384 + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); 385 + const snapshot = JSON.parse(snapshotRaw); 386 + const fileUri = snapshot.files.find(([p]: [string]) => p === 'shared.md')[1].uri as HabitatUri; 387 + 388 + // Remote edit lands on the doc. 389 + const handle = await repo.find<FileDocument>(fileUri); 390 + handle.change((d) => { 391 + (d as unknown as FileDocument).content = 'v2 from remote'; 392 + }); 393 + 394 + // A steady-state sync must not walk/pull — disk stays at v1. 395 + const result = await engine.sync(vaultConfig, tmpDir); 396 + 397 + expect(result.pulled).toBe(0); 398 + expect(readFile(tmpDir, 'shared.md')).toBe('v1'); 399 + }); 400 + 401 + // ------------------------------------------------------------------------- 402 + // Per-file pull on open 403 + // ------------------------------------------------------------------------- 404 + 405 + it('openFile pulls the merged remote content to disk and advances the snapshot head', async () => { 406 + writeFile(tmpDir, 'shared.md', 'v1'); 407 + await engine.sync(vaultConfig, tmpDir); 408 + 409 + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); 410 + const snapshot = JSON.parse(snapshotRaw); 411 + const fileUri = snapshot.files.find(([p]: [string]) => p === 'shared.md')[1].uri as HabitatUri; 412 + 413 + const handle = await repo.find<FileDocument>(fileUri); 414 + handle.change((d) => { 415 + (d as unknown as FileDocument).content = 'v2 from remote'; 416 + }); 417 + const remoteHeads = handle.heads(); 418 + 419 + await engine.openFile(vaultConfig, tmpDir, 'shared.md'); 420 + 421 + expect(readFile(tmpDir, 'shared.md')).toBe('v2 from remote'); 422 + 423 + const snapshotRaw2 = readFile(tmpDir, '.textile/snapshot.json'); 424 + const snapshot2 = JSON.parse(snapshotRaw2); 425 + const entry = snapshot2.files.find(([p]: [string]) => p === 'shared.md')[1]; 426 + expect(entry.head).toEqual(remoteHeads); 427 + }); 428 + 429 + it('openFile is a no-op for an untracked file', async () => { 430 + await engine.sync(vaultConfig, tmpDir); 431 + await engine.openFile(vaultConfig, tmpDir, 'never-seen.md'); 432 + expect(fileExists(tmpDir, 'never-seen.md')).toBe(false); 433 + }); 434 + 435 + // ------------------------------------------------------------------------- 436 + // Per-file push (save/edit events) 437 + // ------------------------------------------------------------------------- 438 + 439 + it('pushFile creates a new file doc and registers it in the root directory', async () => { 440 + await engine.sync(vaultConfig, tmpDir); // establish snapshot 441 + const docCountBefore = repo.docCount(); 442 + 443 + writeFile(tmpDir, 'note.md', '# Note'); 444 + await engine.pushFile(vaultConfig, tmpDir, 'note.md'); 445 + 446 + // A file doc was created. 447 + expect(repo.docCount()).toBe(docCountBefore + 1); 448 + 449 + const snapshot = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); 450 + const fileEntry = snapshot.files.find(([p]: [string]) => p === 'note.md'); 451 + expect(fileEntry).toBeDefined(); 452 + const fileUri = fileEntry[1].uri as HabitatUri; 453 + expect((repo.getDoc<FileDocument>(fileUri) as unknown as FileDocument).content).toBe('# Note'); 454 + 455 + // The root directory doc references it. 456 + const rootDoc = repo.getDoc<DirectoryDocument>(rootUri) as unknown as DirectoryDocument; 457 + expect(rootDoc.docs.some((e) => e.url === fileUri)).toBe(true); 458 + }); 459 + 460 + it('pushFile updates an existing file in place without recreating the doc', async () => { 461 + writeFile(tmpDir, 'doc.md', 'v1'); 462 + await engine.sync(vaultConfig, tmpDir); 463 + 464 + const snap1 = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); 465 + const uriBefore = snap1.files.find(([p]: [string]) => p === 'doc.md')[1].uri; 466 + const docCountBefore = repo.docCount(); 467 + 468 + writeFile(tmpDir, 'doc.md', 'v2'); 469 + await engine.pushFile(vaultConfig, tmpDir, 'doc.md'); 470 + 471 + expect(repo.docCount()).toBe(docCountBefore); // no new doc 472 + const snap2 = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); 473 + const entry = snap2.files.find(([p]: [string]) => p === 'doc.md')[1]; 474 + expect(entry.uri).toBe(uriBefore); // same doc, edited in place 475 + expect((repo.getDoc<FileDocument>(uriBefore) as unknown as FileDocument).content).toBe('v2'); 476 + }); 477 + 478 + it('pushFile is a no-op when the file content is unchanged', async () => { 479 + writeFile(tmpDir, 'doc.md', 'same'); 480 + await engine.sync(vaultConfig, tmpDir); 481 + 482 + const snapBefore = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); 483 + const headBefore = snapBefore.files.find(([p]: [string]) => p === 'doc.md')[1].head; 484 + 485 + await engine.pushFile(vaultConfig, tmpDir, 'doc.md'); 486 + 487 + const snapAfter = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); 488 + const headAfter = snapAfter.files.find(([p]: [string]) => p === 'doc.md')[1].head; 489 + expect(headAfter).toEqual(headBefore); // doc was not touched 490 + }); 491 + 492 + it('pushFile creates intermediate directory docs for a nested new file', async () => { 493 + await engine.sync(vaultConfig, tmpDir); 494 + 495 + writeFile(tmpDir, 'notes/ideas.md', 'some ideas'); 496 + await engine.pushFile(vaultConfig, tmpDir, 'notes/ideas.md'); 497 + 498 + const snapshot = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); 499 + expect(snapshot.files.find(([p]: [string]) => p === 'notes/ideas.md')).toBeDefined(); 500 + const dirEntry = snapshot.directories.find(([p]: [string]) => p === 'notes'); 501 + expect(dirEntry).toBeDefined(); 502 + 503 + // Parent dir doc references the file; root dir doc references the subdir. 504 + const dirUri = dirEntry[1].uri as HabitatUri; 505 + const dirDoc = repo.getDoc<DirectoryDocument>(dirUri) as unknown as DirectoryDocument; 506 + expect(dirDoc.docs.some((e) => e.name === 'ideas.md')).toBe(true); 507 + const rootDoc = repo.getDoc<DirectoryDocument>(rootUri) as unknown as DirectoryDocument; 508 + expect(rootDoc.docs.some((e) => e.url === dirUri)).toBe(true); 509 + }); 510 + 511 + it('pushFile is a no-op for a local vault', async () => { 512 + const localConfig: VaultConfig = { ...vaultConfig, type: 'local' }; 513 + writeFile(tmpDir, 'note.md', 'x'); 514 + await engine.pushFile(localConfig, tmpDir, 'note.md'); 515 + expect(fileExists(tmpDir, '.textile/snapshot.json')).toBe(false); 516 + expect(repo.docCount()).toBe(1); // only the root dir from beforeEach 517 + }); 518 + 519 + // ------------------------------------------------------------------------- 520 + // Snapshot correctness 521 + // ------------------------------------------------------------------------- 522 + 523 + it('snapshot head matches the repo doc heads after a push', async () => { 524 + writeFile(tmpDir, 'tracked.md', 'hello'); 525 + await engine.sync(vaultConfig, tmpDir); 526 + 527 + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); 528 + const snapshot = JSON.parse(snapshotRaw); 529 + const fileEntry = snapshot.files.find(([p]: [string]) => p === 'tracked.md')[1]; 530 + const fileUri = fileEntry.uri as HabitatUri; 531 + 532 + const handle = await repo.find<FileDocument>(fileUri); 533 + expect(fileEntry.head).toEqual(handle.heads()); 534 + }); 535 + 536 + it('snapshot contentHash matches the actual file content after a push', async () => { 537 + writeFile(tmpDir, 'tracked.md', 'hello world'); 538 + await engine.sync(vaultConfig, tmpDir); 539 + 540 + const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); 541 + const snapshot = JSON.parse(snapshotRaw); 542 + const fileEntry = snapshot.files.find(([p]: [string]) => p === 'tracked.md')[1]; 543 + 544 + // Compute expected hash the same way the engine does 545 + const { createHash } = await import('crypto'); 546 + const expected = 'sha256:' + createHash('sha256').update('hello world', 'utf8').digest('hex'); 547 + expect(fileEntry.contentHash).toBe(expected); 548 + }); 549 + });
+522
src/vault/engine.ts
··· 1 + import type { FilesystemApi } from '../filesystem/types'; 2 + import { join, basename, dirname } from '../filesystem/vaultFs'; 3 + import { VaultChangeDetector } from './change-detection'; 4 + import { detectMoves } from './move-detection'; 5 + import type { VaultSnapshotManager } from './snapshot'; 6 + import { contentHash } from './utils/content'; 7 + import { readDocContent } from './utils/text-diff'; 8 + import type { 9 + DetectedChange, 10 + DirectoryDocument, 11 + DirectoryEntry, 12 + FileDocument, 13 + HabitatRepo, 14 + HabitatUri, 15 + MoveCandidate, 16 + SnapshotDirectoryEntry, 17 + SnapshotFileEntry, 18 + SyncSnapshot, 19 + VaultConfig, 20 + } from './types'; 21 + 22 + export interface SyncResult { 23 + pushed: number; 24 + /** Files written to disk. Non-zero only on the first-open clone. */ 25 + pulled: number; 26 + moves: number; 27 + } 28 + 29 + // Pure snapshot mutation helpers — return new snapshots rather than mutating. 30 + 31 + function snapshotWithFile( 32 + snap: SyncSnapshot, 33 + path: string, 34 + entry: SnapshotFileEntry, 35 + ): SyncSnapshot { 36 + const files = new Map(snap.files); 37 + files.set(path, entry); 38 + return { ...snap, timestamp: Date.now(), files }; 39 + } 40 + 41 + function snapshotWithRemovedFile(snap: SyncSnapshot, path: string): SyncSnapshot { 42 + const files = new Map(snap.files); 43 + files.delete(path); 44 + return { ...snap, timestamp: Date.now(), files }; 45 + } 46 + 47 + function snapshotWithDirectory( 48 + snap: SyncSnapshot, 49 + path: string, 50 + entry: SnapshotDirectoryEntry, 51 + ): SyncSnapshot { 52 + const directories = new Map(snap.directories); 53 + directories.set(path, entry); 54 + return { ...snap, timestamp: Date.now(), directories }; 55 + } 56 + 57 + function extname(name: string): string { 58 + const idx = name.lastIndexOf('.'); 59 + return idx > 0 ? name.substring(idx + 1) : ''; 60 + } 61 + 62 + function pathDepth(p: string): number { 63 + if (p === '/') return 0; 64 + return p.split('/').length; 65 + } 66 + 67 + export class VaultSyncEngine { 68 + constructor( 69 + private fs: FilesystemApi, 70 + private repo: HabitatRepo, 71 + private snapshotManager: VaultSnapshotManager, 72 + private detector: VaultChangeDetector, 73 + ) {} 74 + 75 + async sync(vault: VaultConfig, rootPath: string): Promise<SyncResult> { 76 + if (vault.type === 'local') { 77 + return { pushed: 0, pulled: 0, moves: 0 }; 78 + } 79 + 80 + // First sync (no snapshot on disk) does a one-time clone of the remote 81 + // tree; every subsequent sync is push-only. Remote edits to already-cloned 82 + // files are merged when the file is opened (see `openFile`) or live over 83 + // libp2p — never by walking the remote tree here. 84 + const existing = await this.snapshotManager.load(rootPath); 85 + const firstSync = existing === null; 86 + let snap = existing ?? this.snapshotManager.createEmpty(vault.rootUri); 87 + 88 + let pulled = 0; 89 + if (firstSync) { 90 + const remote = await this.detector.scanRemoteTree(vault.rootUri, snap); 91 + ({ snap, pulled } = await this.pull(rootPath, snap, remote)); 92 + } 93 + 94 + // Push local changes (on first sync this also uploads any pre-existing 95 + // local files, since the snapshot started empty). 96 + const localChanges = await this.detector.detectLocal(rootPath, snap); 97 + 98 + const { moves, remainingChanges } = await detectMoves( 99 + localChanges, 100 + async (path) => { 101 + // Deleted files no longer exist on disk — fall back to the last known 102 + // content from the repo so move detection can compare similarity. 103 + try { 104 + return await this.fs.readFile(join(rootPath, path)); 105 + } catch { 106 + const entry = snap.files.get(path); 107 + if (entry) { 108 + try { 109 + const handle = await this.repo.find<FileDocument>(entry.uri); 110 + return readDocContent(handle.doc() as unknown as FileDocument); 111 + } catch { 112 + return null; 113 + } 114 + } 115 + return null; 116 + } 117 + }, 118 + ); 119 + 120 + const { snap: pushedSnap, pushed, moved } = await this.push( 121 + rootPath, 122 + vault.rootUri, 123 + snap, 124 + remainingChanges, 125 + moves, 126 + ); 127 + snap = pushedSnap; 128 + 129 + await this.snapshotManager.save(rootPath, snap); 130 + 131 + return { pushed, pulled, moves: moved }; 132 + } 133 + 134 + /** 135 + * Per-file pull invoked when a file is opened. `repo.find` loads the doc 136 + * through the live session (merging PDS + libp2p state), so this writes the 137 + * latest merged content back to disk and refreshes the snapshot entry. 138 + * No-op for untracked files — remote discovery is handled by the clone. 139 + */ 140 + async openFile( 141 + vault: VaultConfig, 142 + rootPath: string, 143 + relPath: string, 144 + ): Promise<void> { 145 + if (vault.type === 'local') return; 146 + 147 + let snap = 148 + (await this.snapshotManager.load(rootPath)) ?? 149 + this.snapshotManager.createEmpty(vault.rootUri); 150 + 151 + const entry = snap.files.get(relPath); 152 + if (!entry) return; 153 + 154 + snap = await this.pullFile( 155 + rootPath, 156 + { 157 + type: 'edit', 158 + path: relPath, 159 + isDirectory: false, 160 + uri: entry.uri, 161 + }, 162 + snap, 163 + ); 164 + 165 + await this.snapshotManager.save(rootPath, snap); 166 + } 167 + 168 + /** 169 + * Push a single file (add or edit) without scanning the whole tree. Intended 170 + * for save/edit events. Determines add-vs-edit from the snapshot, skips when 171 + * the on-disk content is unchanged, and — for new files only — registers the 172 + * file in its parent directory doc(s) so other clients can discover it. 173 + * Deletes and renames still go through `sync` (they need tree-wide context). 174 + */ 175 + async pushFile( 176 + vault: VaultConfig, 177 + rootPath: string, 178 + relPath: string, 179 + ): Promise<void> { 180 + if (vault.type === 'local') return; 181 + 182 + let snap = 183 + (await this.snapshotManager.load(rootPath)) ?? 184 + this.snapshotManager.createEmpty(vault.rootUri); 185 + 186 + const content = await this.fs.readFile(join(rootPath, relPath)); 187 + const hash = contentHash(content); 188 + const existing = snap.files.get(relPath); 189 + 190 + // Nothing changed since the last push — skip the network round-trip. 191 + if (existing && existing.contentHash === hash) return; 192 + 193 + const isAdd = !existing; 194 + snap = await this.pushFileChange( 195 + rootPath, 196 + { 197 + type: isAdd ? 'add' : 'edit', 198 + path: relPath, 199 + isDirectory: false, 200 + uri: existing?.uri, 201 + contentHash: hash, 202 + }, 203 + snap, 204 + ); 205 + 206 + // A new file needs a directory entry so it's discoverable; edits don't 207 + // change the listing, so skip the directory rewrite for them. 208 + if (isAdd) { 209 + snap = await this.updateParentDirectories([relPath], vault.rootUri, snap); 210 + } 211 + 212 + await this.snapshotManager.save(rootPath, snap); 213 + } 214 + 215 + // --------------------------------------------------------------------------- 216 + // Push phase 217 + // --------------------------------------------------------------------------- 218 + 219 + private async push( 220 + rootPath: string, 221 + rootUri: HabitatUri, 222 + snap: SyncSnapshot, 223 + changes: DetectedChange[], 224 + moves: MoveCandidate[], 225 + ): Promise<{ snap: SyncSnapshot; pushed: number; moved: number }> { 226 + const affectedPaths: string[] = []; 227 + 228 + // 1. Moves — update existing doc in place to preserve CRDT history. 229 + for (const move of moves) { 230 + const content = await this.fs.readFile(join(rootPath, move.newPath)); 231 + const oldEntry = snap.files.get(move.oldPath); 232 + if (oldEntry) { 233 + const handle = await this.repo.find<FileDocument>(oldEntry.uri); 234 + const ext = extname(move.newPath); 235 + const newName = basename(move.newPath); 236 + handle.changeAt(oldEntry.head, (d) => { 237 + const fd = d as unknown as FileDocument; 238 + fd.content = content; 239 + fd.name = newName; 240 + fd.extension = ext; 241 + }); 242 + snap = snapshotWithRemovedFile(snap, move.oldPath); 243 + snap = snapshotWithFile(snap, move.newPath, { 244 + uri: oldEntry.uri, 245 + head: handle.heads(), 246 + contentHash: contentHash(content), 247 + }); 248 + affectedPaths.push(move.oldPath, move.newPath); 249 + } 250 + } 251 + 252 + // 2. File adds and edits. 253 + const fileChanges = changes.filter( 254 + (c) => (c.type === 'add' || c.type === 'edit') && !c.isDirectory, 255 + ); 256 + for (const change of fileChanges) { 257 + snap = await this.pushFileChange(rootPath, change, snap); 258 + affectedPaths.push(change.path); 259 + } 260 + 261 + // 3. File deletes. 262 + const fileDeletes = changes.filter( 263 + (c) => c.type === 'delete' && !c.isDirectory, 264 + ); 265 + for (const change of fileDeletes) { 266 + snap = await this.deleteRemoteFile(change, snap); 267 + affectedPaths.push(change.path); 268 + } 269 + 270 + const pushed = fileChanges.length + fileDeletes.length; 271 + const moved = moves.length; 272 + 273 + // 4. Update parent directories depth-first. 274 + if (affectedPaths.length > 0) { 275 + snap = await this.updateParentDirectories(affectedPaths, rootUri, snap); 276 + } 277 + 278 + await this.snapshotManager.save(rootPath, snap); 279 + return { snap, pushed, moved }; 280 + } 281 + 282 + private async pushFileChange( 283 + rootPath: string, 284 + change: DetectedChange, 285 + snap: SyncSnapshot, 286 + ): Promise<SyncSnapshot> { 287 + const content = await this.fs.readFile(join(rootPath, change.path)); 288 + const hash = contentHash(content); 289 + const name = basename(change.path); 290 + 291 + if (change.type === 'add') { 292 + const { uri, handle } = await this.repo.createFile( 293 + this.buildFileDocument(name, content), 294 + ); 295 + return snapshotWithFile(snap, change.path, { 296 + uri, 297 + head: handle.heads(), 298 + contentHash: hash, 299 + }); 300 + } 301 + 302 + // edit 303 + const entry = snap.files.get(change.path); 304 + if (!entry) { 305 + throw new Error(`[VaultEngine] no snapshot entry for edited file: ${change.path}`); 306 + } 307 + const handle = await this.repo.find<FileDocument>(entry.uri); 308 + handle.changeAt(entry.head, (d) => { 309 + (d as unknown as FileDocument).content = content; 310 + }); 311 + return snapshotWithFile(snap, change.path, { 312 + uri: entry.uri, 313 + head: handle.heads(), 314 + contentHash: hash, 315 + }); 316 + } 317 + 318 + private async deleteRemoteFile( 319 + change: DetectedChange, 320 + snap: SyncSnapshot, 321 + ): Promise<SyncSnapshot> { 322 + if (change.uri) { 323 + await this.repo.delete(change.uri); 324 + } 325 + return snapshotWithRemovedFile(snap, change.path); 326 + } 327 + 328 + // --------------------------------------------------------------------------- 329 + // Directory update helpers 330 + // --------------------------------------------------------------------------- 331 + 332 + private async updateParentDirectories( 333 + affectedPaths: string[], 334 + rootUri: HabitatUri, 335 + snap: SyncSnapshot, 336 + ): Promise<SyncSnapshot> { 337 + const dirSet = new Set<string>(); 338 + 339 + for (const path of affectedPaths) { 340 + let d = dirname(path); 341 + while (true) { 342 + dirSet.add(d); 343 + if (d === '/') break; 344 + d = dirname(d); 345 + } 346 + } 347 + 348 + // Deepest first — children before parents. 349 + const sorted = Array.from(dirSet).sort( 350 + (a, b) => pathDepth(b) - pathDepth(a), 351 + ); 352 + 353 + for (const dir of sorted) { 354 + snap = await this.updateOneDirectory(dir, rootUri, snap); 355 + } 356 + 357 + return snap; 358 + } 359 + 360 + private async updateOneDirectory( 361 + dirRelPath: string, 362 + rootUri: HabitatUri, 363 + snap: SyncSnapshot, 364 + ): Promise<SyncSnapshot> { 365 + const isRoot = dirRelPath === '/'; 366 + const dirEntry = isRoot ? null : snap.directories.get(dirRelPath); 367 + let dirUri: HabitatUri | undefined = isRoot ? rootUri : dirEntry?.uri; 368 + 369 + if (!dirUri) { 370 + // New directory that doesn't yet exist remotely. 371 + const name = basename(dirRelPath); 372 + const { uri: newUri, handle } = await this.repo.createDirectory( 373 + this.buildDirectoryDocument(name), 374 + ); 375 + dirUri = newUri; 376 + snap = snapshotWithDirectory(snap, dirRelPath, { 377 + uri: newUri, 378 + head: handle.heads(), 379 + }); 380 + } 381 + 382 + const handle = await this.repo.find<DirectoryDocument>(dirUri); 383 + const existingHeads = dirEntry?.head ?? handle.heads(); 384 + const expected = this.buildExpectedEntries(dirRelPath, snap); 385 + 386 + handle.changeAt(existingHeads, (d) => { 387 + const docs = d.docs as unknown as DirectoryEntry[]; 388 + docs.splice(0, docs.length); 389 + for (const e of expected) { 390 + docs.push(e); 391 + } 392 + (d as unknown as DirectoryDocument).lastSyncAt = Date.now(); 393 + }); 394 + 395 + // Root is always known via vault.rootUri — no need to track it in the 396 + // snapshot. Storing '/' causes dirname('/') === '/' which makes root 397 + // appear as its own child in buildExpectedEntries. 398 + if (isRoot) { 399 + return snap; 400 + } 401 + 402 + return snapshotWithDirectory(snap, dirRelPath, { 403 + uri: dirUri, 404 + head: handle.heads(), 405 + }); 406 + } 407 + 408 + private buildExpectedEntries( 409 + dirRelPath: string, 410 + snap: SyncSnapshot, 411 + ): DirectoryEntry[] { 412 + const entries: DirectoryEntry[] = []; 413 + 414 + for (const [filePath, fileEntry] of snap.files) { 415 + if (dirname(filePath) === dirRelPath) { 416 + const name = basename(filePath); 417 + const ext = extname(name) || 'md'; 418 + entries.push({ name, type: ext, url: fileEntry.uri }); 419 + } 420 + } 421 + 422 + for (const [subdirPath, subdirEntry] of snap.directories) { 423 + if (dirname(subdirPath) === dirRelPath) { 424 + entries.push({ 425 + name: basename(subdirPath), 426 + type: 'folder', 427 + url: subdirEntry.uri, 428 + }); 429 + } 430 + } 431 + 432 + return entries; 433 + } 434 + 435 + // --------------------------------------------------------------------------- 436 + // Pull phase 437 + // --------------------------------------------------------------------------- 438 + 439 + private async pull( 440 + rootPath: string, 441 + snap: SyncSnapshot, 442 + remoteChanges: DetectedChange[], 443 + ): Promise<{ snap: SyncSnapshot; pulled: number }> { 444 + let pulled = 0; 445 + 446 + for (const change of remoteChanges) { 447 + if (change.isDirectory) { 448 + const fullPath = join(rootPath, change.path); 449 + try { 450 + await this.fs.mkdir(fullPath); 451 + } catch { 452 + // Directory may already exist. 453 + } 454 + if (change.uri && change.head) { 455 + snap = snapshotWithDirectory(snap, change.path, { 456 + uri: change.uri, 457 + head: change.head, 458 + }); 459 + } 460 + pulled++; 461 + } else { 462 + snap = await this.pullFile(rootPath, change, snap); 463 + pulled++; 464 + } 465 + } 466 + 467 + return { snap, pulled }; 468 + } 469 + 470 + private async pullFile( 471 + rootPath: string, 472 + change: DetectedChange, 473 + snap: SyncSnapshot, 474 + ): Promise<SyncSnapshot> { 475 + if (!change.uri) return snap; 476 + 477 + const handle = await this.repo.find<FileDocument>(change.uri); 478 + const content = readDocContent(handle.doc() as unknown as FileDocument); 479 + const fullPath = join(rootPath, change.path); 480 + const parentDir = dirname(fullPath); 481 + 482 + if (parentDir && parentDir !== '/') { 483 + const parentExists = await this.fs.fileExists(parentDir); 484 + if (!parentExists) { 485 + await this.fs.mkdir(parentDir); 486 + } 487 + } 488 + 489 + await this.fs.writeFile(fullPath, content); 490 + 491 + return snapshotWithFile(snap, change.path, { 492 + uri: change.uri, 493 + head: handle.heads(), 494 + contentHash: contentHash(content), 495 + }); 496 + } 497 + 498 + // --------------------------------------------------------------------------- 499 + // Document builders 500 + // --------------------------------------------------------------------------- 501 + 502 + private buildFileDocument(name: string, content: string): FileDocument { 503 + const ext = extname(name); 504 + const mimeType = ext === 'md' ? 'text/markdown' : 'text/plain'; 505 + return { 506 + '@patchwork': { type: 'file' }, 507 + name, 508 + extension: ext, 509 + mimeType, 510 + content, 511 + metadata: { permissions: 0o644 }, 512 + }; 513 + } 514 + 515 + private buildDirectoryDocument(name: string): DirectoryDocument { 516 + return { 517 + '@patchwork': { type: 'folder' }, 518 + docs: [], 519 + name, 520 + }; 521 + } 522 + }
+13 -12
src/vault/habitat-repo.ts
··· 1 1 import * as Automerge from '@automerge/automerge'; 2 - 3 2 import type { 4 3 HabitatDocHandle, 5 4 HabitatRepo, ··· 13 12 HABITAT_VAULT_DIRECTORY_COLLECTION, 14 13 HABITAT_VAULT_FILE_COLLECTION, 15 14 } from '../habitat/config'; 15 + import { createAutomergeDoc } from '../habitat/automergeDoc'; 16 16 17 17 /** 18 18 * Adapts an AutomergeDocSession into a HabitatDocHandle. ··· 58 58 * File documents are stored in `network.habitat.vault.file` 59 59 */ 60 60 export class HabitatRepoImpl implements HabitatRepo { 61 + constructor(private myDid: string) {} 62 + 61 63 async find<T>(uri: HabitatUri): Promise<HabitatDocHandle<T>> { 62 64 const session = acquireDocSession(uri); 63 65 await session.loadPromise; ··· 76 78 return this.create(initial, HABITAT_VAULT_FILE_COLLECTION); 77 79 } 78 80 79 - async create<T>( 81 + private async create<T>( 80 82 initial: T, 81 83 collection: string, 82 84 ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle<T> }> { 83 - // Create a new empty Automerge doc with the initial value 84 - const doc = Automerge.from(initial as Record<string, unknown>); 85 - // TODO: Save the doc to Habitat PDS and get a URI 86 - // For now, this is a stub that returns a placeholder URI 87 - const uri = `at://did:plc:placeholder/${collection}/rkey-placeholder` as HabitatUri; 88 - const session = acquireDocSession(uri); 89 - // Force the session to use our new doc 90 - // This is a simplified implementation; in reality we'd need to 91 - // create the Habitat record first, then acquire the session. 92 - return { uri, handle: new HabitatDocHandleImpl<T>(session) }; 85 + const { uri } = await createAutomergeDoc( 86 + this.myDid, 87 + initial as unknown as Record<string, unknown>, 88 + collection, 89 + ); 90 + const habitatUri = uri as HabitatUri; 91 + const session = acquireDocSession(habitatUri); 92 + await session.loadPromise; 93 + return { uri: habitatUri, handle: new HabitatDocHandleImpl<T>(session) }; 93 94 } 94 95 95 96 async delete(uri: HabitatUri): Promise<void> {
+102
src/vault/move-detection.ts
··· 1 + import type { DetectedChange, MoveCandidate } from './types'; 2 + import { stringSimilarity } from './utils/string-similarity'; 3 + 4 + const DEFAULT_THRESHOLD = 0.7; 5 + const LARGE_FILE_BYTES = 4096; 6 + const SAMPLE_WINDOW = 100; 7 + 8 + export interface MoveDetectionResult { 9 + moves: MoveCandidate[]; 10 + remainingChanges: DetectedChange[]; 11 + } 12 + 13 + /** 14 + * Detect renames/moves by pairing local deletions with local additions 15 + * that have sufficiently similar content (Sørensen–Dice ≥ threshold). 16 + * 17 + * @param changes - All locally detected changes. 18 + * @param getContent - Async getter for current FS content. Returns null for 19 + * unreadable / binary files (those are skipped as non-movable). 20 + * @param threshold - Minimum similarity score. Default 0.7. 21 + */ 22 + export async function detectMoves( 23 + changes: DetectedChange[], 24 + getContent: (path: string) => Promise<string | null>, 25 + threshold = DEFAULT_THRESHOLD, 26 + ): Promise<MoveDetectionResult> { 27 + const deletions = changes.filter((c) => c.type === 'delete' && !c.isDirectory); 28 + const additions = changes.filter((c) => c.type === 'add' && !c.isDirectory); 29 + const others = changes.filter( 30 + (c) => (c.type !== 'delete' && c.type !== 'add') || c.isDirectory, 31 + ); 32 + 33 + const deleteContents = new Map<string, string | null>(); 34 + const addContents = new Map<string, string | null>(); 35 + 36 + await Promise.all([ 37 + ...deletions.map(async (c) => { 38 + deleteContents.set(c.path, await getContent(c.path).catch(() => null)); 39 + }), 40 + ...additions.map(async (c) => { 41 + addContents.set(c.path, await getContent(c.path).catch(() => null)); 42 + }), 43 + ]); 44 + 45 + const claimed = new Set<string>(); 46 + const moves: MoveCandidate[] = []; 47 + const matchedDeletions = new Set<string>(); 48 + 49 + for (const deletion of deletions) { 50 + const delContent = deleteContents.get(deletion.path); 51 + if (delContent === null || delContent === undefined) continue; 52 + 53 + let bestScore = 0; 54 + let bestAddition: DetectedChange | null = null; 55 + 56 + for (const addition of additions) { 57 + if (claimed.has(addition.path)) continue; 58 + const addContent = addContents.get(addition.path); 59 + if (addContent === null || addContent === undefined) continue; 60 + 61 + const score = computeSimilarity(delContent, addContent); 62 + if (score > bestScore) { 63 + bestScore = score; 64 + bestAddition = addition; 65 + } 66 + } 67 + 68 + if (bestScore >= threshold && bestAddition) { 69 + moves.push({ 70 + oldPath: deletion.path, 71 + newPath: bestAddition.path, 72 + similarity: bestScore, 73 + }); 74 + claimed.add(bestAddition.path); 75 + matchedDeletions.add(deletion.path); 76 + } 77 + } 78 + 79 + const remainingChanges: DetectedChange[] = [ 80 + ...others, 81 + ...deletions.filter((d) => !matchedDeletions.has(d.path)), 82 + ...additions.filter((a) => !claimed.has(a.path)), 83 + ]; 84 + 85 + return { moves, remainingChanges }; 86 + } 87 + 88 + function computeSimilarity(a: string, b: string): number { 89 + if (a.length > LARGE_FILE_BYTES || b.length > LARGE_FILE_BYTES) { 90 + const len = Math.min(a.length, b.length); 91 + const scores = [0.25, 0.5, 0.75].map((pos) => { 92 + const start = Math.floor(pos * len); 93 + const end = Math.min(start + SAMPLE_WINDOW, len); 94 + const wa = a.slice(start, end); 95 + const wb = b.slice(start, end); 96 + if (wa.length < 2 || wb.length < 2) return 0; 97 + return stringSimilarity(wa, wb); 98 + }); 99 + return scores.reduce((sum, s) => sum + s, 0) / scores.length; 100 + } 101 + return stringSimilarity(a, b); 102 + }
+10 -7
src/vault/snapshot.ts
··· 7 7 } from './types'; 8 8 import type { FilesystemApi } from '../filesystem/types'; 9 9 10 - const SNAPSHOT_FILE = '.textile/snapshot.json'; 10 + const TEXTILE_DIR = '.textile'; 11 + const SNAPSHOT_FILE = `${TEXTILE_DIR}/snapshot.json`; 11 12 12 13 /** 13 14 * Manages `.textile/snapshot.json` for synced vaults. ··· 18 19 19 20 /** 20 21 * Load the snapshot from disk at the given vault root. 21 - * Returns `null` if no snapshot exists. 22 + * Returns `null` if no snapshot exists yet. 22 23 */ 23 24 async load(rootPath: string): Promise<SyncSnapshot | null> { 24 - const snapshotPath = `${rootPath}/${SNAPSHOT_FILE}`; 25 25 try { 26 - const raw = await this.fs.readFile(snapshotPath); 26 + const raw = await this.fs.readFile(`${rootPath}/${SNAPSHOT_FILE}`); 27 27 return this.deserialize(raw); 28 28 } catch { 29 - // Snapshot doesn't exist yet 30 29 return null; 31 30 } 32 31 } 33 32 34 33 /** 35 34 * Save the snapshot to disk at the given vault root. 35 + * Creates `.textile/` if it does not exist. 36 36 */ 37 37 async save(rootPath: string, snapshot: SyncSnapshot): Promise<void> { 38 - const snapshotPath = `${rootPath}/${SNAPSHOT_FILE}`; 38 + const textileDir = `${rootPath}/${TEXTILE_DIR}`; 39 + if (!(await this.fs.fileExists(textileDir))) { 40 + await this.fs.mkdir(textileDir); 41 + } 39 42 const json = this.serialize(snapshot); 40 - await this.fs.writeFile(snapshotPath, json); 43 + await this.fs.writeFile(`${rootPath}/${SNAPSHOT_FILE}`, json); 41 44 } 42 45 43 46 /**
+1 -1
src/vault/types.ts
··· 94 94 /** 95 95 * Change type classification for sync operations. 96 96 */ 97 - export type ChangeType = 'add' | 'edit' | 'delete' | 'move' | 'conflict'; 97 + export type ChangeType = 'add' | 'edit' | 'delete' | 'move'; 98 98 99 99 /** 100 100 * Represents a detected change during three-way comparison