Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559import type { FilesystemApi } from '../filesystem/types';import { join, basename, dirname } from '../filesystem/vaultFs';import { VaultChangeDetector } from './change-detection';import { detectMoves } from './move-detection';import type { VaultSnapshotManager } from './snapshot';import { contentHash } from './utils/content';import { readDocContent } from './utils/text-diff';import type { DetectedChange, DirectoryDocument, DirectoryEntry, FileDocument, HabitatRepo, HabitatUri, MoveCandidate, SnapshotDirectoryEntry, SnapshotFileEntry, SyncSnapshot, VaultConfig,} from './types';
export interface SyncResult { pushed: number; /** Files written to disk. Non-zero only on the first-open clone. */ pulled: number; moves: number;}
// Pure snapshot mutation helpers — return new snapshots rather than mutating.
function snapshotWithFile( snap: SyncSnapshot, path: string, entry: SnapshotFileEntry,): SyncSnapshot { const files = new Map(snap.files); files.set(path, entry); return { ...snap, timestamp: Date.now(), files };}
function snapshotWithRemovedFile( snap: SyncSnapshot, path: string,): SyncSnapshot { const files = new Map(snap.files); files.delete(path); return { ...snap, timestamp: Date.now(), files };}
function snapshotWithDirectory( snap: SyncSnapshot, path: string, entry: SnapshotDirectoryEntry,): SyncSnapshot { const directories = new Map(snap.directories); directories.set(path, entry); return { ...snap, timestamp: Date.now(), directories };}
function extname(name: string): string { const idx = name.lastIndexOf('.'); return idx > 0 ? name.substring(idx + 1) : '';}
function pathDepth(p: string): number { if (p === '/') return 0; return p.split('/').length;}
export class VaultSyncEngine { constructor( private fs: FilesystemApi, private repo: HabitatRepo, private snapshotManager: VaultSnapshotManager, private detector: VaultChangeDetector, ) {}
async sync(vault: VaultConfig, rootPath: string): Promise<SyncResult> { if (vault.type === 'local') { return { pushed: 0, pulled: 0, moves: 0 }; }
// First sync (no snapshot on disk) does a one-time clone of the remote // tree; every subsequent sync is push-only. Remote edits to already-cloned // files are merged when the file is opened (see `openFile`) or live over // libp2p — never by walking the remote tree here. const existing = await this.snapshotManager.load(rootPath); const firstSync = existing === null; let snap = existing ?? this.snapshotManager.createEmpty(vault.rootUri);
let pulled = 0; if (firstSync) { const remote = await this.detector.scanRemoteTree(vault.rootUri, snap); ({ snap, pulled } = await this.pull(rootPath, snap, remote)); }
// Push local changes (on first sync this also uploads any pre-existing // local files, since the snapshot started empty). const localChanges = await this.detector.detectLocal(rootPath, snap);
const { moves, remainingChanges } = await detectMoves( localChanges, async (path) => { // Deleted files no longer exist on disk — fall back to the last known // content from the repo so move detection can compare similarity. try { return await this.fs.readFile(join(rootPath, path)); } catch { const entry = snap.files.get(path); if (entry) { try { const handle = await this.repo.find<FileDocument>(entry.uri); const c = readDocContent( (handle.doc() as unknown as FileDocument).content, ); return typeof c === 'string' ? c : null; } catch { return null; } } return null; } }, );
const { snap: pushedSnap, pushed, moved, } = await this.push(rootPath, vault.rootUri, snap, remainingChanges, moves); snap = pushedSnap;
await this.snapshotManager.save(rootPath, snap);
return { pushed, pulled, moves: moved }; }
/** * Per-file pull invoked when a file is opened. `repo.find` loads the doc * through the live session (merging PDS + libp2p state), so this writes the * latest merged content back to disk and refreshes the snapshot entry. * No-op for untracked files — remote discovery is handled by the clone. */ async openFile( vault: VaultConfig, rootPath: string, relPath: string, ): Promise<void> { if (vault.type === 'local') return;
let snap = (await this.snapshotManager.load(rootPath)) ?? this.snapshotManager.createEmpty(vault.rootUri);
const entry = snap.files.get(relPath); if (!entry) return;
snap = await this.pullFile( rootPath, { type: 'edit', path: relPath, isDirectory: false, uri: entry.uri, }, snap, );
await this.snapshotManager.save(rootPath, snap); }
/** * 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<void> { 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 * the on-disk content is unchanged, and — for new files only — registers the * file in its parent directory doc(s) so other clients can discover it. * Deletes and renames still go through `sync` (they need tree-wide context). */ async pushFile( vault: VaultConfig, rootPath: string, relPath: string, ): Promise<void> { if (vault.type === 'local') return;
let snap = (await this.snapshotManager.load(rootPath)) ?? this.snapshotManager.createEmpty(vault.rootUri);
const content = await this.fs.readFile(join(rootPath, relPath)); const hash = contentHash(content); const existing = snap.files.get(relPath);
// Nothing changed since the last push — skip the network round-trip. if (existing && existing.contentHash === hash) return;
const isAdd = !existing; snap = await this.pushFileChange( rootPath, { type: isAdd ? 'add' : 'edit', path: relPath, isDirectory: false, uri: existing?.uri, contentHash: hash, }, snap, );
// A new file needs a directory entry so it's discoverable; edits don't // change the listing, so skip the directory rewrite for them. if (isAdd) { snap = await this.updateParentDirectories([relPath], vault.rootUri, snap); }
await this.snapshotManager.save(rootPath, snap); }
// --------------------------------------------------------------------------- // Push phase // ---------------------------------------------------------------------------
private async push( rootPath: string, rootUri: HabitatUri, snap: SyncSnapshot, changes: DetectedChange[], moves: MoveCandidate[], ): Promise<{ snap: SyncSnapshot; pushed: number; moved: number }> { const affectedPaths: string[] = [];
// 1. Moves — update existing doc in place to preserve CRDT history. for (const move of moves) { const content = await this.fs.readFile(join(rootPath, move.newPath)); const oldEntry = snap.files.get(move.oldPath); if (oldEntry) { const handle = await this.repo.find<FileDocument>(oldEntry.uri); const ext = extname(move.newPath); const newName = basename(move.newPath); handle.changeAt(oldEntry.head, (d) => { const fd = d as unknown as FileDocument; fd.content = content; fd.name = newName; fd.extension = ext; }); snap = snapshotWithRemovedFile(snap, move.oldPath); snap = snapshotWithFile(snap, move.newPath, { uri: oldEntry.uri, head: handle.heads(), contentHash: contentHash(content), }); affectedPaths.push(move.oldPath, move.newPath); } }
// 2. File adds and edits. const fileChanges = changes.filter( (c) => (c.type === 'add' || c.type === 'edit') && !c.isDirectory, ); for (const change of fileChanges) { snap = await this.pushFileChange(rootPath, change, snap); affectedPaths.push(change.path); }
// 3. File deletes. const fileDeletes = changes.filter( (c) => c.type === 'delete' && !c.isDirectory, ); for (const change of fileDeletes) { snap = await this.deleteRemoteFile(change, snap); affectedPaths.push(change.path); }
const pushed = fileChanges.length + fileDeletes.length; const moved = moves.length;
// 4. Update parent directories depth-first. if (affectedPaths.length > 0) { snap = await this.updateParentDirectories(affectedPaths, rootUri, snap); }
await this.snapshotManager.save(rootPath, snap); return { snap, pushed, moved }; }
private async pushFileChange( rootPath: string, change: DetectedChange, snap: SyncSnapshot, ): Promise<SyncSnapshot> { const content = await this.fs.readFile(join(rootPath, change.path)); const hash = contentHash(content); const name = basename(change.path);
if (change.type === 'add') { const { uri, handle } = await this.repo.createFile( this.buildFileDocument(name, content), ); return snapshotWithFile(snap, change.path, { uri, head: handle.heads(), contentHash: hash, }); }
// edit const entry = snap.files.get(change.path); if (!entry) { throw new Error( `[VaultEngine] no snapshot entry for edited file: ${change.path}`, ); } const handle = await this.repo.find<FileDocument>(entry.uri); handle.changeAt(entry.head, (d) => { (d as unknown as FileDocument).content = content; }); return snapshotWithFile(snap, change.path, { uri: entry.uri, head: handle.heads(), contentHash: hash, }); }
private async deleteRemoteFile( change: DetectedChange, snap: SyncSnapshot, ): Promise<SyncSnapshot> { if (change.uri) { await this.repo.delete(change.uri); } return snapshotWithRemovedFile(snap, change.path); }
// --------------------------------------------------------------------------- // Directory update helpers // ---------------------------------------------------------------------------
private async updateParentDirectories( affectedPaths: string[], rootUri: HabitatUri, snap: SyncSnapshot, ): Promise<SyncSnapshot> { const dirSet = new Set<string>();
for (const path of affectedPaths) { let d = dirname(path); dirSet.add(d); while (d !== '/') { d = dirname(d); dirSet.add(d); } }
// Deepest first — children before parents. const sorted = Array.from(dirSet).sort( (a, b) => pathDepth(b) - pathDepth(a), );
for (const dir of sorted) { snap = await this.updateOneDirectory(dir, rootUri, snap); }
return snap; }
private async updateOneDirectory( dirRelPath: string, rootUri: HabitatUri, snap: SyncSnapshot, ): Promise<SyncSnapshot> { const isRoot = dirRelPath === '/'; const dirEntry = isRoot ? null : snap.directories.get(dirRelPath); let dirUri: HabitatUri | undefined = isRoot ? rootUri : dirEntry?.uri;
if (!dirUri) { // New directory that doesn't yet exist remotely. const name = basename(dirRelPath); const { uri: newUri, handle } = await this.repo.createDirectory( this.buildDirectoryDocument(name), ); dirUri = newUri; snap = snapshotWithDirectory(snap, dirRelPath, { uri: newUri, head: handle.heads(), }); }
const handle = await this.repo.find<DirectoryDocument>(dirUri); const existingHeads = dirEntry?.head ?? handle.heads(); const expected = this.buildExpectedEntries(dirRelPath, snap);
handle.changeAt(existingHeads, (d) => { const docs = d.docs as unknown as DirectoryEntry[]; docs.splice(0, docs.length); for (const e of expected) { docs.push(e); } (d as unknown as DirectoryDocument).lastSyncAt = Date.now(); });
// Root is always known via vault.rootUri — no need to track it in the // snapshot. Storing '/' causes dirname('/') === '/' which makes root // appear as its own child in buildExpectedEntries. if (isRoot) { return snap; }
return snapshotWithDirectory(snap, dirRelPath, { uri: dirUri, head: handle.heads(), }); }
private buildExpectedEntries( dirRelPath: string, snap: SyncSnapshot, ): DirectoryEntry[] { const entries: DirectoryEntry[] = [];
for (const [filePath, fileEntry] of snap.files) { if (dirname(filePath) === dirRelPath) { const name = basename(filePath); const ext = extname(name) || 'md'; entries.push({ name, type: ext, url: fileEntry.uri }); } }
for (const [subdirPath, subdirEntry] of snap.directories) { if (dirname(subdirPath) === dirRelPath) { entries.push({ name: basename(subdirPath), type: 'folder', url: subdirEntry.uri, }); } }
return entries; }
// --------------------------------------------------------------------------- // Pull phase // ---------------------------------------------------------------------------
private async pull( rootPath: string, snap: SyncSnapshot, remoteChanges: DetectedChange[], ): Promise<{ snap: SyncSnapshot; pulled: number }> { let pulled = 0;
for (const change of remoteChanges) { if (change.isDirectory) { const fullPath = join(rootPath, change.path); try { await this.fs.mkdir(fullPath); } catch { // Directory may already exist. } if (change.uri && change.head) { snap = snapshotWithDirectory(snap, change.path, { uri: change.uri, head: change.head, }); } pulled++; } else { snap = await this.pullFile(rootPath, change, snap); pulled++; } }
return { snap, pulled }; }
private async pullFile( rootPath: string, change: DetectedChange, snap: SyncSnapshot, ): Promise<SyncSnapshot> { if (!change.uri) return snap;
const handle = await this.repo.find<FileDocument>(change.uri); const rawContent = readDocContent( (handle.doc() as unknown as FileDocument).content, ); const content = typeof rawContent === 'string' ? rawContent : ''; const fullPath = join(rootPath, change.path); const parentDir = dirname(fullPath);
if (parentDir && parentDir !== '/') { const parentExists = await this.fs.fileExists(parentDir); if (!parentExists) { await this.fs.mkdir(parentDir); } }
await this.fs.writeFile(fullPath, content);
return snapshotWithFile(snap, change.path, { uri: change.uri, head: handle.heads(), contentHash: contentHash(content), }); }
// --------------------------------------------------------------------------- // Document builders // ---------------------------------------------------------------------------
private buildFileDocument(name: string, content: string): FileDocument { const ext = extname(name); const mimeType = ext === 'md' ? 'text/markdown' : 'text/plain'; return { '@patchwork': { type: 'file' }, name, extension: ext, mimeType, content, metadata: { permissions: 0o644 }, }; }
private buildDirectoryDocument(name: string): DirectoryDocument { return { '@patchwork': { type: 'folder' }, docs: [], name, }; }}