A local-first note taking app
0

Configure Feed

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

Vault integration phase 3: wire synced vaults into the UI

Make synced vaults functional end-to-end by driving the push-only sync
engine from the providers and the composition root:

- SyncedVaultFilesystemProvider: disk tree + CRUD (extends
LocalFilesystemProvider), reconciling each mutation through the engine
(pushFile on create/copy, sync on rename/delete).
- SyncedVaultDocumentsProvider: opens files via the live Automerge session
(multiplayer over libp2p), resolving disk path -> Habitat URI from the
snapshot (pushing first if new), pulling latest on open, and mirroring
session edits back to disk. The session factory is injected (DI) so the
module stays free of the libp2p runtime and is testable.
- engine.ts: add noteMirroredFile() so mirrored in-app edits aren't
re-pushed as phantom external changes.
- registry.ts: persist OpenVault.rootUri.
- App.tsx: provision a synced vault's root directory record on first open,
construct the per-vault engine stack, run the first-mount sync(), and
wire the two synced providers (acquireSession = real acquireDocSession).

Testing uses fakes over mocks: extract InMemoryHabitatRepo +
NodeFilesystemApi to src/vault/testing/fakeHabitat.ts and add a
FakeDocSession sharing one in-memory doc store with the repo. The provider
test runs the real engine/detector/snapshot manager against the fakes and
asserts on real state (on-disk files, snapshot, doc store) — no vi.mock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Ethan Graf (Jun 20, 2026, 12:33 PM EDT) 0a2f013c 0b17f0cd

+813 -156
+85 -10
src/App.tsx
··· 15 15 import { SubtextFilesystemProvider } from './filesystem/providers/subtextFilesystemProvider'; 16 16 import { LocalFilesystemProvider } from './filesystem/providers/localFilesystemProvider'; 17 17 import { RemoteSyncFilesystemProvider } from './filesystem/providers/remoteSyncFilesystemProvider'; 18 + import { SyncedVaultFilesystemProvider } from './filesystem/providers/syncedVaultFilesystemProvider'; 18 19 import { VaultDocumentProvider } from './documents/providers/vaultDocumentsProvider'; 20 + import { SyncedVaultDocumentsProvider } from './documents/providers/syncedVaultDocumentsProvider'; 19 21 import { VaultFilesystemApi } from './filesystem/vaultFs'; 20 22 import { VaultManager } from './components/VaultManager'; 23 + import { HabitatRepoImpl } from './vault/habitat-repo'; 24 + import { VaultSnapshotManager } from './vault/snapshot'; 25 + import { VaultChangeDetector } from './vault/change-detection'; 26 + import { VaultSyncEngine } from './vault/engine'; 27 + import type { HabitatUri, VaultConfig } from './vault/types'; 28 + import { createAutomergeDoc } from './habitat/automergeDoc'; 29 + import { acquireDocSession } from './habitat/automergeDocSession'; 30 + import { HABITAT_VAULT_DIRECTORY_COLLECTION } from './habitat/config'; 21 31 import { 22 32 getOpenVaults, 23 33 addOpenVault, ··· 141 151 const fs = new VaultFilesystemApi(vault); 142 152 return openVaults.map((vaultEntry) => { 143 153 if (vaultEntry.type === 'synced') { 144 - return new RemoteSyncFilesystemProvider({ 145 - id: `vault-${vaultEntry.id}`, 154 + const id = `vault-${vaultEntry.id}`; 155 + // Unprovisioned (created before rootUri existed, or signed out): fall 156 + // back to the read-only stub so the app doesn't crash. 157 + if (!vaultEntry.did || !vaultEntry.rootUri) { 158 + return new RemoteSyncFilesystemProvider({ 159 + id, 160 + rootPath: vaultEntry.path, 161 + displayName: vaultEntry.name, 162 + documents: new VaultDocumentProvider({ fs, rootPath: vaultEntry.path }), 163 + defaultEditorKind: 'automerge', 164 + }); 165 + } 166 + const repo = new HabitatRepoImpl(vaultEntry.did); 167 + const snapshots = new VaultSnapshotManager(fs); 168 + const detector = new VaultChangeDetector(fs, repo); 169 + const engine = new VaultSyncEngine(fs, repo, snapshots, detector); 170 + const vaultConfig: VaultConfig = { 171 + name: vaultEntry.name, 172 + type: 'synced', 173 + rootUri: vaultEntry.rootUri, 174 + createdAt: '', 175 + }; 176 + // First-mount clone + reconcile (out-of-band renames/deletes). 177 + void engine 178 + .sync(vaultConfig, vaultEntry.path) 179 + .catch((e) => console.error('[vault] initial sync failed', e)); 180 + const documents = new SyncedVaultDocumentsProvider({ 181 + id, 182 + vault: vaultConfig, 183 + rootPath: vaultEntry.path, 184 + engine, 185 + snapshots, 186 + fs, 187 + acquireSession: acquireDocSession, 188 + }); 189 + return new SyncedVaultFilesystemProvider({ 190 + id, 146 191 rootPath: vaultEntry.path, 147 192 displayName: vaultEntry.name, 148 - documents: new VaultDocumentProvider({ fs, rootPath: vaultEntry.path }), 193 + documents, 149 194 defaultEditorKind: 'automerge', 195 + fs, 196 + vault: vaultConfig, 197 + engine, 150 198 }); 151 199 } 152 200 return new LocalFilesystemProvider({ ··· 253 301 }, []); 254 302 255 303 const handleOpenVault = useCallback( 256 - (vaultPath: string, vaultType: 'local' | 'synced', did: string | null) => { 304 + async ( 305 + vaultPath: string, 306 + vaultType: 'local' | 'synced', 307 + did: string | null, 308 + ) => { 257 309 const id = vaultIdFromPath(vaultPath); 258 310 const name = vaultPath.split('/').pop() || vaultPath; 259 311 const providerId = `vault-${id}`; 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. 312 + 313 + // Already open — just focus it (avoids duplicate registry entries). 314 + if (getOpenVaults().some((v) => v.id === id)) { 315 + setSelectedProviderId(providerId); 316 + return; 317 + } 318 + 319 + // Provision a synced vault's root directory record on first open so the 320 + // sync engine has a rootUri to clone/sync against. 321 + let rootUri: HabitatUri | undefined; 322 + if (vaultType === 'synced') { 323 + if (!did) { 324 + console.error('[vault] cannot create a synced vault while signed out'); 325 + return; 326 + } 327 + try { 328 + const { uri } = await createAutomergeDoc( 329 + did, 330 + { '@patchwork': { type: 'folder' }, docs: [], name }, 331 + HABITAT_VAULT_DIRECTORY_COLLECTION, 332 + ); 333 + rootUri = uri as HabitatUri; 334 + } catch (e) { 335 + console.error('[vault] failed to provision synced vault root', e); 336 + return; 337 + } 338 + } 339 + 340 + addOpenVault({ id, path: vaultPath, type: vaultType, name, did, rootUri }); 266 341 // Auto-switch to the newly created vault — same path as the selector. 267 342 setSelectedProviderId(providerId); 268 343 },
+198
src/documents/providers/syncedVaultDocumentsProvider.test.ts
··· 1 + import * as nodeFs from 'fs'; 2 + import * as nodePath from 'path'; 3 + import * as os from 'os'; 4 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 5 + 6 + import { VaultSyncEngine } from '../../vault/engine'; 7 + import { VaultChangeDetector } from '../../vault/change-detection'; 8 + import { VaultSnapshotManager } from '../../vault/snapshot'; 9 + import { 10 + InMemoryHabitatRepo, 11 + NodeFilesystemApi, 12 + makeFakeSessions, 13 + type DocStore, 14 + } from '../../vault/testing/fakeHabitat'; 15 + import type { 16 + DirectoryDocument, 17 + FileDocument, 18 + HabitatUri, 19 + VaultConfig, 20 + } from '../../vault/types'; 21 + import { getAutomergePayload } from '../../editors/automerge/automergeEditorBinding'; 22 + import { SyncedVaultDocumentsProvider } from './syncedVaultDocumentsProvider'; 23 + 24 + const MIRROR_DEBOUNCE_MS = 1000; 25 + 26 + function makeRootDirectoryDoc(): DirectoryDocument { 27 + return { '@patchwork': { type: 'folder' }, docs: [], name: 'root' }; 28 + } 29 + 30 + function writeFile(dir: string, relPath: string, content: string): void { 31 + const full = nodePath.join(dir, relPath); 32 + nodeFs.mkdirSync(nodePath.dirname(full), { recursive: true }); 33 + nodeFs.writeFileSync(full, content, 'utf-8'); 34 + } 35 + 36 + function readFile(dir: string, relPath: string): string { 37 + return nodeFs.readFileSync(nodePath.join(dir, relPath), 'utf-8'); 38 + } 39 + 40 + function loadSnapshot(dir: string): { 41 + files: [string, { uri: string; head: string[]; contentHash: string }][]; 42 + directories: [string, unknown][]; 43 + } { 44 + return JSON.parse(readFile(dir, '.textile/snapshot.json')); 45 + } 46 + 47 + function snapshotFile(dir: string, relPath: string) { 48 + return loadSnapshot(dir).files.find(([p]) => p === relPath)?.[1]; 49 + } 50 + 51 + describe('SyncedVaultDocumentsProvider', () => { 52 + let tmpDir: string; 53 + let store: DocStore; 54 + let repo: InMemoryHabitatRepo; 55 + let sessions: ReturnType<typeof makeFakeSessions>; 56 + let engine: VaultSyncEngine; 57 + let vault: VaultConfig; 58 + let provider: SyncedVaultDocumentsProvider; 59 + 60 + beforeEach(async () => { 61 + tmpDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'textile-syncdoc-')); 62 + store = new Map(); 63 + repo = new InMemoryHabitatRepo(store); 64 + 65 + const { uri: rootUri } = await repo.createDirectory(makeRootDirectoryDoc()); 66 + 67 + vault = { 68 + name: 'Test Vault', 69 + type: 'synced', 70 + rootUri, 71 + createdAt: new Date().toISOString(), 72 + }; 73 + 74 + const fs = new NodeFilesystemApi(); 75 + const snapshots = new VaultSnapshotManager(fs); 76 + const detector = new VaultChangeDetector(fs, repo); 77 + engine = new VaultSyncEngine(fs, repo, snapshots, detector); 78 + sessions = makeFakeSessions(store); 79 + 80 + provider = new SyncedVaultDocumentsProvider({ 81 + id: 'vault-test', 82 + vault, 83 + rootPath: tmpDir, 84 + engine, 85 + snapshots, 86 + fs, 87 + acquireSession: sessions.acquire, 88 + }); 89 + }); 90 + 91 + afterEach(() => { 92 + vi.useRealTimers(); 93 + nodeFs.rmSync(tmpDir, { recursive: true, force: true }); 94 + }); 95 + 96 + it('opens an untracked on-disk file by provisioning + tracking the remote doc', async () => { 97 + writeFile(tmpDir, 'note.md', 'hello'); 98 + 99 + const handle = await provider.openDocument(nodePath.join(tmpDir, 'note.md')); 100 + 101 + // Snapshot now tracks the file with a uri. 102 + const entry = snapshotFile(tmpDir, 'note.md'); 103 + expect(entry).toBeDefined(); 104 + const uri = entry!.uri as HabitatUri; 105 + expect(handle.id).toBe(uri); 106 + 107 + // The remote doc was created with the on-disk content. 108 + expect((repo.getDoc<FileDocument>(uri) as unknown as FileDocument).content).toBe('hello'); 109 + 110 + // The editor binding is wired to the live session doc. 111 + const payload = getAutomergePayload(handle.getEditorBinding()); 112 + expect(payload.getDoc().content).toBe('hello'); 113 + }); 114 + 115 + it('pulls newer remote content to disk before binding a tracked file', async () => { 116 + // Establish shared.md locally + remotely. 117 + writeFile(tmpDir, 'shared.md', 'v1'); 118 + await engine.sync(vault, tmpDir); 119 + const uri = snapshotFile(tmpDir, 'shared.md')!.uri as HabitatUri; 120 + 121 + // Remote edit advances the doc; disk still says v1. 122 + const remote = await repo.find<FileDocument>(uri); 123 + remote.change((d) => { 124 + (d as unknown as FileDocument).content = 'v2 from remote'; 125 + }); 126 + const remoteHeads = remote.heads(); 127 + 128 + const handle = await provider.openDocument(nodePath.join(tmpDir, 'shared.md')); 129 + 130 + expect(readFile(tmpDir, 'shared.md')).toBe('v2 from remote'); 131 + expect(getAutomergePayload(handle.getEditorBinding()).getDoc().content).toBe( 132 + 'v2 from remote', 133 + ); 134 + expect(snapshotFile(tmpDir, 'shared.md')!.head).toEqual(remoteHeads); 135 + }); 136 + 137 + it('mirrors in-app edits to disk and keeps the snapshot consistent', async () => { 138 + vi.useFakeTimers(); 139 + writeFile(tmpDir, 'doc.md', 'v1'); 140 + await engine.sync(vault, tmpDir); 141 + const uri = snapshotFile(tmpDir, 'doc.md')!.uri as HabitatUri; 142 + 143 + await provider.openDocument(nodePath.join(tmpDir, 'doc.md')); 144 + 145 + // Edit through the live session, then let the mirror debounce fire. 146 + sessions.get(uri)!.applyChange((d) => { 147 + (d as { content: string }).content = 'edited'; 148 + }); 149 + await vi.advanceTimersByTimeAsync(MIRROR_DEBOUNCE_MS); 150 + 151 + // Mirror wrote to disk; the shared store (remote) reflects the edit too. 152 + expect(readFile(tmpDir, 'doc.md')).toBe('edited'); 153 + expect((repo.getDoc<FileDocument>(uri) as unknown as FileDocument).content).toBe('edited'); 154 + 155 + // The snapshot was updated, so a steady-state sync sees no phantom change. 156 + vi.useRealTimers(); 157 + const result = await engine.sync(vault, tmpDir); 158 + expect(result.pushed).toBe(0); 159 + }); 160 + 161 + it('release marks the session released and stops mirroring', async () => { 162 + vi.useFakeTimers(); 163 + writeFile(tmpDir, 'doc.md', 'v1'); 164 + await engine.sync(vault, tmpDir); 165 + const uri = snapshotFile(tmpDir, 'doc.md')!.uri as HabitatUri; 166 + 167 + const handle = await provider.openDocument(nodePath.join(tmpDir, 'doc.md')); 168 + handle.release(); 169 + 170 + expect(sessions.get(uri)!.released).toBe(true); 171 + 172 + // A post-release edit must not be mirrored to disk. 173 + sessions.get(uri)!.applyChange((d) => { 174 + (d as { content: string }).content = 'after release'; 175 + }); 176 + await vi.advanceTimersByTimeAsync(MIRROR_DEBOUNCE_MS); 177 + expect(readFile(tmpDir, 'doc.md')).toBe('v1'); 178 + }); 179 + 180 + it('createDocument() creates a new untitled file on disk and tracks it', async () => { 181 + const handle = await provider.createDocument(); 182 + 183 + expect(nodeFs.existsSync(nodePath.join(tmpDir, 'Untitled.md'))).toBe(true); 184 + const entry = snapshotFile(tmpDir, 'Untitled.md'); 185 + expect(entry).toBeDefined(); 186 + expect(handle.id).toBe(entry!.uri); 187 + }); 188 + 189 + it('opening the same path twice reuses one session', async () => { 190 + writeFile(tmpDir, 'note.md', 'hello'); 191 + const a = await provider.openDocument(nodePath.join(tmpDir, 'note.md')); 192 + const b = await provider.openDocument(nodePath.join(tmpDir, 'note.md')); 193 + 194 + expect(a.id).toBe(b.id); 195 + // A single FakeDocSession instance backs both handles. 196 + expect(sessions.get(a.id)).toBe(sessions.get(b.id)); 197 + }); 198 + });
+172
src/documents/providers/syncedVaultDocumentsProvider.ts
··· 1 + import * as Automerge from '@automerge/automerge'; 2 + 3 + import { createAutomergeBinding } from '../../editors/automerge/automergeEditorBinding'; 4 + // Type-only import: keeps this module free of the libp2p-backed session runtime 5 + // so it stays importable in tests. The real factory is injected at the 6 + // composition root (see App.tsx); tests inject an in-memory fake. 7 + import type { AutomergeDocSession } from '../../habitat/automergeDocSession'; 8 + import { join } from '../../filesystem/vaultFs'; 9 + import type { FilesystemApi } from '../../filesystem/types'; 10 + import type { VaultSnapshotManager } from '../../vault/snapshot'; 11 + import type { VaultSyncEngine } from '../../vault/engine'; 12 + import type { VaultConfig, HabitatUri } from '../../vault/types'; 13 + import type { DocumentHandle, DocumentProvider } from '../types'; 14 + 15 + export type SyncedVaultDocumentProviderOptions = { 16 + vault: VaultConfig; 17 + /** Absolute path to the vault root. */ 18 + rootPath: string; 19 + engine: VaultSyncEngine; 20 + snapshots: VaultSnapshotManager; 21 + fs: FilesystemApi; 22 + /** 23 + * Live-session factory. Wired to the real `acquireDocSession` at the 24 + * composition root; tests inject an in-memory fake. 25 + */ 26 + acquireSession: (uri: string) => AutomergeDocSession; 27 + }; 28 + 29 + const MIRROR_DEBOUNCE_MS = 1000; 30 + 31 + /** 32 + * Document provider for synced vault files. 33 + * 34 + * Opening a file binds the editor to the live Automerge session 35 + * (`acquireDocSession`), giving real-time multiplayer over libp2p. The session 36 + * is the source of truth; this provider mirrors its content back to the on-disk 37 + * file (debounced) so external tools see current text, and keeps the sync 38 + * snapshot in step so a later `engine.sync()` doesn't re-push the mirror. 39 + */ 40 + export class SyncedVaultDocumentsProvider implements DocumentProvider { 41 + readonly id: string; 42 + private acquireSession: (uri: string) => AutomergeDocSession; 43 + 44 + constructor( 45 + private opts: SyncedVaultDocumentProviderOptions & { id: string }, 46 + ) { 47 + this.id = opts.id; 48 + this.acquireSession = opts.acquireSession; 49 + } 50 + 51 + async openDocument(absPath: string): Promise<DocumentHandle> { 52 + const relPath = this.rel(absPath); 53 + 54 + // Resolve the file's Habitat URI. A brand-new local file isn't in the 55 + // snapshot yet — push it first so a remote doc + uri exist. 56 + let uri = await this.resolveUri(relPath); 57 + if (!uri) { 58 + await this.opts.engine.pushFile(this.opts.vault, this.opts.rootPath, relPath); 59 + uri = await this.resolveUri(relPath); 60 + } 61 + if (!uri) { 62 + throw new Error(`[SyncedVault] could not resolve uri for ${relPath}`); 63 + } 64 + 65 + // Pull the latest merged remote state to disk before binding. 66 + await this.opts.engine.openFile(this.opts.vault, this.opts.rootPath, relPath); 67 + 68 + const session = this.acquireSession(uri); 69 + const stopMirror = this.mirrorSessionToDisk(session, relPath, uri); 70 + 71 + return { 72 + id: session.uri, 73 + loadPromise: session.loadPromise, 74 + getTitle: () => session.getTitle(), 75 + subscribeTitle: () => () => {}, 76 + release: () => { 77 + stopMirror(); 78 + session.release(); 79 + }, 80 + getEditorBinding: () => 81 + createAutomergeBinding({ 82 + getDoc: () => session.doc, 83 + applyChange: session.applyChange, 84 + subscribeToChanges: session.subscribe, 85 + caretProvider: null, 86 + }), 87 + }; 88 + } 89 + 90 + async createDocument(options?: { id?: string }): Promise<DocumentHandle> { 91 + if (options?.id) { 92 + return this.openDocument(options.id); 93 + } 94 + // Create a new untitled file in the vault root, then open it (openDocument 95 + // pushes it to get a uri). 96 + let fileName = 'Untitled.md'; 97 + let filePath = join(this.opts.rootPath, fileName); 98 + let counter = 1; 99 + while (await this.opts.fs.fileExists(filePath)) { 100 + fileName = `Untitled-${counter}.md`; 101 + filePath = join(this.opts.rootPath, fileName); 102 + counter++; 103 + } 104 + await this.opts.fs.writeFile(filePath, ''); 105 + return this.openDocument(filePath); 106 + } 107 + 108 + /** 109 + * Mirror live-session edits to the on-disk file (debounced) and keep the 110 + * snapshot consistent. Returns a stop function that flushes a final write. 111 + */ 112 + private mirrorSessionToDisk( 113 + session: AutomergeDocSession, 114 + relPath: string, 115 + uri: HabitatUri, 116 + ): () => void { 117 + const fullPath = join(this.opts.rootPath, relPath); 118 + let timer: ReturnType<typeof setTimeout> | null = null; 119 + let lastContent: string | null = null; 120 + 121 + const flush = async () => { 122 + const content = this.contentOf(session); 123 + if (content === lastContent) return; 124 + lastContent = content; 125 + try { 126 + await this.opts.fs.writeFile(fullPath, content); 127 + await this.opts.engine.noteMirroredFile( 128 + this.opts.vault, 129 + this.opts.rootPath, 130 + relPath, 131 + uri, 132 + Automerge.getHeads(session.doc), 133 + content, 134 + ); 135 + } catch { 136 + // Best-effort mirror; the live session remains the source of truth. 137 + } 138 + }; 139 + 140 + const unsubscribe = session.subscribe(() => { 141 + if (timer) clearTimeout(timer); 142 + timer = setTimeout(() => { 143 + timer = null; 144 + void flush(); 145 + }, MIRROR_DEBOUNCE_MS); 146 + }); 147 + 148 + return () => { 149 + unsubscribe(); 150 + if (timer) { 151 + clearTimeout(timer); 152 + timer = null; 153 + } 154 + void flush(); 155 + }; 156 + } 157 + 158 + private contentOf(session: AutomergeDocSession): string { 159 + const content = (session.doc as { content?: unknown }).content; 160 + return typeof content === 'string' ? content : ''; 161 + } 162 + 163 + private rel(absPath: string): string { 164 + const prefix = `${this.opts.rootPath}/`; 165 + return absPath.startsWith(prefix) ? absPath.slice(prefix.length) : absPath; 166 + } 167 + 168 + private async resolveUri(relPath: string): Promise<HabitatUri | null> { 169 + const snap = await this.opts.snapshots.load(this.opts.rootPath); 170 + return snap?.files.get(relPath)?.uri ?? null; 171 + } 172 + }
+69
src/filesystem/providers/syncedVaultFilesystemProvider.ts
··· 1 + import type { FileSystemEntry } from '../types'; 2 + import type { VaultConfig } from '../../vault/types'; 3 + import type { VaultSyncEngine } from '../../vault/engine'; 4 + import { 5 + LocalFilesystemProvider, 6 + type LocalFilesystemProviderOptions, 7 + } from './localFilesystemProvider'; 8 + 9 + export type SyncedVaultFilesystemProviderOptions = 10 + LocalFilesystemProviderOptions & { 11 + vault: VaultConfig; 12 + engine: VaultSyncEngine; 13 + }; 14 + 15 + /** 16 + * Filesystem provider for a synced vault. 17 + * 18 + * The sidebar tree and CRUD reuse the on-disk behaviour of 19 + * `LocalFilesystemProvider` (files live on disk after the first-mount clone); 20 + * each mutation then drives the push-only `VaultSyncEngine` to reconcile the 21 + * remote Habitat records. File *content* edits are not handled here — they 22 + * flow through the live Automerge session in `SyncedVaultDocumentsProvider`. 23 + */ 24 + export class SyncedVaultFilesystemProvider extends LocalFilesystemProvider { 25 + private vault: VaultConfig; 26 + private engine: VaultSyncEngine; 27 + 28 + constructor(options: SyncedVaultFilesystemProviderOptions) { 29 + super(options); 30 + this.vault = options.vault; 31 + this.engine = options.engine; 32 + } 33 + 34 + /** Absolute entry id → vault-relative path. */ 35 + private rel(entryId: string): string { 36 + const prefix = `${this.rootPath}/`; 37 + return entryId.startsWith(prefix) ? entryId.slice(prefix.length) : entryId; 38 + } 39 + 40 + async createFile(parentId?: string, name?: string): Promise<FileSystemEntry> { 41 + const entry = await super.createFile(parentId, name); 42 + // Creates the remote file doc + a snapshot entry with its uri. 43 + await this.engine.pushFile(this.vault, this.rootPath, this.rel(entry.id)); 44 + return entry; 45 + } 46 + 47 + // createDirectory: the remote directory doc is created lazily by the engine 48 + // when the first child file is pushed (updateParentDirectories), so the 49 + // disk-only behaviour inherited from LocalFilesystemProvider is sufficient. 50 + 51 + async renameFile(entryId: string, newName: string): Promise<FileSystemEntry> { 52 + const entry = await super.renameFile(entryId, newName); 53 + // Rename = delete old + add new on disk; sync()'s move detection 54 + // reconciles the remote doc in place. 55 + await this.engine.sync(this.vault, this.rootPath); 56 + return entry; 57 + } 58 + 59 + async deleteFile(entryId: string): Promise<void> { 60 + await super.deleteFile(entryId); 61 + await this.engine.sync(this.vault, this.rootPath); 62 + } 63 + 64 + async copyFile(entryId: string): Promise<FileSystemEntry> { 65 + const entry = await super.copyFile(entryId); 66 + await this.engine.pushFile(this.vault, this.rootPath, this.rel(entry.id)); 67 + return entry; 68 + } 69 + }
+1 -144
src/vault/engine.test.ts
··· 1 1 import * as nodeFs from 'fs'; 2 2 import * as nodePath from 'path'; 3 3 import * as os from 'os'; 4 - import * as Automerge from '@automerge/automerge'; 5 4 import { afterEach, beforeEach, describe, expect, it } from 'vitest'; 6 5 7 6 import { VaultSyncEngine } from './engine'; 8 7 import { VaultChangeDetector } from './change-detection'; 9 8 import { VaultSnapshotManager } from './snapshot'; 9 + import { InMemoryHabitatRepo, NodeFilesystemApi } from './testing/fakeHabitat'; 10 10 import type { 11 11 DirectoryDocument, 12 12 FileDocument, 13 - HabitatDocHandle, 14 - HabitatRepo, 15 13 HabitatUri, 16 14 VaultConfig, 17 15 } 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 16 160 17 // --------------------------------------------------------------------------- 161 18 // Test fixture helpers
+27
src/vault/engine.ts
··· 166 166 } 167 167 168 168 /** 169 + * Record that an open file's on-disk mirror now matches the given doc state. 170 + * Called by the synced document provider after it mirrors live-session edits 171 + * to disk, so a later `sync()` doesn't mistake the in-app edit for an 172 + * external on-disk change and redundantly re-push it. `head` is the doc's 173 + * current Automerge heads; `content` is its text. 174 + */ 175 + async noteMirroredFile( 176 + vault: VaultConfig, 177 + rootPath: string, 178 + relPath: string, 179 + uri: HabitatUri, 180 + head: string[], 181 + content: string, 182 + ): Promise<void> { 183 + if (vault.type === 'local') return; 184 + let snap = 185 + (await this.snapshotManager.load(rootPath)) ?? 186 + this.snapshotManager.createEmpty(vault.rootUri); 187 + snap = snapshotWithFile(snap, relPath, { 188 + uri, 189 + head, 190 + contentHash: contentHash(content), 191 + }); 192 + await this.snapshotManager.save(rootPath, snap); 193 + } 194 + 195 + /** 169 196 * Push a single file (add or edit) without scanning the whole tree. Intended 170 197 * for save/edit events. Determines add-vs-edit from the snapshot, skips when 171 198 * the on-disk content is unchanged, and — for new files only — registers the
+8 -2
src/vault/registry.ts
··· 1 - import type { VaultType } from './types'; 1 + import type { HabitatUri, VaultType } from './types'; 2 2 3 3 export interface OpenVault { 4 4 /** Unique identifier for this open vault instance. */ ··· 10 10 type: VaultType; 11 11 /** DID of the Atmosphere account that owns this vault (null for local-only). */ 12 12 did: string | null; 13 + /** 14 + * Habitat URI of the root directory record. Set for synced vaults (created 15 + * when the vault is first provisioned); undefined for local vaults. 16 + */ 17 + rootUri?: HabitatUri; 13 18 } 14 19 15 20 const STORAGE_KEY = 'textile.openVaults'; ··· 23 28 if (!raw) return []; 24 29 const parsed = JSON.parse(raw) as Array<Partial<OpenVault>>; 25 30 if (!Array.isArray(parsed)) return []; 26 - // Migrate old entries that may not have the `did` field. 31 + // Migrate old entries that may not have the `did`/`rootUri` fields. 27 32 return parsed.map((entry) => ({ 28 33 id: entry.id ?? '', 29 34 name: entry.name ?? '', 30 35 path: entry.path ?? '', 31 36 type: entry.type ?? 'local', 32 37 did: entry.did ?? null, 38 + rootUri: entry.rootUri, 33 39 })); 34 40 } catch { 35 41 return [];
+253
src/vault/testing/fakeHabitat.ts
··· 1 + /** 2 + * Test-only in-memory fakes for the vault stack. Imported ONLY by `*.test.ts`. 3 + * 4 + * These are fakes (real in-memory state), not mocks: assertions are made on 5 + * their state — the doc store, on-disk files, the snapshot — rather than on 6 + * call order. The repo and the doc session share a single `DocStore`, so edits 7 + * made through a session are visible to the engine via the repo and vice-versa, 8 + * matching the production relationship where `acquireDocSession` and 9 + * `HabitatRepoImpl` wrap the same underlying record. 10 + */ 11 + import * as nodeFs from 'fs'; 12 + import * as Automerge from '@automerge/automerge'; 13 + 14 + import type { AutomergeDocSession } from '../../habitat/automergeDocSession'; 15 + import type { AutomergeDocSchema } from '../../editors/automerge/automergeEditorBinding'; 16 + import type { FilesystemApi, FsDirent } from '../../filesystem/types'; 17 + import type { 18 + DirectoryDocument, 19 + FileDocument, 20 + HabitatDocHandle, 21 + HabitatRepo, 22 + HabitatUri, 23 + } from '../types'; 24 + 25 + // --------------------------------------------------------------------------- 26 + // Shared in-memory document store 27 + // --------------------------------------------------------------------------- 28 + 29 + export type DocStore = Map<HabitatUri, Automerge.Doc<unknown>>; 30 + 31 + // --------------------------------------------------------------------------- 32 + // Node-native FilesystemApi — a fake of the IPC-backed VaultFilesystemApi that 33 + // writes real files to disk so tests can assert on their contents. 34 + // --------------------------------------------------------------------------- 35 + 36 + export class NodeFilesystemApi implements FilesystemApi { 37 + async readdir(dirPath: string): Promise<FsDirent[]> { 38 + const entries = nodeFs.readdirSync(dirPath, { withFileTypes: true }); 39 + return entries.map((e) => ({ 40 + name: e.name, 41 + isDirectory: e.isDirectory(), 42 + isFile: e.isFile(), 43 + })); 44 + } 45 + 46 + async readFile(filePath: string): Promise<string> { 47 + return nodeFs.readFileSync(filePath, 'utf-8'); 48 + } 49 + 50 + async writeFile(filePath: string, content: string): Promise<void> { 51 + nodeFs.writeFileSync(filePath, content, 'utf-8'); 52 + } 53 + 54 + async fileExists(filePath: string): Promise<boolean> { 55 + return nodeFs.existsSync(filePath); 56 + } 57 + 58 + async mkdir(dirPath: string): Promise<void> { 59 + nodeFs.mkdirSync(dirPath, { recursive: true }); 60 + } 61 + 62 + async rename(oldPath: string, newPath: string): Promise<void> { 63 + nodeFs.renameSync(oldPath, newPath); 64 + } 65 + 66 + async deletePath(filePath: string): Promise<void> { 67 + nodeFs.rmSync(filePath, { recursive: true, force: true }); 68 + } 69 + 70 + async copyFile(src: string, dest: string): Promise<void> { 71 + nodeFs.copyFileSync(src, dest); 72 + } 73 + } 74 + 75 + // --------------------------------------------------------------------------- 76 + // In-memory HabitatRepo backed by real Automerge over a shared DocStore 77 + // --------------------------------------------------------------------------- 78 + 79 + class InMemoryDocHandle<T> implements HabitatDocHandle<T> { 80 + constructor( 81 + private uri: HabitatUri, 82 + private store: DocStore, 83 + ) {} 84 + 85 + private get current(): Automerge.Doc<T> { 86 + return this.store.get(this.uri) as Automerge.Doc<T>; 87 + } 88 + 89 + doc(): Automerge.Doc<T> { 90 + return this.current; 91 + } 92 + 93 + heads(): string[] { 94 + return Automerge.getHeads(this.current); 95 + } 96 + 97 + change(fn: (d: T) => void): void { 98 + const next = Automerge.change(this.current, fn as Automerge.ChangeFn<unknown>); 99 + this.store.set(this.uri, next); 100 + } 101 + 102 + changeAt(heads: string[], fn: (d: T) => void): void { 103 + const result = Automerge.changeAt( 104 + this.current, 105 + heads, 106 + fn as Automerge.ChangeFn<unknown>, 107 + ); 108 + if (result.newHeads !== null) { 109 + this.store.set(this.uri, result.newDoc); 110 + } 111 + } 112 + 113 + view(heads: string[]): Automerge.Doc<T> { 114 + return Automerge.view(this.current, heads) as Automerge.Doc<T>; 115 + } 116 + 117 + on(_event: 'change', _cb: () => void): () => void { 118 + return () => {}; 119 + } 120 + } 121 + 122 + export class InMemoryHabitatRepo implements HabitatRepo { 123 + private counter = 0; 124 + 125 + constructor(public readonly store: DocStore = new Map()) {} 126 + 127 + private makeUri(collection: string): HabitatUri { 128 + return `at://did:plc:test/${collection}/${++this.counter}` as HabitatUri; 129 + } 130 + 131 + private handle<T>(uri: HabitatUri): InMemoryDocHandle<T> { 132 + return new InMemoryDocHandle<T>(uri, this.store); 133 + } 134 + 135 + async find<T>(uri: HabitatUri): Promise<HabitatDocHandle<T>> { 136 + if (!this.store.has(uri)) { 137 + throw new Error(`[InMemoryHabitatRepo] doc not found: ${uri}`); 138 + } 139 + return this.handle<T>(uri); 140 + } 141 + 142 + async createDirectory( 143 + initial: DirectoryDocument, 144 + ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle<DirectoryDocument> }> { 145 + const uri = this.makeUri('network.habitat.vault.directory'); 146 + this.store.set(uri, Automerge.from(initial as unknown as Record<string, unknown>)); 147 + return { uri, handle: this.handle<DirectoryDocument>(uri) }; 148 + } 149 + 150 + async createFile( 151 + initial: FileDocument, 152 + ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle<FileDocument> }> { 153 + const uri = this.makeUri('network.habitat.vault.file'); 154 + this.store.set(uri, Automerge.from(initial as unknown as Record<string, unknown>)); 155 + return { uri, handle: this.handle<FileDocument>(uri) }; 156 + } 157 + 158 + async delete(uri: HabitatUri): Promise<void> { 159 + this.store.delete(uri); 160 + } 161 + 162 + /** Test helper: directly read a doc from the store. */ 163 + getDoc<T>(uri: HabitatUri): Automerge.Doc<T> | undefined { 164 + return this.store.get(uri) as Automerge.Doc<T> | undefined; 165 + } 166 + 167 + /** Test helper: count docs in the store. */ 168 + docCount(): number { 169 + return this.store.size; 170 + } 171 + } 172 + 173 + // --------------------------------------------------------------------------- 174 + // Fake live document session backed by the same DocStore 175 + // --------------------------------------------------------------------------- 176 + 177 + export class FakeDocSession implements AutomergeDocSession { 178 + released = false; 179 + readonly provider = null; 180 + readonly node = null; 181 + readonly ownerRecord = {} as AutomergeDocSession['ownerRecord']; 182 + readonly loadPromise = Promise.resolve(); 183 + 184 + private listeners = new Set<() => void>(); 185 + 186 + constructor( 187 + public readonly uri: string, 188 + private store: DocStore, 189 + ) {} 190 + 191 + get doc(): Automerge.Doc<AutomergeDocSchema> { 192 + return this.store.get(this.uri as HabitatUri) as Automerge.Doc<AutomergeDocSchema>; 193 + } 194 + 195 + getTitle = (): string => { 196 + const doc = this.doc as { name?: unknown } | undefined; 197 + return typeof doc?.name === 'string' ? doc.name : ''; 198 + }; 199 + 200 + applyChange = (fn: (doc: AutomergeDocSchema) => void): void => { 201 + this.store.set( 202 + this.uri as HabitatUri, 203 + Automerge.change(this.doc, fn as Automerge.ChangeFn<unknown>), 204 + ); 205 + this.notify(); 206 + }; 207 + 208 + changeAt = (heads: string[], fn: (doc: AutomergeDocSchema) => void): void => { 209 + const result = Automerge.changeAt( 210 + this.doc, 211 + heads, 212 + fn as Automerge.ChangeFn<unknown>, 213 + ); 214 + if (result.newHeads !== null) { 215 + this.store.set(this.uri as HabitatUri, result.newDoc); 216 + this.notify(); 217 + } 218 + }; 219 + 220 + subscribe = (cb: () => void): (() => void) => { 221 + this.listeners.add(cb); 222 + return () => this.listeners.delete(cb); 223 + }; 224 + 225 + resync = async (): Promise<void> => {}; 226 + 227 + release = (): void => { 228 + this.released = true; 229 + }; 230 + 231 + private notify(): void { 232 + for (const listener of this.listeners) listener(); 233 + } 234 + } 235 + 236 + /** 237 + * Build a fake session factory over a shared `DocStore`. One `FakeDocSession` 238 + * is reused per uri so tests can read its state after opening a document. 239 + */ 240 + export function makeFakeSessions(store: DocStore) { 241 + const sessions = new Map<string, FakeDocSession>(); 242 + return { 243 + acquire: (uri: string): FakeDocSession => { 244 + let session = sessions.get(uri); 245 + if (!session) { 246 + session = new FakeDocSession(uri, store); 247 + sessions.set(uri, session); 248 + } 249 + return session; 250 + }, 251 + get: (uri: string): FakeDocSession | undefined => sessions.get(uri), 252 + }; 253 + }