A local-first note taking app
0

Configure Feed

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

Vault integration phase 1: foundation types, HabitatRepo, snapshot, and utils

Lays the groundwork for Obsidian-style vaults backed by Habitat PDS +
libp2p gossipsub. Adds types, HabitatRepo/DocHandle adapter over
AutomergeDocSession, VaultSnapshotManager, vault registry, and utility
modules (content hashing, text diff, move detection similarity, directory
traversal). Also pulls forward collection-aware routing in automergeDoc.ts
and adds changeAt to AutomergeDocSession.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Ethan Graf (Jun 17, 2026, 9:17 PM EDT) 9b8cc432 c063ef29

+596 -4
+31 -4
src/habitat/automergeDoc.ts
··· 3 3 import { 4 4 HABITAT_DOCS_COLLECTION, 5 5 HABITAT_DOCS_EDIT_COLLECTION, 6 + HABITAT_VAULT_FILE_COLLECTION, 7 + HABITAT_VAULT_FILE_EDIT_COLLECTION, 6 8 } from './config'; 7 9 import { parseDocUri, editRkeyFor } from './habitatDoc'; 8 10 import type { HabitatDoc, TypedRecord } from './types'; ··· 11 13 // Re-export parseDocUri and editRkeyFor so callers don't need to know 12 14 // they live in the Yjs file — they are CRDT-agnostic. 13 15 export { parseDocUri, editRkeyFor }; 16 + 17 + /** 18 + * Extract the collection NSID from a habitat/at URI. 19 + * 20 + * `parseDocUri` deliberately only returns `(ownerDid, rkey)` — the rest of 21 + * the docs pipeline doesn't care about the collection. Vault documents, 22 + * however, live in dedicated collections (directory vs file), so the 23 + * Automerge path needs the collection to load/save against the right one. 24 + * Falls back to `network.habitat.docs` for malformed/short URIs. 25 + */ 26 + function collectionFromUri(uri: string): string { 27 + const parts = uri.split('/'); 28 + return parts[3] || HABITAT_DOCS_COLLECTION; 29 + } 14 30 15 31 export type AutomergeDocSchema = { 16 32 name: string; ··· 60 76 initialDoc: Automerge.Doc<AutomergeDocSchema>, 61 77 ): Promise<LoadDocResult> { 62 78 const { ownerDid, rkey } = parseDocUri(uri); 79 + const collection = collectionFromUri(uri); 63 80 64 81 const ownerRecord = await getPrivateRecord<HabitatDoc>( 65 - HABITAT_DOCS_COLLECTION, 82 + collection, 66 83 rkey, 67 84 ownerDid, 68 85 true, ··· 99 116 100 117 const { ownerDid, rkey } = parseDocUri(ownerRecord.uri); 101 118 const editRkey = editRkeyFor(ownerDid, rkey); 119 + const editCollection = 120 + collectionFromUri(ownerRecord.uri) === HABITAT_VAULT_FILE_COLLECTION 121 + ? HABITAT_VAULT_FILE_EDIT_COLLECTION 122 + : HABITAT_DOCS_EDIT_COLLECTION; 102 123 103 124 const results = await Promise.all( 104 125 memberDids.map((did) => 105 126 getPrivateRecord<HabitatDoc>( 106 - HABITAT_DOCS_EDIT_COLLECTION, 127 + editCollection, 107 128 editRkey, 108 129 did, 109 130 ).catch(() => null), ··· 143 164 144 165 export function chooseSaveTarget(ownerUri: string, myDid: string): SaveTarget { 145 166 const { ownerDid, rkey } = parseDocUri(ownerUri); 167 + const ownerCollection = collectionFromUri(ownerUri); 146 168 if (ownerDid === myDid) { 147 - return { repo: myDid, collection: HABITAT_DOCS_COLLECTION, rkey }; 169 + return { repo: myDid, collection: ownerCollection, rkey }; 148 170 } 171 + // Route the collaborator edit mirror to the matching edit collection. 172 + const editCollection = 173 + ownerCollection === HABITAT_VAULT_FILE_COLLECTION 174 + ? HABITAT_VAULT_FILE_EDIT_COLLECTION 175 + : HABITAT_DOCS_EDIT_COLLECTION; 149 176 return { 150 177 repo: myDid, 151 - collection: HABITAT_DOCS_EDIT_COLLECTION, 178 + collection: editCollection, 152 179 rkey: editRkeyFor(ownerDid, rkey), 153 180 }; 154 181 }
+9
src/habitat/automergeDocSession.ts
··· 38 38 resync: () => Promise<void>; 39 39 release: () => void; 40 40 applyChange: (fn: (doc: AutomergeDocSchema) => void) => void; 41 + changeAt: (heads: string[], fn: (doc: AutomergeDocSchema) => void) => void; 41 42 subscribe: (cb: () => void) => () => void; 42 43 }; 43 44 ··· 325 326 entry.doc = newDoc; 326 327 entry.provider?.onLocalChange(newDoc); 327 328 notifyChange(entry); 329 + }, 330 + changeAt: (heads, fn) => { 331 + const result = Automerge.changeAt(entry.doc, heads, fn); 332 + if (result.newHeads !== null) { 333 + entry.doc = result.newDoc; 334 + entry.provider?.onLocalChange(result.newDoc); 335 + notifyChange(entry); 336 + } 328 337 }, 329 338 subscribe: (cb) => { 330 339 entry.changeListeners.add(cb);
+5
src/habitat/config.ts
··· 13 13 /** Collection NSIDs used by the docs lexicon. */ 14 14 export const HABITAT_DOCS_COLLECTION = 'network.habitat.docs'; 15 15 export const HABITAT_DOCS_EDIT_COLLECTION = 'network.habitat.docs.edit'; 16 + 17 + /** Collection NSIDs for vault documents (directories and files). */ 18 + export const HABITAT_VAULT_DIRECTORY_COLLECTION = 'network.habitat.vault.directory'; 19 + export const HABITAT_VAULT_FILE_COLLECTION = 'network.habitat.vault.file'; 20 + export const HABITAT_VAULT_FILE_EDIT_COLLECTION = 'network.habitat.vault.file.edit';
+100
src/vault/habitat-repo.ts
··· 1 + import * as Automerge from '@automerge/automerge'; 2 + 3 + import type { 4 + HabitatDocHandle, 5 + HabitatRepo, 6 + HabitatUri, 7 + DirectoryDocument, 8 + FileDocument, 9 + } from './types'; 10 + import { acquireDocSession } from '../habitat/automergeDocSession'; 11 + import type { AutomergeDocSession } from '../habitat/automergeDocSession'; 12 + import { 13 + HABITAT_VAULT_DIRECTORY_COLLECTION, 14 + HABITAT_VAULT_FILE_COLLECTION, 15 + } from '../habitat/config'; 16 + 17 + /** 18 + * Adapts an AutomergeDocSession into a HabitatDocHandle. 19 + * 20 + * Mirrors the automerge-repo DocHandle API but uses HabitatUri 21 + * and is backed by the existing textile AutomergeDocSession. 22 + */ 23 + class HabitatDocHandleImpl<T> implements HabitatDocHandle<T> { 24 + constructor(private session: AutomergeDocSession) {} 25 + 26 + doc(): Automerge.Doc<T> { 27 + return this.session.doc as Automerge.Doc<T>; 28 + } 29 + 30 + heads(): string[] { 31 + return Automerge.getHeads(this.session.doc); 32 + } 33 + 34 + change(fn: (d: T) => void): void { 35 + this.session.applyChange(fn as (doc: unknown) => void); 36 + } 37 + 38 + changeAt(heads: string[], fn: (d: T) => void): void { 39 + this.session.changeAt(heads, fn as (doc: unknown) => void); 40 + } 41 + 42 + view(heads: string[]): Automerge.Doc<T> { 43 + return Automerge.view(this.session.doc, heads) as Automerge.Doc<T>; 44 + } 45 + 46 + on(event: 'change', cb: () => void): () => void { 47 + return this.session.subscribe(cb); 48 + } 49 + } 50 + 51 + /** 52 + * HabitatRepo mirrors the automerge-repo Repo API. 53 + * 54 + * Uses `acquireDocSession` from the existing textile sync stack 55 + * to load/save Habitat records. No `automerge-repo` dependency. 56 + * 57 + * Directory documents are stored in `network.habitat.vault.directory` 58 + * File documents are stored in `network.habitat.vault.file` 59 + */ 60 + export class HabitatRepoImpl implements HabitatRepo { 61 + async find<T>(uri: HabitatUri): Promise<HabitatDocHandle<T>> { 62 + const session = acquireDocSession(uri); 63 + await session.loadPromise; 64 + return new HabitatDocHandleImpl<T>(session); 65 + } 66 + 67 + async createDirectory( 68 + initial: DirectoryDocument, 69 + ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle<DirectoryDocument> }> { 70 + return this.create(initial, HABITAT_VAULT_DIRECTORY_COLLECTION); 71 + } 72 + 73 + async createFile( 74 + initial: FileDocument, 75 + ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle<FileDocument> }> { 76 + return this.create(initial, HABITAT_VAULT_FILE_COLLECTION); 77 + } 78 + 79 + async create<T>( 80 + initial: T, 81 + collection: string, 82 + ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle<T> }> { 83 + // Create a new empty Automerge doc with the initial value 84 + const doc = Automerge.from(initial as Record<string, unknown>); 85 + // TODO: Save the doc to Habitat PDS and get a URI 86 + // For now, this is a stub that returns a placeholder URI 87 + const uri = `at://did:plc:placeholder/${collection}/rkey-placeholder` as HabitatUri; 88 + const session = acquireDocSession(uri); 89 + // Force the session to use our new doc 90 + // This is a simplified implementation; in reality we'd need to 91 + // create the Habitat record first, then acquire the session. 92 + return { uri, handle: new HabitatDocHandleImpl<T>(session) }; 93 + } 94 + 95 + async delete(uri: HabitatUri): Promise<void> { 96 + // TODO: Implement deletion via Habitat PDS 97 + // For now, this is a no-op 98 + console.warn('[HabitatRepo] delete not yet implemented for', uri); 99 + } 100 + }
+97
src/vault/snapshot.ts
··· 1 + import type { 2 + HabitatUri, 3 + SerializableSyncSnapshot, 4 + SnapshotDirectoryEntry, 5 + SnapshotFileEntry, 6 + SyncSnapshot, 7 + } from './types'; 8 + import type { FilesystemApi } from '../filesystem/types'; 9 + 10 + const SNAPSHOT_FILE = '.textile/snapshot.json'; 11 + 12 + /** 13 + * Manages `.textile/snapshot.json` for synced vaults. 14 + * Loads, saves, serializes, and deserializes the sync snapshot. 15 + */ 16 + export class VaultSnapshotManager { 17 + constructor(private fs: FilesystemApi) {} 18 + 19 + /** 20 + * Load the snapshot from disk at the given vault root. 21 + * Returns `null` if no snapshot exists. 22 + */ 23 + async load(rootPath: string): Promise<SyncSnapshot | null> { 24 + const snapshotPath = `${rootPath}/${SNAPSHOT_FILE}`; 25 + try { 26 + const raw = await this.fs.readFile(snapshotPath); 27 + return this.deserialize(raw); 28 + } catch { 29 + // Snapshot doesn't exist yet 30 + return null; 31 + } 32 + } 33 + 34 + /** 35 + * Save the snapshot to disk at the given vault root. 36 + */ 37 + async save(rootPath: string, snapshot: SyncSnapshot): Promise<void> { 38 + const snapshotPath = `${rootPath}/${SNAPSHOT_FILE}`; 39 + const json = this.serialize(snapshot); 40 + await this.fs.writeFile(snapshotPath, json); 41 + } 42 + 43 + /** 44 + * Convert a SyncSnapshot to a JSON string. 45 + * Maps are serialized as arrays of [key, value] tuples. 46 + */ 47 + serialize(snapshot: SyncSnapshot): string { 48 + const serializable: SerializableSyncSnapshot = { 49 + timestamp: snapshot.timestamp, 50 + rootUri: snapshot.rootUri, 51 + files: Array.from(snapshot.files.entries()), 52 + directories: Array.from(snapshot.directories.entries()), 53 + }; 54 + return JSON.stringify(serializable, null, 2); 55 + } 56 + 57 + /** 58 + * Parse a JSON string back into a SyncSnapshot. 59 + * Reconstructs Maps from arrays of [key, value] tuples. 60 + */ 61 + deserialize(raw: string): SyncSnapshot { 62 + const parsed = JSON.parse(raw) as SerializableSyncSnapshot; 63 + 64 + const files = new Map<string, SnapshotFileEntry>(); 65 + if (Array.isArray(parsed.files)) { 66 + for (const [key, value] of parsed.files) { 67 + files.set(key, value); 68 + } 69 + } 70 + 71 + const directories = new Map<string, SnapshotDirectoryEntry>(); 72 + if (Array.isArray(parsed.directories)) { 73 + for (const [key, value] of parsed.directories) { 74 + directories.set(key, value); 75 + } 76 + } 77 + 78 + return { 79 + timestamp: parsed.timestamp, 80 + rootUri: parsed.rootUri as HabitatUri, 81 + files, 82 + directories, 83 + }; 84 + } 85 + 86 + /** 87 + * Create an empty snapshot for a new synced vault. 88 + */ 89 + createEmpty(rootUri: HabitatUri): SyncSnapshot { 90 + return { 91 + timestamp: Date.now(), 92 + rootUri, 93 + files: new Map(), 94 + directories: new Map(), 95 + }; 96 + } 97 + }
+198
src/vault/types.ts
··· 1 1 /** 2 2 * Habitat URI branded string. Uses at:// URIs instead of automerge: URLs. 3 3 */ 4 + import type * as Automerge from '@automerge/automerge'; 5 + 4 6 export type HabitatUri = string & { __brand: 'HabitatUri' }; 5 7 6 8 export function isHabitatUri(value: string): value is HabitatUri { ··· 8 10 } 9 11 10 12 export type VaultType = 'local' | 'synced'; 13 + 14 + /** 15 + * Vault configuration stored in `.textile/vault.yaml` on disk. 16 + */ 17 + export interface VaultConfig { 18 + name: string; 19 + type: VaultType; 20 + rootUri: HabitatUri; 21 + createdAt: string; 22 + } 23 + 24 + /** 25 + * Collection types for vault documents. 26 + */ 27 + export type VaultDocumentType = 'directory' | 'file'; 28 + 29 + /** 30 + * Entry in a directory document's `docs` array. 31 + * 32 + * `type` is `string` rather than `'file' | 'folder'` to stay compatible 33 + * with pushwork 2.x, which uses extension strings (`'md'`, `'png'`) for 34 + * file entries. Only `'folder'` is unambiguously a directory. 35 + */ 36 + export interface DirectoryEntry { 37 + name: string; 38 + type: string; 39 + url: HabitatUri; 40 + } 41 + 42 + /** 43 + * Directory node (in Automerge blob). Mirrors pushwork's 44 + * `PushworkDirectoryDocument` but with `HabitatUri` URLs. 45 + */ 46 + export interface DirectoryDocument { 47 + '@patchwork': { type: 'folder' }; 48 + docs: DirectoryEntry[]; 49 + name?: string; 50 + title?: string; 51 + lastSyncAt?: number; 52 + with?: string; 53 + } 54 + 55 + /** 56 + * File node (in Automerge blob). Mirrors pushwork's 57 + * `PushworkFileDocument` exactly for compatibility. 58 + */ 59 + export interface FileDocument { 60 + '@patchwork': { type: 'file' }; 61 + name: string; 62 + extension: string; 63 + mimeType: string; 64 + content: string | Uint8Array; 65 + metadata: { permissions: number }; 66 + } 67 + 68 + /** 69 + * Union type for any vault document (file or directory). 70 + */ 71 + export type VaultDocument = DirectoryDocument | FileDocument; 72 + 73 + /** 74 + * True if a DirectoryEntry points at a sub-directory document. 75 + * False for any file leaf, regardless of how the producer encoded the 76 + * file's `type` (`'file'`, `'md'`, `'png'`, etc.). 77 + */ 78 + export function isFolderEntry(entry: { type: unknown }): boolean { 79 + return entry.type === 'folder'; 80 + } 81 + 82 + /** 83 + * Convenience predicate: a file leaf. Requires `type` to actually be 84 + * a non-empty string — `undefined`, `null`, and `''` are NOT files. 85 + */ 86 + export function isFileEntry(entry: { type: unknown }): boolean { 87 + return ( 88 + typeof entry.type === 'string' && 89 + entry.type.length > 0 && 90 + entry.type !== 'folder' 91 + ); 92 + } 93 + 94 + /** 95 + * Change type classification for sync operations. 96 + */ 97 + export type ChangeType = 'add' | 'edit' | 'delete' | 'move' | 'conflict'; 98 + 99 + /** 100 + * Represents a detected change during three-way comparison 101 + * (local filesystem ↔ snapshot ↔ remote doc). 102 + */ 103 + export interface DetectedChange { 104 + type: ChangeType; 105 + path: string; 106 + isDirectory: boolean; 107 + oldPath?: string; 108 + newPath?: string; 109 + uri?: HabitatUri; 110 + head?: string[]; 111 + contentHash?: string; 112 + } 113 + 114 + /** 115 + * Move detection result: a rename detected by content similarity. 116 + */ 117 + export interface MoveCandidate { 118 + oldPath: string; 119 + newPath: string; 120 + similarity: number; 121 + } 122 + 123 + /** 124 + * Tracked file entry in the sync snapshot. 125 + */ 126 + export interface SnapshotFileEntry { 127 + uri: HabitatUri; 128 + head: string[]; 129 + contentHash: string; 130 + } 131 + 132 + /** 133 + * Tracked directory entry in the sync snapshot. 134 + */ 135 + export interface SnapshotDirectoryEntry { 136 + uri: HabitatUri; 137 + head: string[]; 138 + } 139 + 140 + /** 141 + * Union type for snapshot entries. 142 + */ 143 + export type SnapshotEntry = SnapshotFileEntry | SnapshotDirectoryEntry; 144 + 145 + /** 146 + * Sync snapshot for local state management. 147 + * Only used for synced vaults. 148 + */ 149 + export interface SyncSnapshot { 150 + timestamp: number; 151 + rootUri: HabitatUri; 152 + files: Map<string, SnapshotFileEntry>; 153 + directories: Map<string, SnapshotDirectoryEntry>; 154 + } 155 + 156 + /** 157 + * Serializable version of SyncSnapshot for JSON storage. 158 + */ 159 + export interface SerializableSyncSnapshot { 160 + timestamp: number; 161 + rootUri: HabitatUri; 162 + files: Array<[string, SnapshotFileEntry]>; 163 + directories: Array<[string, SnapshotDirectoryEntry]>; 164 + } 165 + 166 + /** 167 + * HabitatDocHandle mirrors the automerge-repo DocHandle API 168 + * but uses HabitatUri and is backed by AutomergeDocSession. 169 + * 170 + * This is a structural subset, not a literal implements. 171 + */ 172 + export interface HabitatDocHandle<T> { 173 + /** The current document. */ 174 + doc(): Automerge.Doc<T>; 175 + /** Current document heads (like a git commit). */ 176 + heads(): string[]; 177 + /** Apply a change to the document. */ 178 + change(fn: (d: T) => void): void; 179 + /** Apply a change as if the document were at the given heads. */ 180 + changeAt(heads: string[], fn: (d: T) => void): void; 181 + /** View the document at a specific point in time. */ 182 + view(heads: string[]): Automerge.Doc<T>; 183 + /** Subscribe to local/remote changes. Returns an unsubscribe function. */ 184 + on(event: 'change', cb: () => void): () => void; 185 + } 186 + 187 + /** 188 + * HabitatRepo mirrors the automerge-repo Repo API 189 + * but uses HabitatUri and is backed by Habitat PDS + AutomergeDocSession. 190 + * 191 + * Uses separate collections for directories and files: 192 + * - network.habitat.vault.directory 193 + * - network.habitat.vault.file 194 + */ 195 + export interface HabitatRepo { 196 + /** Load an existing Habitat record into a handle. */ 197 + find<T>(uri: HabitatUri): Promise<HabitatDocHandle<T>>; 198 + /** Create a new directory document. */ 199 + createDirectory( 200 + initial: DirectoryDocument, 201 + ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle<DirectoryDocument> }>; 202 + /** Create a new file document. */ 203 + createFile( 204 + initial: FileDocument, 205 + ): Promise<{ uri: HabitatUri; handle: HabitatDocHandle<FileDocument> }>; 206 + /** Remove a record. */ 207 + delete(uri: HabitatUri): Promise<void>; 208 + }
+34
src/vault/utils/content.ts
··· 1 + import { createHash } from 'crypto'; 2 + 3 + /** 4 + * Compute a SHA-256 content hash for snapshot comparison. 5 + */ 6 + export function contentHash(content: string | Uint8Array): string { 7 + const hash = createHash('sha256'); 8 + if (typeof content === 'string') { 9 + hash.update(content, 'utf8'); 10 + } else { 11 + hash.update(content); 12 + } 13 + return `sha256:${hash.digest('hex')}`; 14 + } 15 + 16 + /** 17 + * Deep equality for file content (string vs Uint8Array). 18 + */ 19 + export function isContentEqual( 20 + a: string | Uint8Array, 21 + b: string | Uint8Array, 22 + ): boolean { 23 + if (typeof a === 'string' && typeof b === 'string') { 24 + return a === b; 25 + } 26 + if (a instanceof Uint8Array && b instanceof Uint8Array) { 27 + if (a.length !== b.length) return false; 28 + for (let i = 0; i < a.length; i++) { 29 + if (a[i] !== b[i]) return false; 30 + } 31 + return true; 32 + } 33 + return false; 34 + }
+20
src/vault/utils/directory.ts
··· 1 + import type { DirectoryEntry, HabitatUri } from '../types'; 2 + 3 + /** 4 + * Get the plain URI without any version (head) suffix. 5 + * Habitat URIs are `at://did/collection/rkey` — no heads fragment. 6 + * This is a no-op for Habitat URIs but kept for API parity with pushwork. 7 + */ 8 + export function getPlainUri(uri: HabitatUri): HabitatUri { 9 + return uri; 10 + } 11 + 12 + /** 13 + * Find an entry by name in a directory's `docs` array. 14 + */ 15 + export function findFileInDirectoryHierarchy( 16 + docs: DirectoryEntry[], 17 + name: string, 18 + ): DirectoryEntry | null { 19 + return docs.find((entry) => entry.name === name) ?? null; 20 + }
+33
src/vault/utils/string-similarity.ts
··· 1 + /** 2 + * Compute the Sørensen–Dice coefficient between two strings. 3 + * Used for move detection (rename detection by content similarity). 4 + * 5 + * Returns a value between 0 (no similarity) and 1 (identical). 6 + */ 7 + export function stringSimilarity(a: string, b: string): number { 8 + if (a === b) return 1; 9 + if (a.length < 2 || b.length < 2) return 0; 10 + 11 + const bigramsA = getBigrams(a); 12 + const bigramsB = getBigrams(b); 13 + 14 + let intersection = 0; 15 + for (const [bigram, countA] of bigramsA) { 16 + const countB = bigramsB.get(bigram) ?? 0; 17 + intersection += Math.min(countA, countB); 18 + } 19 + 20 + const total = bigramsA.size + bigramsB.size; 21 + if (total === 0) return 0; 22 + 23 + return (2 * intersection) / total; 24 + } 25 + 26 + function getBigrams(str: string): Map<string, number> { 27 + const bigrams = new Map<string, number>(); 28 + for (let i = 0; i < str.length - 1; i++) { 29 + const bigram = str.slice(i, i + 2); 30 + bigrams.set(bigram, (bigrams.get(bigram) ?? 0) + 1); 31 + } 32 + return bigrams; 33 + }
+69
src/vault/utils/text-diff.ts
··· 1 + import * as Automerge from '@automerge/automerge'; 2 + 3 + import type { FileDocument } from '../types'; 4 + 5 + /** 6 + * Splice text into an Automerge document at a given path. 7 + * Wraps Automerge.splice() for the vault document shape. 8 + * 9 + * Only works when the target field is an Automerge text object. 10 + * For plain strings, use updateTextContent instead. 11 + */ 12 + export function spliceText<T extends FileDocument>( 13 + doc: Automerge.Doc<T>, 14 + path: readonly string[], 15 + index: number, 16 + deleteCount: number, 17 + insert: string, 18 + ): Automerge.Doc<T> { 19 + return Automerge.change(doc, (d) => { 20 + Automerge.splice(d, path as any, index, deleteCount, insert); 21 + }); 22 + } 23 + 24 + /** 25 + * Update the `content` field of a FileDocument to match `newContent`. 26 + * 27 + * For regular strings: assigns directly (plain strings in Automerge are 28 + * replaced wholesale; this is the correct path for the PushworkFileDocument 29 + * schema which stores content as `string | Uint8Array`). 30 + * 31 + * For legacy immutable strings (RawString / ImmutableString): assigns directly. 32 + * For Automerge text objects: uses Automerge.updateText for efficient 33 + * character-level diffing. 34 + */ 35 + export function updateTextContent<T extends FileDocument>( 36 + doc: Automerge.Doc<T>, 37 + newContent: string, 38 + ): Automerge.Doc<T> { 39 + const current = readDocContent(doc); 40 + if (current === newContent) return doc; 41 + 42 + return Automerge.change(doc, (d) => { 43 + const content = d.content as unknown; 44 + if (typeof content === 'string') { 45 + // Plain string: direct assignment 46 + d.content = newContent as unknown as T['content']; 47 + } else if (Automerge.isImmutableString(content)) { 48 + // ImmutableString / RawString: direct assignment 49 + d.content = newContent as unknown as T['content']; 50 + } else { 51 + // Assume it's an Automerge text object 52 + Automerge.updateText(d, ['content'] as any, newContent); 53 + } 54 + }); 55 + } 56 + 57 + /** 58 + * Read the `content` field from a FileDocument, normalizing RawString to plain string. 59 + */ 60 + export function readDocContent<T extends FileDocument>(doc: Automerge.Doc<T>): string { 61 + const content = doc.content as unknown; 62 + if (typeof content === 'string') return content; 63 + if (content instanceof Uint8Array) return ''; 64 + // RawString / ImmutableString fallback 65 + if (Automerge.isImmutableString(content)) { 66 + return content.val; 67 + } 68 + return ''; 69 + }