Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451import * as nodeFs from 'fs';import * as nodePath from 'path';import * as os from 'os';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, HabitatUri, VaultConfig,} from './types';
// ---------------------------------------------------------------------------// Test fixture helpers// ---------------------------------------------------------------------------
function makeRootDirectoryDoc(): DirectoryDocument { return { '@patchwork': { type: 'folder' }, docs: [], name: 'root' };}
function writeFile(dir: string, relPath: string, content: string): void { const full = nodePath.join(dir, relPath); nodeFs.mkdirSync(nodePath.dirname(full), { recursive: true }); nodeFs.writeFileSync(full, content, 'utf-8');}
function readFile(dir: string, relPath: string): string { return nodeFs.readFileSync(nodePath.join(dir, relPath), 'utf-8');}
function fileExists(dir: string, relPath: string): boolean { return nodeFs.existsSync(nodePath.join(dir, relPath));}
// ---------------------------------------------------------------------------// Test suite// ---------------------------------------------------------------------------
describe('VaultSyncEngine', () => { let tmpDir: string; let repo: InMemoryHabitatRepo; let rootUri: HabitatUri; let vaultConfig: VaultConfig; let engine: VaultSyncEngine; let fs: NodeFilesystemApi;
beforeEach(async () => { tmpDir = nodeFs.mkdtempSync( nodePath.join(os.tmpdir(), 'textile-vault-test-'), ); repo = new InMemoryHabitatRepo();
const { uri } = await repo.createDirectory(makeRootDirectoryDoc()); rootUri = uri;
vaultConfig = { type: 'synced', rootUri, };
fs = new NodeFilesystemApi(); const snapshotManager = new VaultSnapshotManager(fs); const detector = new VaultChangeDetector(fs, repo); engine = new VaultSyncEngine(fs, repo, snapshotManager, detector); });
afterEach(() => { nodeFs.rmSync(tmpDir, { recursive: true, force: true }); });
// ------------------------------------------------------------------------- // No-op cases // -------------------------------------------------------------------------
it('returns zeros for a local vault without touching the filesystem', async () => { const localConfig: VaultConfig = { ...vaultConfig, type: 'local' }; const result = await engine.sync(localConfig, tmpDir); expect(result).toEqual({ pushed: 0, pulled: 0, moves: 0 }); expect(nodeFs.existsSync(nodePath.join(tmpDir, '.textile'))).toBe(false); });
it('syncs an empty vault with no changes and saves a snapshot', async () => { const result = await engine.sync(vaultConfig, tmpDir); expect(result).toEqual({ pushed: 0, pulled: 0, moves: 0 }); expect(fileExists(tmpDir, '.textile/snapshot.json')).toBe(true); });
it('second sync with no changes is idempotent', async () => { await engine.sync(vaultConfig, tmpDir); const docCountAfterFirst = repo.docCount();
const result = await engine.sync(vaultConfig, tmpDir); expect(result).toEqual({ pushed: 0, pulled: 0, moves: 0 }); expect(repo.docCount()).toBe(docCountAfterFirst); });
// ------------------------------------------------------------------------- // Push: local → remote // -------------------------------------------------------------------------
it('pushes a new root-level file to the repo', async () => { writeFile(tmpDir, 'hello.md', '# Hello');
const result = await engine.sync(vaultConfig, tmpDir);
expect(result.pushed).toBe(1); expect(result.pulled).toBe(0);
// Snapshot records the file const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const fileEntry = snapshot.files.find(([p]: [string]) => p === 'hello.md'); expect(fileEntry).toBeDefined();
// Repo has a file doc for it const fileUri = fileEntry[1].uri as HabitatUri; const doc = repo.getDoc<FileDocument>(fileUri); expect(doc).toBeDefined(); expect((doc as unknown as FileDocument).content).toBe('# Hello'); });
it('pushes a new file inside a new subdirectory, creating directory docs', async () => { writeFile(tmpDir, 'notes/ideas.md', 'some ideas');
const result = await engine.sync(vaultConfig, tmpDir);
expect(result.pushed).toBe(1);
const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw);
// Both the file and its parent directory are in the snapshot const fileEntry = snapshot.files.find( ([p]: [string]) => p === 'notes/ideas.md', ); expect(fileEntry).toBeDefined(); const dirEntry = snapshot.directories.find( ([p]: [string]) => p === 'notes', ); expect(dirEntry).toBeDefined(); });
it('pushes an edit to an existing file', async () => { writeFile(tmpDir, 'doc.md', 'original'); await engine.sync(vaultConfig, tmpDir);
writeFile(tmpDir, 'doc.md', 'updated'); const result = await engine.sync(vaultConfig, tmpDir);
expect(result.pushed).toBe(1);
const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const fileEntry = snapshot.files.find(([p]: [string]) => p === 'doc.md'); const fileUri = fileEntry[1].uri as HabitatUri; const doc = repo.getDoc<FileDocument>(fileUri); expect((doc as unknown as FileDocument).content).toBe('updated'); });
it('removes a deleted file from the snapshot', async () => { writeFile(tmpDir, 'gone.md', 'bye'); await engine.sync(vaultConfig, tmpDir);
nodeFs.rmSync(nodePath.join(tmpDir, 'gone.md')); const result = await engine.sync(vaultConfig, tmpDir);
expect(result.pushed).toBe(1);
const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const fileEntry = snapshot.files.find(([p]: [string]) => p === 'gone.md'); expect(fileEntry).toBeUndefined(); });
// ------------------------------------------------------------------------- // Move detection // -------------------------------------------------------------------------
it('detects a rename as a move, reusing the existing doc URI', async () => { writeFile( tmpDir, 'original.md', 'content that is long enough for similarity', ); await engine.sync(vaultConfig, tmpDir);
const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const originalUri = snapshot.files.find( ([p]: [string]) => p === 'original.md', )[1].uri;
// Rename: remove old, create new with same content nodeFs.rmSync(nodePath.join(tmpDir, 'original.md')); writeFile( tmpDir, 'renamed.md', 'content that is long enough for similarity', );
const result = await engine.sync(vaultConfig, tmpDir);
expect(result.moves).toBe(1);
const snapshotRaw2 = readFile(tmpDir, '.textile/snapshot.json'); const snapshot2 = JSON.parse(snapshotRaw2); const renamedEntry = snapshot2.files.find( ([p]: [string]) => p === 'renamed.md', ); expect(renamedEntry).toBeDefined(); // Same URI as the original — doc was updated in place, not recreated expect(renamedEntry[1].uri).toBe(originalUri); // Old path is gone expect( snapshot2.files.find(([p]: [string]) => p === 'original.md'), ).toBeUndefined(); });
// ------------------------------------------------------------------------- // First-open clone: remote → local (one time only) // -------------------------------------------------------------------------
it('clones a pre-existing remote file to the filesystem on first sync', async () => { // Seed a remote file + directory entry BEFORE the first sync (no snapshot). const { uri: fileUri } = await repo.createFile({ '@patchwork': { type: 'file' }, name: 'remote.md', extension: 'md', mimeType: 'text/markdown', content: 'from remote', metadata: { permissions: 0o644 }, }); const rootHandle = await repo.find<DirectoryDocument>(rootUri); rootHandle.change((d) => { (d as unknown as DirectoryDocument).docs.push({ name: 'remote.md', type: 'md', url: fileUri, }); });
const result = await engine.sync(vaultConfig, tmpDir);
expect(result.pulled).toBe(1); expect(fileExists(tmpDir, 'remote.md')).toBe(true); expect(readFile(tmpDir, 'remote.md')).toBe('from remote'); });
it('does not pull remote edits to tracked files during steady-state sync', async () => { // Establish the file both locally and in the snapshot (first sync clones // nothing; this push creates the doc + snapshot entry). writeFile(tmpDir, 'shared.md', 'v1'); await engine.sync(vaultConfig, tmpDir);
const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const fileUri = snapshot.files.find(([p]: [string]) => p === 'shared.md')[1] .uri as HabitatUri;
// Remote edit lands on the doc. const handle = await repo.find<FileDocument>(fileUri); handle.change((d) => { (d as unknown as FileDocument).content = 'v2 from remote'; });
// A steady-state sync must not walk/pull — disk stays at v1. const result = await engine.sync(vaultConfig, tmpDir);
expect(result.pulled).toBe(0); expect(readFile(tmpDir, 'shared.md')).toBe('v1'); });
// ------------------------------------------------------------------------- // Per-file pull on open // -------------------------------------------------------------------------
it('openFile pulls the merged remote content to disk and advances the snapshot head', async () => { writeFile(tmpDir, 'shared.md', 'v1'); await engine.sync(vaultConfig, tmpDir);
const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const fileUri = snapshot.files.find(([p]: [string]) => p === 'shared.md')[1] .uri as HabitatUri;
const handle = await repo.find<FileDocument>(fileUri); handle.change((d) => { (d as unknown as FileDocument).content = 'v2 from remote'; }); const remoteHeads = handle.heads();
await engine.openFile(vaultConfig, tmpDir, 'shared.md');
expect(readFile(tmpDir, 'shared.md')).toBe('v2 from remote');
const snapshotRaw2 = readFile(tmpDir, '.textile/snapshot.json'); const snapshot2 = JSON.parse(snapshotRaw2); const entry = snapshot2.files.find(([p]: [string]) => p === 'shared.md')[1]; expect(entry.head).toEqual(remoteHeads); });
it('openFile is a no-op for an untracked file', async () => { await engine.sync(vaultConfig, tmpDir); await engine.openFile(vaultConfig, tmpDir, 'never-seen.md'); expect(fileExists(tmpDir, 'never-seen.md')).toBe(false); });
// ------------------------------------------------------------------------- // Per-file push (save/edit events) // -------------------------------------------------------------------------
it('pushFile creates a new file doc and registers it in the root directory', async () => { await engine.sync(vaultConfig, tmpDir); // establish snapshot const docCountBefore = repo.docCount();
writeFile(tmpDir, 'note.md', '# Note'); await engine.pushFile(vaultConfig, tmpDir, 'note.md');
// A file doc was created. expect(repo.docCount()).toBe(docCountBefore + 1);
const snapshot = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); const fileEntry = snapshot.files.find(([p]: [string]) => p === 'note.md'); expect(fileEntry).toBeDefined(); const fileUri = fileEntry[1].uri as HabitatUri; expect( (repo.getDoc<FileDocument>(fileUri) as unknown as FileDocument).content, ).toBe('# Note');
// The root directory doc references it. const rootDoc = repo.getDoc<DirectoryDocument>( rootUri, ) as unknown as DirectoryDocument; expect(rootDoc.docs.some((e) => e.url === fileUri)).toBe(true); });
it('pushFile updates an existing file in place without recreating the doc', async () => { writeFile(tmpDir, 'doc.md', 'v1'); await engine.sync(vaultConfig, tmpDir);
const snap1 = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); const uriBefore = snap1.files.find(([p]: [string]) => p === 'doc.md')[1] .uri; const docCountBefore = repo.docCount();
writeFile(tmpDir, 'doc.md', 'v2'); await engine.pushFile(vaultConfig, tmpDir, 'doc.md');
expect(repo.docCount()).toBe(docCountBefore); // no new doc const snap2 = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); const entry = snap2.files.find(([p]: [string]) => p === 'doc.md')[1]; expect(entry.uri).toBe(uriBefore); // same doc, edited in place expect( (repo.getDoc<FileDocument>(uriBefore) as unknown as FileDocument).content, ).toBe('v2'); });
it('pushFile is a no-op when the file content is unchanged', async () => { writeFile(tmpDir, 'doc.md', 'same'); await engine.sync(vaultConfig, tmpDir);
const snapBefore = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); const headBefore = snapBefore.files.find( ([p]: [string]) => p === 'doc.md', )[1].head;
await engine.pushFile(vaultConfig, tmpDir, 'doc.md');
const snapAfter = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); const headAfter = snapAfter.files.find(([p]: [string]) => p === 'doc.md')[1] .head; expect(headAfter).toEqual(headBefore); // doc was not touched });
it('pushFile creates intermediate directory docs for a nested new file', async () => { await engine.sync(vaultConfig, tmpDir);
writeFile(tmpDir, 'notes/ideas.md', 'some ideas'); await engine.pushFile(vaultConfig, tmpDir, 'notes/ideas.md');
const snapshot = JSON.parse(readFile(tmpDir, '.textile/snapshot.json')); expect( snapshot.files.find(([p]: [string]) => p === 'notes/ideas.md'), ).toBeDefined(); const dirEntry = snapshot.directories.find( ([p]: [string]) => p === 'notes', ); expect(dirEntry).toBeDefined();
// Parent dir doc references the file; root dir doc references the subdir. const dirUri = dirEntry[1].uri as HabitatUri; const dirDoc = repo.getDoc<DirectoryDocument>( dirUri, ) as unknown as DirectoryDocument; expect(dirDoc.docs.some((e) => e.name === 'ideas.md')).toBe(true); const rootDoc = repo.getDoc<DirectoryDocument>( rootUri, ) as unknown as DirectoryDocument; expect(rootDoc.docs.some((e) => e.url === dirUri)).toBe(true); });
it('pushFile is a no-op for a local vault', async () => { const localConfig: VaultConfig = { ...vaultConfig, type: 'local' }; writeFile(tmpDir, 'note.md', 'x'); await engine.pushFile(localConfig, tmpDir, 'note.md'); expect(fileExists(tmpDir, '.textile/snapshot.json')).toBe(false); expect(repo.docCount()).toBe(1); // only the root dir from beforeEach });
// ------------------------------------------------------------------------- // Snapshot correctness // -------------------------------------------------------------------------
it('snapshot head matches the repo doc heads after a push', async () => { writeFile(tmpDir, 'tracked.md', 'hello'); await engine.sync(vaultConfig, tmpDir);
const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const fileEntry = snapshot.files.find( ([p]: [string]) => p === 'tracked.md', )[1]; const fileUri = fileEntry.uri as HabitatUri;
const handle = await repo.find<FileDocument>(fileUri); expect(fileEntry.head).toEqual(handle.heads()); });
it('snapshot contentHash matches the actual file content after a push', async () => { writeFile(tmpDir, 'tracked.md', 'hello world'); await engine.sync(vaultConfig, tmpDir);
const snapshotRaw = readFile(tmpDir, '.textile/snapshot.json'); const snapshot = JSON.parse(snapshotRaw); const fileEntry = snapshot.files.find( ([p]: [string]) => p === 'tracked.md', )[1];
// Compute expected hash the same way the engine does const { createHash } = await import('crypto'); const expected = 'sha256:' + createHash('sha256').update('hello world', 'utf8').digest('hex'); expect(fileEntry.contentHash).toBe(expected); });});