From 0a2f013cf6811c052d8db099a643e3ed654474dd Mon Sep 17 00:00:00 2001 From: Ethan Graf Date: Sat, 20 Jun 2026 12:33:34 -0400 Subject: [PATCH] Vault integration phase 3: wire synced vaults into the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/App.tsx | 95 ++++++- .../syncedVaultDocumentsProvider.test.ts | 198 ++++++++++++++ .../providers/syncedVaultDocumentsProvider.ts | 172 ++++++++++++ .../syncedVaultFilesystemProvider.ts | 69 +++++ src/vault/engine.test.ts | 145 +--------- src/vault/engine.ts | 27 ++ src/vault/registry.ts | 10 +- src/vault/testing/fakeHabitat.ts | 253 ++++++++++++++++++ 8 files changed, 813 insertions(+), 156 deletions(-) create mode 100644 src/documents/providers/syncedVaultDocumentsProvider.test.ts create mode 100644 src/documents/providers/syncedVaultDocumentsProvider.ts create mode 100644 src/filesystem/providers/syncedVaultFilesystemProvider.ts create mode 100644 src/vault/testing/fakeHabitat.ts diff --git a/src/App.tsx b/src/App.tsx index 34741ef..6bda774 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,9 +15,19 @@ import type { FileSystemProvider } from './filesystem/types'; import { SubtextFilesystemProvider } from './filesystem/providers/subtextFilesystemProvider'; import { LocalFilesystemProvider } from './filesystem/providers/localFilesystemProvider'; import { RemoteSyncFilesystemProvider } from './filesystem/providers/remoteSyncFilesystemProvider'; +import { SyncedVaultFilesystemProvider } from './filesystem/providers/syncedVaultFilesystemProvider'; import { VaultDocumentProvider } from './documents/providers/vaultDocumentsProvider'; +import { SyncedVaultDocumentsProvider } from './documents/providers/syncedVaultDocumentsProvider'; import { VaultFilesystemApi } from './filesystem/vaultFs'; import { VaultManager } from './components/VaultManager'; +import { HabitatRepoImpl } from './vault/habitat-repo'; +import { VaultSnapshotManager } from './vault/snapshot'; +import { VaultChangeDetector } from './vault/change-detection'; +import { VaultSyncEngine } from './vault/engine'; +import type { HabitatUri, VaultConfig } from './vault/types'; +import { createAutomergeDoc } from './habitat/automergeDoc'; +import { acquireDocSession } from './habitat/automergeDocSession'; +import { HABITAT_VAULT_DIRECTORY_COLLECTION } from './habitat/config'; import { getOpenVaults, addOpenVault, @@ -141,12 +151,50 @@ function AppShell() { const fs = new VaultFilesystemApi(vault); return openVaults.map((vaultEntry) => { if (vaultEntry.type === 'synced') { - return new RemoteSyncFilesystemProvider({ - id: `vault-${vaultEntry.id}`, + const id = `vault-${vaultEntry.id}`; + // Unprovisioned (created before rootUri existed, or signed out): fall + // back to the read-only stub so the app doesn't crash. + if (!vaultEntry.did || !vaultEntry.rootUri) { + return new RemoteSyncFilesystemProvider({ + id, + rootPath: vaultEntry.path, + displayName: vaultEntry.name, + documents: new VaultDocumentProvider({ fs, rootPath: vaultEntry.path }), + defaultEditorKind: 'automerge', + }); + } + const repo = new HabitatRepoImpl(vaultEntry.did); + const snapshots = new VaultSnapshotManager(fs); + const detector = new VaultChangeDetector(fs, repo); + const engine = new VaultSyncEngine(fs, repo, snapshots, detector); + const vaultConfig: VaultConfig = { + name: vaultEntry.name, + type: 'synced', + rootUri: vaultEntry.rootUri, + createdAt: '', + }; + // First-mount clone + reconcile (out-of-band renames/deletes). + void engine + .sync(vaultConfig, vaultEntry.path) + .catch((e) => console.error('[vault] initial sync failed', e)); + const documents = new SyncedVaultDocumentsProvider({ + id, + vault: vaultConfig, + rootPath: vaultEntry.path, + engine, + snapshots, + fs, + acquireSession: acquireDocSession, + }); + return new SyncedVaultFilesystemProvider({ + id, rootPath: vaultEntry.path, displayName: vaultEntry.name, - documents: new VaultDocumentProvider({ fs, rootPath: vaultEntry.path }), + documents, defaultEditorKind: 'automerge', + fs, + vault: vaultConfig, + engine, }); } return new LocalFilesystemProvider({ @@ -253,16 +301,43 @@ function AppShell() { }, []); const handleOpenVault = useCallback( - (vaultPath: string, vaultType: 'local' | 'synced', did: string | null) => { + async ( + vaultPath: string, + vaultType: 'local' | 'synced', + did: string | null, + ) => { const id = vaultIdFromPath(vaultPath); const name = vaultPath.split('/').pop() || vaultPath; const providerId = `vault-${id}`; - addOpenVault({ id, path: vaultPath, type: vaultType, name, did }); - // TODO: On first mount of a synced vault, run a top-level - // VaultSyncEngine.sync() once — it performs the one-time remote clone and - // reconciles any out-of-band changes (renames/deletes) made while the app - // was closed. Steady-state edits should then go through - // VaultSyncEngine.pushFile/openFile rather than full syncs. + + // Already open — just focus it (avoids duplicate registry entries). + if (getOpenVaults().some((v) => v.id === id)) { + setSelectedProviderId(providerId); + return; + } + + // Provision a synced vault's root directory record on first open so the + // sync engine has a rootUri to clone/sync against. + let rootUri: HabitatUri | undefined; + if (vaultType === 'synced') { + if (!did) { + console.error('[vault] cannot create a synced vault while signed out'); + return; + } + try { + const { uri } = await createAutomergeDoc( + did, + { '@patchwork': { type: 'folder' }, docs: [], name }, + HABITAT_VAULT_DIRECTORY_COLLECTION, + ); + rootUri = uri as HabitatUri; + } catch (e) { + console.error('[vault] failed to provision synced vault root', e); + return; + } + } + + addOpenVault({ id, path: vaultPath, type: vaultType, name, did, rootUri }); // Auto-switch to the newly created vault — same path as the selector. setSelectedProviderId(providerId); }, diff --git a/src/documents/providers/syncedVaultDocumentsProvider.test.ts b/src/documents/providers/syncedVaultDocumentsProvider.test.ts new file mode 100644 index 0000000..4da828a --- /dev/null +++ b/src/documents/providers/syncedVaultDocumentsProvider.test.ts @@ -0,0 +1,198 @@ +import * as nodeFs from 'fs'; +import * as nodePath from 'path'; +import * as os from 'os'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { VaultSyncEngine } from '../../vault/engine'; +import { VaultChangeDetector } from '../../vault/change-detection'; +import { VaultSnapshotManager } from '../../vault/snapshot'; +import { + InMemoryHabitatRepo, + NodeFilesystemApi, + makeFakeSessions, + type DocStore, +} from '../../vault/testing/fakeHabitat'; +import type { + DirectoryDocument, + FileDocument, + HabitatUri, + VaultConfig, +} from '../../vault/types'; +import { getAutomergePayload } from '../../editors/automerge/automergeEditorBinding'; +import { SyncedVaultDocumentsProvider } from './syncedVaultDocumentsProvider'; + +const MIRROR_DEBOUNCE_MS = 1000; + +function makeRootDirectoryDoc(): DirectoryDocument { + return { '@patchwork': { type: 'folder' }, docs: [], name: 'root' }; +} + +function writeFile(dir: string, relPath: string, content: string): void { + const full = nodePath.join(dir, relPath); + nodeFs.mkdirSync(nodePath.dirname(full), { recursive: true }); + nodeFs.writeFileSync(full, content, 'utf-8'); +} + +function readFile(dir: string, relPath: string): string { + return nodeFs.readFileSync(nodePath.join(dir, relPath), 'utf-8'); +} + +function loadSnapshot(dir: string): { + files: [string, { uri: string; head: string[]; contentHash: string }][]; + directories: [string, unknown][]; +} { + return JSON.parse(readFile(dir, '.textile/snapshot.json')); +} + +function snapshotFile(dir: string, relPath: string) { + return loadSnapshot(dir).files.find(([p]) => p === relPath)?.[1]; +} + +describe('SyncedVaultDocumentsProvider', () => { + let tmpDir: string; + let store: DocStore; + let repo: InMemoryHabitatRepo; + let sessions: ReturnType; + let engine: VaultSyncEngine; + let vault: VaultConfig; + let provider: SyncedVaultDocumentsProvider; + + beforeEach(async () => { + tmpDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'textile-syncdoc-')); + store = new Map(); + repo = new InMemoryHabitatRepo(store); + + const { uri: rootUri } = await repo.createDirectory(makeRootDirectoryDoc()); + + vault = { + name: 'Test Vault', + type: 'synced', + rootUri, + createdAt: new Date().toISOString(), + }; + + const fs = new NodeFilesystemApi(); + const snapshots = new VaultSnapshotManager(fs); + const detector = new VaultChangeDetector(fs, repo); + engine = new VaultSyncEngine(fs, repo, snapshots, detector); + sessions = makeFakeSessions(store); + + provider = new SyncedVaultDocumentsProvider({ + id: 'vault-test', + vault, + rootPath: tmpDir, + engine, + snapshots, + fs, + acquireSession: sessions.acquire, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + nodeFs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('opens an untracked on-disk file by provisioning + tracking the remote doc', async () => { + writeFile(tmpDir, 'note.md', 'hello'); + + const handle = await provider.openDocument(nodePath.join(tmpDir, 'note.md')); + + // Snapshot now tracks the file with a uri. + const entry = snapshotFile(tmpDir, 'note.md'); + expect(entry).toBeDefined(); + const uri = entry!.uri as HabitatUri; + expect(handle.id).toBe(uri); + + // The remote doc was created with the on-disk content. + expect((repo.getDoc(uri) as unknown as FileDocument).content).toBe('hello'); + + // The editor binding is wired to the live session doc. + const payload = getAutomergePayload(handle.getEditorBinding()); + expect(payload.getDoc().content).toBe('hello'); + }); + + it('pulls newer remote content to disk before binding a tracked file', async () => { + // Establish shared.md locally + remotely. + writeFile(tmpDir, 'shared.md', 'v1'); + await engine.sync(vault, tmpDir); + const uri = snapshotFile(tmpDir, 'shared.md')!.uri as HabitatUri; + + // Remote edit advances the doc; disk still says v1. + const remote = await repo.find(uri); + remote.change((d) => { + (d as unknown as FileDocument).content = 'v2 from remote'; + }); + const remoteHeads = remote.heads(); + + const handle = await provider.openDocument(nodePath.join(tmpDir, 'shared.md')); + + expect(readFile(tmpDir, 'shared.md')).toBe('v2 from remote'); + expect(getAutomergePayload(handle.getEditorBinding()).getDoc().content).toBe( + 'v2 from remote', + ); + expect(snapshotFile(tmpDir, 'shared.md')!.head).toEqual(remoteHeads); + }); + + it('mirrors in-app edits to disk and keeps the snapshot consistent', async () => { + vi.useFakeTimers(); + writeFile(tmpDir, 'doc.md', 'v1'); + await engine.sync(vault, tmpDir); + const uri = snapshotFile(tmpDir, 'doc.md')!.uri as HabitatUri; + + await provider.openDocument(nodePath.join(tmpDir, 'doc.md')); + + // Edit through the live session, then let the mirror debounce fire. + sessions.get(uri)!.applyChange((d) => { + (d as { content: string }).content = 'edited'; + }); + await vi.advanceTimersByTimeAsync(MIRROR_DEBOUNCE_MS); + + // Mirror wrote to disk; the shared store (remote) reflects the edit too. + expect(readFile(tmpDir, 'doc.md')).toBe('edited'); + expect((repo.getDoc(uri) as unknown as FileDocument).content).toBe('edited'); + + // The snapshot was updated, so a steady-state sync sees no phantom change. + vi.useRealTimers(); + const result = await engine.sync(vault, tmpDir); + expect(result.pushed).toBe(0); + }); + + it('release marks the session released and stops mirroring', async () => { + vi.useFakeTimers(); + writeFile(tmpDir, 'doc.md', 'v1'); + await engine.sync(vault, tmpDir); + const uri = snapshotFile(tmpDir, 'doc.md')!.uri as HabitatUri; + + const handle = await provider.openDocument(nodePath.join(tmpDir, 'doc.md')); + handle.release(); + + expect(sessions.get(uri)!.released).toBe(true); + + // A post-release edit must not be mirrored to disk. + sessions.get(uri)!.applyChange((d) => { + (d as { content: string }).content = 'after release'; + }); + await vi.advanceTimersByTimeAsync(MIRROR_DEBOUNCE_MS); + expect(readFile(tmpDir, 'doc.md')).toBe('v1'); + }); + + it('createDocument() creates a new untitled file on disk and tracks it', async () => { + const handle = await provider.createDocument(); + + expect(nodeFs.existsSync(nodePath.join(tmpDir, 'Untitled.md'))).toBe(true); + const entry = snapshotFile(tmpDir, 'Untitled.md'); + expect(entry).toBeDefined(); + expect(handle.id).toBe(entry!.uri); + }); + + it('opening the same path twice reuses one session', async () => { + writeFile(tmpDir, 'note.md', 'hello'); + const a = await provider.openDocument(nodePath.join(tmpDir, 'note.md')); + const b = await provider.openDocument(nodePath.join(tmpDir, 'note.md')); + + expect(a.id).toBe(b.id); + // A single FakeDocSession instance backs both handles. + expect(sessions.get(a.id)).toBe(sessions.get(b.id)); + }); +}); diff --git a/src/documents/providers/syncedVaultDocumentsProvider.ts b/src/documents/providers/syncedVaultDocumentsProvider.ts new file mode 100644 index 0000000..d56482d --- /dev/null +++ b/src/documents/providers/syncedVaultDocumentsProvider.ts @@ -0,0 +1,172 @@ +import * as Automerge from '@automerge/automerge'; + +import { createAutomergeBinding } from '../../editors/automerge/automergeEditorBinding'; +// Type-only import: keeps this module free of the libp2p-backed session runtime +// so it stays importable in tests. The real factory is injected at the +// composition root (see App.tsx); tests inject an in-memory fake. +import type { AutomergeDocSession } from '../../habitat/automergeDocSession'; +import { join } from '../../filesystem/vaultFs'; +import type { FilesystemApi } from '../../filesystem/types'; +import type { VaultSnapshotManager } from '../../vault/snapshot'; +import type { VaultSyncEngine } from '../../vault/engine'; +import type { VaultConfig, HabitatUri } from '../../vault/types'; +import type { DocumentHandle, DocumentProvider } from '../types'; + +export type SyncedVaultDocumentProviderOptions = { + vault: VaultConfig; + /** Absolute path to the vault root. */ + rootPath: string; + engine: VaultSyncEngine; + snapshots: VaultSnapshotManager; + fs: FilesystemApi; + /** + * Live-session factory. Wired to the real `acquireDocSession` at the + * composition root; tests inject an in-memory fake. + */ + acquireSession: (uri: string) => AutomergeDocSession; +}; + +const MIRROR_DEBOUNCE_MS = 1000; + +/** + * Document provider for synced vault files. + * + * Opening a file binds the editor to the live Automerge session + * (`acquireDocSession`), giving real-time multiplayer over libp2p. The session + * is the source of truth; this provider mirrors its content back to the on-disk + * file (debounced) so external tools see current text, and keeps the sync + * snapshot in step so a later `engine.sync()` doesn't re-push the mirror. + */ +export class SyncedVaultDocumentsProvider implements DocumentProvider { + readonly id: string; + private acquireSession: (uri: string) => AutomergeDocSession; + + constructor( + private opts: SyncedVaultDocumentProviderOptions & { id: string }, + ) { + this.id = opts.id; + this.acquireSession = opts.acquireSession; + } + + async openDocument(absPath: string): Promise { + const relPath = this.rel(absPath); + + // Resolve the file's Habitat URI. A brand-new local file isn't in the + // snapshot yet — push it first so a remote doc + uri exist. + let uri = await this.resolveUri(relPath); + if (!uri) { + await this.opts.engine.pushFile(this.opts.vault, this.opts.rootPath, relPath); + uri = await this.resolveUri(relPath); + } + if (!uri) { + throw new Error(`[SyncedVault] could not resolve uri for ${relPath}`); + } + + // Pull the latest merged remote state to disk before binding. + await this.opts.engine.openFile(this.opts.vault, this.opts.rootPath, relPath); + + const session = this.acquireSession(uri); + const stopMirror = this.mirrorSessionToDisk(session, relPath, uri); + + return { + id: session.uri, + loadPromise: session.loadPromise, + getTitle: () => session.getTitle(), + subscribeTitle: () => () => {}, + release: () => { + stopMirror(); + session.release(); + }, + getEditorBinding: () => + createAutomergeBinding({ + getDoc: () => session.doc, + applyChange: session.applyChange, + subscribeToChanges: session.subscribe, + caretProvider: null, + }), + }; + } + + async createDocument(options?: { id?: string }): Promise { + if (options?.id) { + return this.openDocument(options.id); + } + // Create a new untitled file in the vault root, then open it (openDocument + // pushes it to get a uri). + let fileName = 'Untitled.md'; + let filePath = join(this.opts.rootPath, fileName); + let counter = 1; + while (await this.opts.fs.fileExists(filePath)) { + fileName = `Untitled-${counter}.md`; + filePath = join(this.opts.rootPath, fileName); + counter++; + } + await this.opts.fs.writeFile(filePath, ''); + return this.openDocument(filePath); + } + + /** + * Mirror live-session edits to the on-disk file (debounced) and keep the + * snapshot consistent. Returns a stop function that flushes a final write. + */ + private mirrorSessionToDisk( + session: AutomergeDocSession, + relPath: string, + uri: HabitatUri, + ): () => void { + const fullPath = join(this.opts.rootPath, relPath); + let timer: ReturnType | null = null; + let lastContent: string | null = null; + + const flush = async () => { + const content = this.contentOf(session); + if (content === lastContent) return; + lastContent = content; + try { + await this.opts.fs.writeFile(fullPath, content); + await this.opts.engine.noteMirroredFile( + this.opts.vault, + this.opts.rootPath, + relPath, + uri, + Automerge.getHeads(session.doc), + content, + ); + } catch { + // Best-effort mirror; the live session remains the source of truth. + } + }; + + const unsubscribe = session.subscribe(() => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + void flush(); + }, MIRROR_DEBOUNCE_MS); + }); + + return () => { + unsubscribe(); + if (timer) { + clearTimeout(timer); + timer = null; + } + void flush(); + }; + } + + private contentOf(session: AutomergeDocSession): string { + const content = (session.doc as { content?: unknown }).content; + return typeof content === 'string' ? content : ''; + } + + private rel(absPath: string): string { + const prefix = `${this.opts.rootPath}/`; + return absPath.startsWith(prefix) ? absPath.slice(prefix.length) : absPath; + } + + private async resolveUri(relPath: string): Promise { + const snap = await this.opts.snapshots.load(this.opts.rootPath); + return snap?.files.get(relPath)?.uri ?? null; + } +} diff --git a/src/filesystem/providers/syncedVaultFilesystemProvider.ts b/src/filesystem/providers/syncedVaultFilesystemProvider.ts new file mode 100644 index 0000000..e5ea103 --- /dev/null +++ b/src/filesystem/providers/syncedVaultFilesystemProvider.ts @@ -0,0 +1,69 @@ +import type { FileSystemEntry } from '../types'; +import type { VaultConfig } from '../../vault/types'; +import type { VaultSyncEngine } from '../../vault/engine'; +import { + LocalFilesystemProvider, + type LocalFilesystemProviderOptions, +} from './localFilesystemProvider'; + +export type SyncedVaultFilesystemProviderOptions = + LocalFilesystemProviderOptions & { + vault: VaultConfig; + engine: VaultSyncEngine; + }; + +/** + * Filesystem provider for a synced vault. + * + * The sidebar tree and CRUD reuse the on-disk behaviour of + * `LocalFilesystemProvider` (files live on disk after the first-mount clone); + * each mutation then drives the push-only `VaultSyncEngine` to reconcile the + * remote Habitat records. File *content* edits are not handled here — they + * flow through the live Automerge session in `SyncedVaultDocumentsProvider`. + */ +export class SyncedVaultFilesystemProvider extends LocalFilesystemProvider { + private vault: VaultConfig; + private engine: VaultSyncEngine; + + constructor(options: SyncedVaultFilesystemProviderOptions) { + super(options); + this.vault = options.vault; + this.engine = options.engine; + } + + /** Absolute entry id → vault-relative path. */ + private rel(entryId: string): string { + const prefix = `${this.rootPath}/`; + return entryId.startsWith(prefix) ? entryId.slice(prefix.length) : entryId; + } + + async createFile(parentId?: string, name?: string): Promise { + const entry = await super.createFile(parentId, name); + // Creates the remote file doc + a snapshot entry with its uri. + await this.engine.pushFile(this.vault, this.rootPath, this.rel(entry.id)); + return entry; + } + + // createDirectory: the remote directory doc is created lazily by the engine + // when the first child file is pushed (updateParentDirectories), so the + // disk-only behaviour inherited from LocalFilesystemProvider is sufficient. + + async renameFile(entryId: string, newName: string): Promise { + const entry = await super.renameFile(entryId, newName); + // Rename = delete old + add new on disk; sync()'s move detection + // reconciles the remote doc in place. + await this.engine.sync(this.vault, this.rootPath); + return entry; + } + + async deleteFile(entryId: string): Promise { + await super.deleteFile(entryId); + await this.engine.sync(this.vault, this.rootPath); + } + + async copyFile(entryId: string): Promise { + const entry = await super.copyFile(entryId); + await this.engine.pushFile(this.vault, this.rootPath, this.rel(entry.id)); + return entry; + } +} diff --git a/src/vault/engine.test.ts b/src/vault/engine.test.ts index 20b196a..110f36a 100644 --- a/src/vault/engine.test.ts +++ b/src/vault/engine.test.ts @@ -1,161 +1,18 @@ import * as nodeFs from 'fs'; import * as nodePath from 'path'; import * as os from 'os'; -import * as Automerge from '@automerge/automerge'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { VaultSyncEngine } from './engine'; import { VaultChangeDetector } from './change-detection'; import { VaultSnapshotManager } from './snapshot'; +import { InMemoryHabitatRepo, NodeFilesystemApi } from './testing/fakeHabitat'; import type { DirectoryDocument, FileDocument, - HabitatDocHandle, - HabitatRepo, HabitatUri, VaultConfig, } from './types'; -import type { FilesystemApi, FsDirent } from '../filesystem/types'; - -// --------------------------------------------------------------------------- -// Node-native FilesystemApi -// --------------------------------------------------------------------------- - -class NodeFilesystemApi implements FilesystemApi { - async readdir(dirPath: string): Promise { - const entries = nodeFs.readdirSync(dirPath, { withFileTypes: true }); - return entries.map((e) => ({ - name: e.name, - isDirectory: e.isDirectory(), - isFile: e.isFile(), - })); - } - - async readFile(filePath: string): Promise { - return nodeFs.readFileSync(filePath, 'utf-8'); - } - - async writeFile(filePath: string, content: string): Promise { - nodeFs.writeFileSync(filePath, content, 'utf-8'); - } - - async fileExists(filePath: string): Promise { - return nodeFs.existsSync(filePath); - } - - async mkdir(dirPath: string): Promise { - nodeFs.mkdirSync(dirPath, { recursive: true }); - } - - async rename(oldPath: string, newPath: string): Promise { - nodeFs.renameSync(oldPath, newPath); - } - - async deletePath(filePath: string): Promise { - nodeFs.rmSync(filePath, { recursive: true, force: true }); - } - - async copyFile(src: string, dest: string): Promise { - nodeFs.copyFileSync(src, dest); - } -} - -// --------------------------------------------------------------------------- -// In-memory HabitatRepo backed by real Automerge -// --------------------------------------------------------------------------- - -class InMemoryDocHandle implements HabitatDocHandle { - constructor( - private uri: HabitatUri, - private store: Map>, - ) {} - - private get current(): Automerge.Doc { - return this.store.get(this.uri) as Automerge.Doc; - } - - doc(): Automerge.Doc { - return this.current; - } - - heads(): string[] { - return Automerge.getHeads(this.current); - } - - change(fn: (d: T) => void): void { - const next = Automerge.change(this.current, fn as Automerge.ChangeFn); - this.store.set(this.uri, next); - } - - changeAt(heads: string[], fn: (d: T) => void): void { - const result = Automerge.changeAt( - this.current, - heads, - fn as Automerge.ChangeFn, - ); - if (result.newHeads !== null) { - this.store.set(this.uri, result.newDoc); - } - } - - view(heads: string[]): Automerge.Doc { - return Automerge.view(this.current, heads) as Automerge.Doc; - } - - on(_event: 'change', _cb: () => void): () => void { - return () => {}; - } -} - -class InMemoryHabitatRepo implements HabitatRepo { - private store = new Map>(); - private counter = 0; - - private makeUri(collection: string): HabitatUri { - return `at://did:plc:test/${collection}/${++this.counter}` as HabitatUri; - } - - private handle(uri: HabitatUri): InMemoryDocHandle { - return new InMemoryDocHandle(uri, this.store); - } - - async find(uri: HabitatUri): Promise> { - if (!this.store.has(uri)) { - throw new Error(`[InMemoryHabitatRepo] doc not found: ${uri}`); - } - return this.handle(uri); - } - - async createDirectory( - initial: DirectoryDocument, - ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle }> { - const uri = this.makeUri('network.habitat.vault.directory'); - this.store.set(uri, Automerge.from(initial as unknown as Record)); - return { uri, handle: this.handle(uri) }; - } - - async createFile( - initial: FileDocument, - ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle }> { - const uri = this.makeUri('network.habitat.vault.file'); - this.store.set(uri, Automerge.from(initial as unknown as Record)); - return { uri, handle: this.handle(uri) }; - } - - async delete(uri: HabitatUri): Promise { - this.store.delete(uri); - } - - /** Test helper: directly read a doc from the store. */ - getDoc(uri: HabitatUri): Automerge.Doc | undefined { - return this.store.get(uri) as Automerge.Doc | undefined; - } - - /** Test helper: count docs in the store. */ - docCount(): number { - return this.store.size; - } -} // --------------------------------------------------------------------------- // Test fixture helpers diff --git a/src/vault/engine.ts b/src/vault/engine.ts index 8e94198..f399151 100644 --- a/src/vault/engine.ts +++ b/src/vault/engine.ts @@ -165,6 +165,33 @@ export class VaultSyncEngine { await this.snapshotManager.save(rootPath, snap); } + /** + * Record that an open file's on-disk mirror now matches the given doc state. + * Called by the synced document provider after it mirrors live-session edits + * to disk, so a later `sync()` doesn't mistake the in-app edit for an + * external on-disk change and redundantly re-push it. `head` is the doc's + * current Automerge heads; `content` is its text. + */ + async noteMirroredFile( + vault: VaultConfig, + rootPath: string, + relPath: string, + uri: HabitatUri, + head: string[], + content: string, + ): Promise { + if (vault.type === 'local') return; + let snap = + (await this.snapshotManager.load(rootPath)) ?? + this.snapshotManager.createEmpty(vault.rootUri); + snap = snapshotWithFile(snap, relPath, { + uri, + head, + contentHash: contentHash(content), + }); + await this.snapshotManager.save(rootPath, snap); + } + /** * Push a single file (add or edit) without scanning the whole tree. Intended * for save/edit events. Determines add-vs-edit from the snapshot, skips when diff --git a/src/vault/registry.ts b/src/vault/registry.ts index f0701d7..f143298 100644 --- a/src/vault/registry.ts +++ b/src/vault/registry.ts @@ -1,4 +1,4 @@ -import type { VaultType } from './types'; +import type { HabitatUri, VaultType } from './types'; export interface OpenVault { /** Unique identifier for this open vault instance. */ @@ -10,6 +10,11 @@ export interface OpenVault { type: VaultType; /** DID of the Atmosphere account that owns this vault (null for local-only). */ did: string | null; + /** + * Habitat URI of the root directory record. Set for synced vaults (created + * when the vault is first provisioned); undefined for local vaults. + */ + rootUri?: HabitatUri; } const STORAGE_KEY = 'textile.openVaults'; @@ -23,13 +28,14 @@ function loadPersisted(): OpenVault[] { if (!raw) return []; const parsed = JSON.parse(raw) as Array>; if (!Array.isArray(parsed)) return []; - // Migrate old entries that may not have the `did` field. + // Migrate old entries that may not have the `did`/`rootUri` fields. return parsed.map((entry) => ({ id: entry.id ?? '', name: entry.name ?? '', path: entry.path ?? '', type: entry.type ?? 'local', did: entry.did ?? null, + rootUri: entry.rootUri, })); } catch { return []; diff --git a/src/vault/testing/fakeHabitat.ts b/src/vault/testing/fakeHabitat.ts new file mode 100644 index 0000000..2c9c78d --- /dev/null +++ b/src/vault/testing/fakeHabitat.ts @@ -0,0 +1,253 @@ +/** + * Test-only in-memory fakes for the vault stack. Imported ONLY by `*.test.ts`. + * + * These are fakes (real in-memory state), not mocks: assertions are made on + * their state — the doc store, on-disk files, the snapshot — rather than on + * call order. The repo and the doc session share a single `DocStore`, so edits + * made through a session are visible to the engine via the repo and vice-versa, + * matching the production relationship where `acquireDocSession` and + * `HabitatRepoImpl` wrap the same underlying record. + */ +import * as nodeFs from 'fs'; +import * as Automerge from '@automerge/automerge'; + +import type { AutomergeDocSession } from '../../habitat/automergeDocSession'; +import type { AutomergeDocSchema } from '../../editors/automerge/automergeEditorBinding'; +import type { FilesystemApi, FsDirent } from '../../filesystem/types'; +import type { + DirectoryDocument, + FileDocument, + HabitatDocHandle, + HabitatRepo, + HabitatUri, +} from '../types'; + +// --------------------------------------------------------------------------- +// Shared in-memory document store +// --------------------------------------------------------------------------- + +export type DocStore = Map>; + +// --------------------------------------------------------------------------- +// Node-native FilesystemApi — a fake of the IPC-backed VaultFilesystemApi that +// writes real files to disk so tests can assert on their contents. +// --------------------------------------------------------------------------- + +export class NodeFilesystemApi implements FilesystemApi { + async readdir(dirPath: string): Promise { + const entries = nodeFs.readdirSync(dirPath, { withFileTypes: true }); + return entries.map((e) => ({ + name: e.name, + isDirectory: e.isDirectory(), + isFile: e.isFile(), + })); + } + + async readFile(filePath: string): Promise { + return nodeFs.readFileSync(filePath, 'utf-8'); + } + + async writeFile(filePath: string, content: string): Promise { + nodeFs.writeFileSync(filePath, content, 'utf-8'); + } + + async fileExists(filePath: string): Promise { + return nodeFs.existsSync(filePath); + } + + async mkdir(dirPath: string): Promise { + nodeFs.mkdirSync(dirPath, { recursive: true }); + } + + async rename(oldPath: string, newPath: string): Promise { + nodeFs.renameSync(oldPath, newPath); + } + + async deletePath(filePath: string): Promise { + nodeFs.rmSync(filePath, { recursive: true, force: true }); + } + + async copyFile(src: string, dest: string): Promise { + nodeFs.copyFileSync(src, dest); + } +} + +// --------------------------------------------------------------------------- +// In-memory HabitatRepo backed by real Automerge over a shared DocStore +// --------------------------------------------------------------------------- + +class InMemoryDocHandle implements HabitatDocHandle { + constructor( + private uri: HabitatUri, + private store: DocStore, + ) {} + + private get current(): Automerge.Doc { + return this.store.get(this.uri) as Automerge.Doc; + } + + doc(): Automerge.Doc { + return this.current; + } + + heads(): string[] { + return Automerge.getHeads(this.current); + } + + change(fn: (d: T) => void): void { + const next = Automerge.change(this.current, fn as Automerge.ChangeFn); + this.store.set(this.uri, next); + } + + changeAt(heads: string[], fn: (d: T) => void): void { + const result = Automerge.changeAt( + this.current, + heads, + fn as Automerge.ChangeFn, + ); + if (result.newHeads !== null) { + this.store.set(this.uri, result.newDoc); + } + } + + view(heads: string[]): Automerge.Doc { + return Automerge.view(this.current, heads) as Automerge.Doc; + } + + on(_event: 'change', _cb: () => void): () => void { + return () => {}; + } +} + +export class InMemoryHabitatRepo implements HabitatRepo { + private counter = 0; + + constructor(public readonly store: DocStore = new Map()) {} + + private makeUri(collection: string): HabitatUri { + return `at://did:plc:test/${collection}/${++this.counter}` as HabitatUri; + } + + private handle(uri: HabitatUri): InMemoryDocHandle { + return new InMemoryDocHandle(uri, this.store); + } + + async find(uri: HabitatUri): Promise> { + if (!this.store.has(uri)) { + throw new Error(`[InMemoryHabitatRepo] doc not found: ${uri}`); + } + return this.handle(uri); + } + + async createDirectory( + initial: DirectoryDocument, + ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle }> { + const uri = this.makeUri('network.habitat.vault.directory'); + this.store.set(uri, Automerge.from(initial as unknown as Record)); + return { uri, handle: this.handle(uri) }; + } + + async createFile( + initial: FileDocument, + ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle }> { + const uri = this.makeUri('network.habitat.vault.file'); + this.store.set(uri, Automerge.from(initial as unknown as Record)); + return { uri, handle: this.handle(uri) }; + } + + async delete(uri: HabitatUri): Promise { + this.store.delete(uri); + } + + /** Test helper: directly read a doc from the store. */ + getDoc(uri: HabitatUri): Automerge.Doc | undefined { + return this.store.get(uri) as Automerge.Doc | undefined; + } + + /** Test helper: count docs in the store. */ + docCount(): number { + return this.store.size; + } +} + +// --------------------------------------------------------------------------- +// Fake live document session backed by the same DocStore +// --------------------------------------------------------------------------- + +export class FakeDocSession implements AutomergeDocSession { + released = false; + readonly provider = null; + readonly node = null; + readonly ownerRecord = {} as AutomergeDocSession['ownerRecord']; + readonly loadPromise = Promise.resolve(); + + private listeners = new Set<() => void>(); + + constructor( + public readonly uri: string, + private store: DocStore, + ) {} + + get doc(): Automerge.Doc { + return this.store.get(this.uri as HabitatUri) as Automerge.Doc; + } + + getTitle = (): string => { + const doc = this.doc as { name?: unknown } | undefined; + return typeof doc?.name === 'string' ? doc.name : ''; + }; + + applyChange = (fn: (doc: AutomergeDocSchema) => void): void => { + this.store.set( + this.uri as HabitatUri, + Automerge.change(this.doc, fn as Automerge.ChangeFn), + ); + this.notify(); + }; + + changeAt = (heads: string[], fn: (doc: AutomergeDocSchema) => void): void => { + const result = Automerge.changeAt( + this.doc, + heads, + fn as Automerge.ChangeFn, + ); + if (result.newHeads !== null) { + this.store.set(this.uri as HabitatUri, result.newDoc); + this.notify(); + } + }; + + subscribe = (cb: () => void): (() => void) => { + this.listeners.add(cb); + return () => this.listeners.delete(cb); + }; + + resync = async (): Promise => {}; + + release = (): void => { + this.released = true; + }; + + private notify(): void { + for (const listener of this.listeners) listener(); + } +} + +/** + * Build a fake session factory over a shared `DocStore`. One `FakeDocSession` + * is reused per uri so tests can read its state after opening a document. + */ +export function makeFakeSessions(store: DocStore) { + const sessions = new Map(); + return { + acquire: (uri: string): FakeDocSession => { + let session = sessions.get(uri); + if (!session) { + session = new FakeDocSession(uri, store); + sessions.set(uri, session); + } + return session; + }, + get: (uri: string): FakeDocSession | undefined => sessions.get(uri), + }; +} -- 2.51.2