Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651import * as A from '@automerge/automerge'import type { Repository, AutomergeUrl, DocHandle, DocumentId as AutomergeRepoDocumentId } from '../storage/document-runtime-contract'import type { Span } from '@automerge/automerge'import { getHeads } from '@automerge/automerge'import { decodeHeads, encodeHeads } from '../storage/document-runtime-contract'import type { Doc } from '@automerge/automerge'import type { PushworkDirectoryDocument, PushworkFileDocument, DirectoryEntry } from '../types/pushwork'import { extensionFromFileName, mimeTypeFromExtension, isPushworkFile, isPushworkDirectory, isFolderEntry, isFileEntry, REAMS_STATE_MARKER } from '../types/pushwork'import { inferContentType } from '../types/content-types'import { linkReservedDocument, openReservedDocument, reservedDigest } from '../storage/reserved-document'import { OnboardingPathCollision } from './onboarding-path-collision'import { WELCOME_REFRESH_UPGRADE } from './welcome-refresh-upgrade'import { WELCOME_DOCUMENT_CONTENT, WELCOME_DOCUMENT_MARKER, WELCOME_DOCUMENT_SCOPE, WELCOME_DOCUMENT_VERSION } from './welcome-document'import { starterTemplateDocument } from '../plugins/templates/starter-template-content'import { validateStateDocument } from './vault-state-validation'import type { WriteOwnership } from '../sync/semantic-write-ownership'import { rememberSemanticHeads, semanticWriteOwnership } from '../sync/semantic-write-ownership'import { inspectRichText, visibleTextFromDoc, type RichTextBlockDescriptor, type NativeBlockSnapshot, type NodeIndexSnapshot } from '../automerge-markdown'import { buildNodeIndex, buildRecordNodeIndex, collectMarkdownStructures, reconcileDuplicateEagerNodeMarkers, splitNativeBlock, joinNativeBlock, updateNativeBlock, type BlockMarkerValue } from '../automerge-markdown'import { applyMarkerDelta, validateMarkerDeltaBeforeMutation, validateScalarPayload, type LineOwner, type MarkerDelta } from '../automerge-markdown/line-attachment'import { planLineAttachmentDelta, type AttachmentTextChange, type AttachmentAstExtent } from '../editor/line-attachment-extension'import type { NodeIndexResult } from '../automerge-markdown'import type { NodeIndex } from '../automerge-markdown'import type { FrameworkDoc } from '../format-preservation/types'import type { InlineSemanticChange } from '../editor/inline-semantic-ops'import type { Heads } from '../sync/heads'import { headsEqual, normalizeHeads } from '../sync/heads'import { captureFactOrigin, decodeBlockFactKey, encodeBlockFactKey, isBlockScalar, readBlockFactCandidates, readBlockFactDocumentAt, writeBlockFactDraft, clearBlockFactDraft, type BlockScalar, type FactOrigin } from '../types-records/block-record-facts'import { materializeRecordValue } from '../types-records/record-value'import { NATIVE_MARKER_SNAPSHOT_MAX_BYTES, snapshotBlockFact, snapshotNativeMarker, snapshotNativeMarkers } from '../types-records/snapshot-values'import { orphanFactCleanup, prepareTextFirstMarkerDelta, type RecordAttachmentPlan, type RecordPolicyPreviousState } from '../types-records/record-structure-policy'import { recordTypeNamesByStructure } from '../types-records/type-tag-parser'import { reconcileSharedFrontmatter } from '../types-records/root-frontmatter-surface'import type { TypeCatalogSnapshot } from '../types-records/type-catalog'
function isRecordValueForRead(value: unknown): boolean { try { materializeRecordValue(value); return true } catch { return false }}import { isProfilingEnabled, recordPerfSample } from '../lib/perf-profile'import { locateBlockTitle, locateTaskCheckbox } from '../types-records/markdown-binding'import { CommittedObserverError, TextFirstWriter, type TextApplyResult, type TextChange, type TextWriterProgress } from '../spikes/text-first-writer'import { diffTextChanges } from '../sync/text-diff'import { getCoreSemanticRegistry } from '../format-preservation/core-registry'import { isSafeIncrementalMarkdownEdit } from '../automerge-markdown/safe-incremental-edit'import { classifyMarkdownMarkerType } from '../automerge-markdown/marker-classification'
function freezeMarkerDelta(delta: MarkerDelta, markerBudget: { bytes: number; readonly maxBytes: number }): MarkerDelta { return Object.freeze({ ...delta, before: Object.freeze(delta.before.map(owner => Object.freeze({ ...owner }))), after: Object.freeze(delta.after.map(owner => Object.freeze({ ...owner }))), removedNodeIds: delta.removedNodeIds && Object.freeze([...delta.removedNodeIds]), markers: delta.markers && Object.freeze(delta.markers.map(entry => Object.freeze({ nodeId: entry.nodeId, marker: snapshotNativeMarker(entry.marker, markerBudget) }))), markerUpdates: delta.markerUpdates && Object.freeze(delta.markerUpdates.map(update => Object.freeze({ nodeId: update.nodeId, before: snapshotNativeMarker(update.before, markerBudget), after: snapshotNativeMarker(update.after, markerBudget), }))), facts: delta.facts && Object.freeze(delta.facts.map(fact => Object.freeze({ key: fact.key, before: fact.before === undefined ? undefined : snapshotBlockFact(fact.before), after: fact.after === undefined ? undefined : snapshotBlockFact(fact.after), origin: fact.origin, }))), })}
function snapshotTextFirstAttachmentMarkers(document: A.Doc<FrameworkDoc>): Readonly<Record<string, BlockMarkerValue>> { const markers = Object.create(null) as Record<string, BlockMarkerValue> const markerPositions = new Set<number>() const ownedMarkerPositions = new Set<number>() let plainIndex = 0 for (const span of A.spans(document, ['content'])) { if (span.type !== 'block') { plainIndex += span.value.length continue } const marker = span.value as BlockMarkerValue const attrs = marker.attrs const nodeId = typeof attrs === 'object' && attrs !== null && !Array.isArray(attrs) ? (attrs as Record<string, unknown>)['reams.nodeId'] : undefined if (markerPositions.has(plainIndex) && (typeof nodeId === 'string' || ownedMarkerPositions.has(plainIndex))) { throw new Error(`Attachment source has conflicting native markers at position ${plainIndex}`) } if (typeof nodeId === 'string') { if (Object.prototype.hasOwnProperty.call(markers, nodeId)) throw new Error(`Attachment source has duplicate native markers: ${nodeId}`) validateScalarPayload(marker, `marker:${nodeId}`) markers[nodeId] = marker ownedMarkerPositions.add(plainIndex) } markerPositions.add(plainIndex) } return snapshotNativeMarkers(markers)}
function defensiveTextFirstAttachmentMarkers(markers: Readonly<Record<string, BlockMarkerValue>>): Readonly<Record<string, BlockMarkerValue>> { return snapshotNativeMarkers(markers)}
export class DocumentManagerValidationError extends Error { constructor(readonly documentId: AutomergeUrl, readonly reason: 'not-file' | 'binary-content' | 'malformed-pushwork' | 'invalid-input') { super(reason === 'not-file' ? `Document ${documentId} is not a Pushwork file` : reason === 'binary-content' ? `Document ${documentId} does not contain text content` : `Document ${documentId} cannot be updated: ${reason}`) this.name = 'DocumentManagerValidationError' }}
export class DocumentManagerIdentityError extends Error { constructor(readonly documentId: unknown) { super(`Invalid document identity: ${String(documentId)}`) this.name = 'DocumentManagerIdentityError' }}
/** Authoritative sibling-name collision in a folder document. */export class DocumentManagerExistingEntryError extends Error { constructor(readonly entryName: string) { super(`Entry already exists: ${entryName}`) this.name = 'DocumentManagerExistingEntryError' }}
function isPlainRecord(value: unknown): value is Record<string, unknown> { return Object.prototype.toString.call(value) === '[object Object]'}
function isAuthoritativeNotFound(error: unknown): boolean { if (typeof DOMException !== 'undefined' && error instanceof DOMException) { return error.name === 'NotFoundError' } if (isPlainRecord(error) && error.name === 'NotFoundError') return true return error instanceof Error && /(?:not found|does not exist|unknown document)/i.test(error.message)}
/** Stable application identity; AutomergeUrl remains the current backing type. */export type DocumentId = AutomergeUrl
const folderMutationTails = new WeakMap<object, Map<AutomergeUrl, Promise<void>>>()// Once deletion removes a folder's edge, this repository must not accept new// child publications into its body, even if durability or tombstoning fails.// Keep the fence for the repository lifetime; reopening derives reachability// from durable edges, and there are no surviving queued calls across a reopen.const retiringFolders = new WeakMap<object, Set<AutomergeUrl>>()
function retireFolder(repo: Repository, url: AutomergeUrl): void { let retiring = retiringFolders.get(repo) if (!retiring) { retiring = new Set(); retiringFolders.set(repo, retiring) } retiring.add(url)}
async function withFolderMutationLock<T>(repo: Repository, folderUrl: AutomergeUrl, operation: () => Promise<T>, allowRetiring = false): Promise<T> { let locks = folderMutationTails.get(repo) if (!locks) { locks = new Map() folderMutationTails.set(repo, locks) } const previous = locks.get(folderUrl) ?? Promise.resolve() let release!: () => void const current = new Promise<void>(resolve => { release = resolve }) locks.set(folderUrl, current) await previous try { if (!allowRetiring && retiringFolders.get(repo)?.has(folderUrl)) { throw Object.assign(new Error(`Folder deletion has detached ${folderUrl}; cannot publish new children.`), { code: 'FOLDER_DELETING' }) } return await operation() } finally { release() if (locks.get(folderUrl) === current) locks.delete(folderUrl) }}
function withFolderMutationLocks<T>(repo: Repository, urls: readonly AutomergeUrl[], operation: () => Promise<T>, retiringTarget?: AutomergeUrl): Promise<T> { const folders = [...new Set(urls)].sort() const visit = (index: number): Promise<T> => index === folders.length ? operation() : withFolderMutationLock(repo, folders[index]!, () => visit(index + 1), folders[index] === retiringTarget) return visit(0)}
function hasFolderParent(repo: Repository, childUrl: AutomergeUrl, allowedParent?: AutomergeUrl): boolean { return Object.values(repo.handles ?? {}).some(parent => { if (parent.url === allowedParent || parent.isDeleted() || !parent.isReady()) return false const doc = parent.doc() return isPushworkDirectory(doc) && doc.docs.some(entry => entry.url === childUrl && isFolderEntry(entry)) })}
/** Synchronous final validation: no await between this check and edge publication. */function assertFolderAttachment(repo: Repository, parentUrl: AutomergeUrl, child: DocHandle<PushworkDirectoryDocument>): void { if (retiringFolders.get(repo)?.has(child.url)) { throw Object.assign(new Error(`Folder ${child.url} is being deleted`), { code: 'FOLDER_DELETING' }) } if (child.isDeleted()) throw Object.assign(new Error(`Cannot attach deleted folder ${child.url}`), { code: 'DOCUMENT_DELETED' }) if (hasFolderParent(repo, child.url, parentUrl)) { throw Object.assign(new Error(`Folder ${child.url} is already linked in another parent`), { code: 'FOLDER_ALREADY_LINKED' }) }}
function assertFolderEntryVacant(folder: PushworkDirectoryDocument, name: string): void { if (folder.docs.some(entry => entry.name === name)) throw new DocumentManagerExistingEntryError(name)}
function assertDocumentId(documentId: unknown): asserts documentId is DocumentId { if (typeof documentId !== 'string' || documentId.length === 0) { throw new DocumentManagerIdentityError(documentId) }}
function detached(value: unknown): unknown { if (value === null || typeof value !== 'object') return value if (Array.isArray(value)) return value.map(detached) // Automerge span values are always plain records, so Object.entries // does not throw in practice. If it did, the error would propagate // to isAutomergeTextAtPath's try/catch which returns false. return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, detached(child)]))}
function isAutomergeTextAtPath(doc: unknown, path: readonly (string | number)[]): boolean { // path is always ['content'] (single segment) — direct property access // is sufficient. The reduce/guard pattern was dead code for multi-segment // paths that are never used. // Uint8Array content is already rejected by validateTextContentShapeValue // before this function is called, so we don't re-check it here. try { const spans = A.spans(doc as A.Doc<unknown>, [...path]) return spans.every((span) => { const spanValue = detached(span.value) if (span.type === 'text') return typeof spanValue === 'string' // For block spans, validate the block value structure. // Non-block/non-text spans fall through to isPlainRecord which // returns false for non-objects. return isPlainRecord(spanValue) && typeof spanValue.type === 'string' && Array.isArray(spanValue.parents) && spanValue.parents.every((parent) => typeof parent === 'string') && isPlainRecord(spanValue.attrs) }) } catch { return false }}
function validateTextContentShapeValue(url: AutomergeUrl, doc: PushworkFileDocument): 'scalar' | 'rich' { if (!isPushworkFile(doc)) throw new DocumentManagerValidationError(url, 'not-file') if (doc.content instanceof Uint8Array) throw new DocumentManagerValidationError(url, 'binary-content') let spans: readonly { readonly type: string }[] = [] let marks: readonly unknown[] = [] try { spans = A.spans(doc as A.Doc<unknown>, ['content']) marks = A.marks(doc as A.Doc<unknown>, ['content']) } catch { throw new DocumentManagerValidationError(url, 'malformed-pushwork') } if (typeof doc.content === 'string' && marks.length === 0 && !spans.some((span) => span.type === 'block') && spans.length > 0) return 'scalar' if (!isAutomergeTextAtPath(doc, ['content'])) throw new DocumentManagerValidationError(url, 'malformed-pushwork') return 'rich'}
function assertValidInlineSemanticRange(range: { start: number; end: number }, newContentLength: number): void { if (!Number.isInteger(range.start) || !Number.isInteger(range.end)) { throw new Error(`Invalid inline semantic range [${range.start}, ${range.end})`) } if (range.start < 0 || range.end < range.start || range.end > newContentLength) { throw new Error(`Invalid inline semantic range [${range.start}, ${range.end})`) } if (range.start === range.end) { throw new Error(`Invalid inline semantic range [${range.start}, ${range.end})`) }}
interface CreateFileOptions { /** Explicit host-generated onboarding asset; never infer from its filename. */ onboarding?: boolean contentType?: string skipMarkdownWorkerPreflight?: boolean /** Mark host-owned state before publishing its document into a folder. */ reamsState?: true /** Seed the CRDT map before linking a host-only state file into the folder. */ reamsInitialValues?: Record<string, string>}
export interface TextFirstDocumentState { readonly documentId: DocumentId readonly heads: Heads readonly content: string readonly lineOwners: readonly LineOwner[] readonly nodeIndex: NodeIndexResult}
export type TextFirstTransaction = { readonly changes: readonly TextChange[] readonly markerDelta?: MarkerDelta}
export type TextFirstAttachmentStatus = 'not-requested' | 'applied' | 'unsupported'export type TextFirstLocalGeometry = { readonly lineOwners: readonly LineOwner[]; readonly nodeIndex: NodeIndexResult }export type TextFirstTransactionResult = TextApplyResult & { readonly attachment: TextFirstAttachmentStatus; readonly localGeometry?: TextFirstLocalGeometry }
function bootstrapMarkerType(text: string, structure: ReturnType<typeof collectMarkdownStructures>[number]): string { return classifyMarkdownMarkerType(text, structure)}
export type { TextApplyResult, TextChange, TextWriterProgress }
function bootstrapNodeId(_documentId: string, _frontier: Heads, _plainIndex: number, _kind: string): string { const bytes = new Uint8Array(16) globalThis.crypto.getRandomValues(bytes) return `bootstrap-${Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')}`}
/** * Facts are only valid when the resulting native subject still carries the * same active Type evidence. Policy prepares the concrete delta; storage only * accepts it. */function assertTextFirstFactsMatchActiveTypes(document: A.Doc<FrameworkDoc>, recognizedTypeNames: readonly string[]): void { const rich = inspectRichText(document, { path: ['content'] }) const structures = collectMarkdownStructures(rich.visibleText) const activeTypes = recordTypeNamesByStructure(rich.visibleText, structures, recognizedTypeNames) const index = buildRecordNodeIndex(document, { path: ['content'], recognizedTypeNames, heads: [] }) if (index.status !== 'available') throw new Error(`Invalid text-first record index after mutation: ${index.reason}`)
const activeTypesByNode = new Map<string, ReadonlySet<string>>() for (const structure of structures) { const types = activeTypes.get(structure) if (!types) continue const node = index.index.nodeAtStructure(structure) if (!node) throw new Error(`Invalid text-first record owner after mutation at ${structure.from}:${structure.to}`) activeTypesByNode.set(node.id, new Set(types)) }
const root = document as unknown as Readonly<Record<string, unknown>> for (const key of Object.keys(root)) { const decoded = decodeBlockFactKey(key) if (!decoded) continue const candidates = readBlockFactCandidates(root, key) if (candidates.length !== 1) throw new Error(`Invalid text-first record fact after mutation: ${key}`) const types = activeTypesByNode.get(decoded[0]) if (!types || !types.has(decoded[1])) { throw new Error(`Invalid text-first record fact owner after mutation: ${key}`) } }}
function bootstrapMarkdownDraft(draft: A.Doc<FrameworkDoc>, documentId: string, frontier: Heads, recognizedTypeNames: readonly string[] = []): void { // New files are scalar, unmarked text. Project that shape in one native // update instead of repeatedly splitting the same growing sequence. Rich // documents still use the preservation path below so opaque markers, marks, // and shared-origin identities are never replaced. These markers are only // durable identity anchors for records; ordinary Markdown remains plain // text until a recognized record is materialized. for (const key of Object.keys(draft as unknown as Record<string, unknown>)) { const decoded = decodeBlockFactKey(key) if (!decoded) continue const candidates = readBlockFactCandidates(draft as unknown as Record<string, unknown>, key) if (candidates.length === 0 || candidates.length > 1) { throw new Error(`Cannot initialize Markdown through a malformed record fact: ${key}`) } } if (typeof draft.content === 'string') { const currentSpans = A.spans(draft, ['content']) if (currentSpans.length === 1 && currentSpans[0]?.type === 'text' && currentSpans[0].value === draft.content && A.marks(draft, ['content']).length === 0) { const text = draft.content const structures = collectMarkdownStructures(text) const recordStructures = recordTypeNamesByStructure(text, structures, recognizedTypeNames) const recordOnlyStructures = structures.filter(structure => recordStructures.has(structure)) if (recordOnlyStructures.length === 0) { const root = draft as unknown as Record<string, unknown> for (const key of Object.keys(root)) { if (!decodeBlockFactKey(key)) continue const candidates = readBlockFactCandidates(root, key) if (candidates.length === 0 || candidates.length > 1) throw new Error(`Cannot initialize Markdown through a malformed record fact: ${key}`) delete root[key] } return } const spans: Span[] = [] let plainIndex = 0 for (const structure of recordOnlyStructures) { if (structure.markerPlainIndex > plainIndex) spans.push({ type: 'text', value: text.slice(plainIndex, structure.markerPlainIndex) }) spans.push({ type: 'block', value: { type: bootstrapMarkerType(text, structure), parents: [], attrs: { 'reams.nodeId': bootstrapNodeId(documentId, frontier, structure.markerPlainIndex, structure.kind), 'reams.bootstrap': true, 'reams.bootstrapFrontier': JSON.stringify([...frontier].sort()), }, }, }) plainIndex = structure.markerPlainIndex } if (plainIndex < text.length) spans.push({ type: 'text', value: text.slice(plainIndex) }) A.updateSpans(draft, ['content'], spans) return } } let rich = inspectRichText(draft, { path: ['content'] }) const blocks = rich.blocks() const duplicateGroups: RichTextBlockDescriptor[][] = [] for (let start = 0; start < blocks.length;) { let end = start + 1 while (end < blocks.length && blocks[end]!.plainIndex === blocks[start]!.plainIndex) end += 1 if (end - start > 1) duplicateGroups.push(blocks.slice(start, end)) start = end } const equivalentOpaqueMarkers = (left: RichTextBlockDescriptor, right: RichTextBlockDescriptor): boolean => { const withoutId = (marker: BlockMarkerValue) => ({ ...marker, attrs: Object.fromEntries(Object.entries(marker.attrs as Record<string, unknown>).filter(([key]) => key !== 'reams.nodeId')) }) return JSON.stringify(withoutId(left.markerValue)) === JSON.stringify(withoutId(right.markerValue)) } const canRepair = duplicateGroups.length > 0 && duplicateGroups.every(group => { const first = group[0]! const ids = group.map(block => block.attrs['reams.nodeId']) return group.every(block => block.attrs['reams.bootstrap'] === true && block.attrs['reams.bootstrapFrontier'] === first.attrs['reams.bootstrapFrontier']) && ids.every(id => id === ids[0]) && group.every(block => equivalentOpaqueMarkers(first, block)) }) if (canRepair) { reconcileDuplicateEagerNodeMarkers(draft, { path: ['content'] }) rich = inspectRichText(draft, { path: ['content'] }) } const structures = collectMarkdownStructures(rich.visibleText) const recordStructures = recordTypeNamesByStructure(rich.visibleText, structures, recognizedTypeNames) const structureKey = (structure: { readonly from: number; readonly to: number; readonly markerPlainIndex: number }) => `${structure.from}:${structure.to}:${structure.markerPlainIndex}` const recordTypesByKey = new Map([...recordStructures].map(([structure, types]) => [structureKey(structure), types] as const)) const frameworkTypes = new Set(['paragraph', 'heading', 'hr', 'code-fence', 'ordered-list', 'bullet-list', 'blockquote']) const clearInactiveFacts = (nodeId: unknown, activeTypes: readonly string[]) => { if (typeof nodeId !== 'string') return const active = new Set(activeTypes) for (const key of Object.keys(draft as unknown as Record<string, unknown>)) { const decoded = decodeBlockFactKey(key) if (decoded && decoded[0] === nodeId && !active.has(decoded[1])) { delete (draft as unknown as Record<string, unknown>)[key] } } } const currentBlocks = rich.blocks() const blocksByPlainIndex = new Map<number, Array<(typeof currentBlocks)[number]>>() for (const block of currentBlocks) blocksByPlainIndex.set(block.plainIndex, [...(blocksByPlainIndex.get(block.plainIndex) ?? []), block]) const occupied = new Set(blocksByPlainIndex.keys()) const positions = structures.map(structure => ({ structure, richIndex: rich.toRichPosition(structure.markerPlainIndex, 'before-block'), }))
for (const { structure, richIndex } of [...positions].reverse()) { const existingBlocks = blocksByPlainIndex.get(structure.markerPlainIndex) ?? [] if (existingBlocks.some(block => !frameworkTypes.has(block.type))) throw new Error(`Cannot initialize Markdown block through opaque marker at ${structure.markerPlainIndex}`) const recognizedTypes = recordTypesByKey.get(structureKey(structure)) const hasRecognizedType = recognizedTypes !== undefined const displaced = existingBlocks.length === 0 && hasRecognizedType ? currentBlocks.filter(block => block.plainIndex === structure.markerPlainIndex - 1 && frameworkTypes.has(block.type) && typeof block.attrs['reams.nodeId'] === 'string') : [] if (displaced.length === 1) { throw new Error(`Cannot initialize Markdown through a displaced retained marker at ${structure.markerPlainIndex}`) } const existing = existingBlocks[0] if (existing) { if (!hasRecognizedType) { clearInactiveFacts(existing.attrs['reams.nodeId'], []) for (const block of [...existingBlocks].sort((left, right) => right.richIndex - left.richIndex)) { joinNativeBlock(draft, { path: ['content'], richIndex: block.richIndex }) } continue } clearInactiveFacts(existing.attrs['reams.nodeId'], recognizedTypes ?? []) const type = bootstrapMarkerType(rich.visibleText, structure) if (existing.type !== type) throw new Error(`Cannot initialize Markdown block through a mismatched existing marker at ${structure.markerPlainIndex}`) if (typeof existing.attrs['reams.nodeId'] !== 'string' || existing.attrs['reams.nodeId'].length === 0) { updateNativeBlock(draft, { path: ['content'], richIndex: existing.richIndex, marker: { ...existing.markerValue, attrs: { ...existing.attrs, 'reams.nodeId': bootstrapNodeId(documentId, frontier, structure.markerPlainIndex, structure.kind), 'reams.bootstrap': true, 'reams.bootstrapFrontier': JSON.stringify([...frontier].sort()) } }, }) } continue } if (!hasRecognizedType) continue if (occupied.has(structure.markerPlainIndex)) throw new Error(`Cannot initialize Markdown block through an occupied marker at ${structure.markerPlainIndex}`) const type = bootstrapMarkerType(rich.visibleText, structure) splitNativeBlock(draft, { path: ['content'], richIndex, marker: { type, parents: [], attrs: { 'reams.nodeId': bootstrapNodeId(documentId, frontier, structure.markerPlainIndex, structure.kind), 'reams.bootstrap': true, 'reams.bootstrapFrontier': JSON.stringify([...frontier].sort()) } }, }) occupied.add(structure.markerPlainIndex) } // Remove legacy framework markers not selected as records. Custom markers // are never swallowed: the caller will observe the unsupported boundary. const after = inspectRichText(draft, { path: ['content'] }) for (const block of [...after.blocks()].sort((left, right) => right.richIndex - left.richIndex)) { const id = block.attrs['reams.nodeId'] const structure = collectMarkdownStructures(after.visibleText).find(candidate => candidate.markerPlainIndex === block.plainIndex) const activeStructures = recordTypeNamesByStructure(after.visibleText, collectMarkdownStructures(after.visibleText), recognizedTypeNames) const activeStructureKeys = new Set([...activeStructures.keys()].map(candidate => structureKey(candidate))) if (!structure || !activeStructureKeys.has(structureKey(structure))) { if (!['paragraph', 'heading', 'hr', 'code-fence', 'ordered-list', 'bullet-list', 'blockquote'].includes(block.type)) { throw new Error(`Cannot remove opaque Markdown marker at ${block.plainIndex}`) } joinNativeBlock(draft, { path: ['content'], richIndex: block.richIndex }) if (typeof id === 'string') { for (const key of Object.keys(draft as unknown as Record<string, unknown>)) { const decoded = decodeBlockFactKey(key) if (decoded?.[0] === id) delete (draft as unknown as Record<string, unknown>)[key] } } } } const final = inspectRichText(draft, { path: ['content'] }) const finalStructures = collectMarkdownStructures(final.visibleText) const finalActive = recordTypeNamesByStructure(final.visibleText, finalStructures, recognizedTypeNames) const activeTypesByNode = new Map<string, ReadonlySet<string>>() for (const structure of finalStructures) { const block = final.blocks().find(candidate => candidate.plainIndex === structure.markerPlainIndex) const nodeId = block?.attrs['reams.nodeId'] if (typeof nodeId === 'string' && finalActive.has(structure)) activeTypesByNode.set(nodeId, new Set(finalActive.get(structure))) } const root = draft as unknown as Record<string, unknown> for (const key of Object.keys(root)) { const decoded = decodeBlockFactKey(key) if (!decoded) continue const candidates = readBlockFactCandidates(root, key) if (candidates.length === 0 || candidates.length > 1) throw new Error(`Cannot initialize Markdown through a malformed record fact: ${key}`) if (!activeTypesByNode.get(decoded[0])?.has(decoded[1])) delete root[key] }}
function hasOpaqueBoundaryConflict(document: A.Doc<FrameworkDoc>): boolean { const rich = inspectRichText(document, { path: ['content'] }) const boundaries = new Set(collectMarkdownStructures(rich.visibleText).map(structure => structure.markerPlainIndex)) // The Markdown parser omits marker-only list items, but a native bullet // marker for one is still a valid framework boundary after list Enter. let lineStart = 0 for (const line of rich.visibleText.split('\n')) { if (/^\s*(?:[-+*]|\d+[.)])(?:\s+(?:\[[ xX]\])?)?\s*$/.test(line)) boundaries.add(lineStart) lineStart += line.length + 1 } const frameworkTypes = new Set(['paragraph', 'heading', 'hr', 'code-fence', 'ordered-list', 'bullet-list', 'blockquote']) return rich.blocks().some(block => boundaries.has(block.plainIndex) && !frameworkTypes.has(block.type))}
function safeBuildNodeIndex(document: A.Doc<FrameworkDoc>): NodeIndexResult { const opaqueBoundary = hasOpaqueBoundaryConflict(document) try { const result = buildNodeIndex(document, { path: ['content'] }) // Keep structural fallbacks observable at the native-span boundary even // when the parser returns an index that must be rejected as opaque. if (opaqueBoundary || result.status !== 'available') A.spans(document, ['content']) return opaqueBoundary ? { status: 'invalid', reason: 'unmapped-node' } : result } catch { return { status: 'invalid', reason: 'text-authoritative' } }}
function mapTextPosition(position: number, change: TextChange, affinity: -1 | 1): number { if (position < change.from) return position if (position > change.to) return position + change.insert.length - (change.to - change.from) return change.from + (affinity < 0 ? 0 : change.insert.length)}
function safeIncrementalNodeIndex( previous: NodeIndex, before: string, change: TextChange,): NodeIndex | undefined { if (change.from === change.to && change.insert.length === 0) return previous const extents = previous.nodes.length === 0 && !previous.hasUnmappedCustomMarkers ? collectMarkdownStructures(before) : previous.nodes const node = extents.find(candidate => candidate.from <= change.from && change.to <= candidate.to) if (!node || !isSafeIncrementalMarkdownEdit(before, change, [node])) return undefined
const entries = new Map<string, ReturnType<typeof previous.getNode>>() for (const candidate of previous.nodes) { const mapped = Object.freeze({ ...candidate, from: mapTextPosition(candidate.from, change, -1), to: mapTextPosition(candidate.to, change, 1), ...(candidate.markerPlainIndex === undefined ? {} : { markerPlainIndex: mapTextPosition(candidate.markerPlainIndex, change, -1) }), }) entries.set(mapped.id, mapped) } const nodes = Object.freeze([...entries.values()].filter((candidate): candidate is NonNullable<typeof candidate> => candidate !== undefined)) const byStructure = new Map<string, NonNullable<typeof nodes[number]>>() for (const candidate of nodes) byStructure.set(`${candidate.kind}:${candidate.from}:${candidate.to}`, candidate) return Object.freeze({ headsKey: '', nodes, hasUnmappedCustomMarkers: previous.hasUnmappedCustomMarkers, getNode: (id: string) => entries.get(id), nodeAtStructure: (structure: { readonly kind: string; readonly from: number; readonly to: number }) => byStructure.get(`${structure.kind}:${structure.from}:${structure.to}`), })}
function replaceExplicitWholeDocument(draft: A.Doc<FrameworkDoc>, documentId: string, frontier: Heads, oldIds: ReadonlySet<string>, recognizedTypeNames: readonly string[]): void { const currentBlocks = inspectRichText(draft, { path: ['content'] }).blocks() for (const block of [...currentBlocks].sort((left, right) => right.richIndex - left.richIndex)) { if (typeof block.attrs['reams.nodeId'] === 'string' && oldIds.has(block.attrs['reams.nodeId'])) { joinNativeBlock(draft, { path: ['content'], richIndex: block.richIndex }) } } const root = draft as unknown as Record<string, unknown> for (const key of Object.keys(root)) { const decoded = decodeBlockFactKey(key) if (decoded && oldIds.has(decoded[0])) delete root[key] } bootstrapMarkdownDraft(draft, documentId, frontier, recognizedTypeNames)}
interface TextFirstRecord { readonly handle: DocHandle<PushworkFileDocument> readonly writer: TextFirstWriter readonly listeners: Set<(event: { readonly heads: readonly string[]; readonly origin?: object }) => void> readonly onChange: () => void owner?: object leases: Set<object> disposed?: boolean recognizedTypeNames: readonly string[] applyingOrigin?: object lastNotifiedHeads?: Heads nodeIndexCache?: { readonly heads: Heads readonly content: string readonly nodeIndex: NodeIndexResult } attachmentMarkerCache?: { readonly heads: Heads readonly markers: Readonly<Record<string, BlockMarkerValue>> }}
export type ListFoldState = Readonly<Record<string, { readonly collapsed: boolean }>>
const LIST_FOLD_KEY_PREFIX = 'reams.ui.list-fold.v1:'
function encodeListFoldKey(nodeId: string): string { if (nodeId.length === 0) throw new Error('Invalid list fold node ID') return `${LIST_FOLD_KEY_PREFIX}${JSON.stringify(nodeId)}`}
function decodeListFoldKey(key: string): string | undefined { if (!key.startsWith(LIST_FOLD_KEY_PREFIX)) return undefined try { const value: unknown = JSON.parse(key.slice(LIST_FOLD_KEY_PREFIX.length)) return typeof value === 'string' && value.length > 0 && `${LIST_FOLD_KEY_PREFIX}${JSON.stringify(value)}` === key ? value : undefined } catch { return undefined }}
/** * Compatibility-API named result for the canonical text write seam. * Conceptually the `url` field is the stable document identity; its * AutomergeUrl type is the current backing implementation/API shape. */export interface TextWriteResult { documentId: DocumentId beforeHeads: Heads heads: Heads content: string beforeContent: string afterContent: string readonly factChange?: Readonly<{ readonly key: string; readonly before?: BlockScalar; readonly after?: BlockScalar; readonly origin?: FactOrigin }> readonly structuralChange?: Readonly< | { readonly kind: 'task-checkbox'; readonly nodeId: string; readonly checked: boolean; readonly isolated: true } | { readonly kind: 'block-title'; readonly nodeId: string; readonly title: string; readonly isolated: true } >}
/** Exact single-range CM6 text change hint. Internal callers must derive this * from the transaction that produced `newContent`; untrusted guesses are unsafe. */export interface FixedWidthTextEditHint { readonly beforeFrom: number readonly beforeTo: number readonly afterFrom: number readonly afterTo: number}
export interface TaskCheckboxWriteResult extends TextWriteResult { readonly path: 'targeted' | 'fallback' readonly validated: boolean readonly changed: boolean readonly conflicted: boolean readonly checkboxBound: boolean /** False when a synchronous extra write made the targeted contribution unsafe to patch incrementally. */ readonly incrementalSafe: boolean}
export interface BlockTitleWriteResult extends TextWriteResult { readonly path: 'targeted' | 'fallback' readonly validated: boolean readonly changed: boolean /** False when a synchronous extra write made the targeted contribution unsafe to patch incrementally. */ readonly incrementalSafe: boolean}
interface TaskCheckboxSnapshot { readonly checkbox: { readonly markerFrom: number; readonly markerTo: number } readonly nodeId: string readonly nodeKind: string readonly nativeType: string readonly nativeRichIndex: number readonly checkboxRichFrom: number readonly authorizedTypeNames: ReadonlySet<string> readonly hasTouchingMarks: boolean}
interface MarkdownSnapshot { readonly shape: 'scalar' | 'rich' readonly content: string readonly nodeIndex: NodeIndexResult readonly taskCheckboxes: ReadonlyMap<string, TaskCheckboxSnapshot> readonly authorizedTypesByNode: ReadonlyMap<string, ReadonlySet<string>> readonly richCoordinates: { readonly marks: ReadonlyArray<{ readonly start: number; readonly end: number }>; readonly toRichRange: (range: { readonly start: number; readonly end: number }) => { readonly start: number; readonly end: number } }}
function assertTextRange(range: { readonly from: number; readonly to: number }, length: number): void { if (!Number.isSafeInteger(range.from) || !Number.isSafeInteger(range.to) || range.from < 0 || range.to < range.from || range.to > length) { throw new Error(`Invalid visible text replacement range [${range.from}, ${range.to})`) }}
function isSurrogateBoundary(text: string, offset: number): boolean { return offset > 0 && offset < text.length && text.charCodeAt(offset - 1) >= 0xd800 && text.charCodeAt(offset - 1) <= 0xdbff && text.charCodeAt(offset) >= 0xdc00 && text.charCodeAt(offset) <= 0xdfff}
function assertValidFixedWidthHint(url: AutomergeUrl, before: string, after: string, hint: FixedWidthTextEditHint): void { const values = [hint.beforeFrom, hint.beforeTo, hint.afterFrom, hint.afterTo] if (!values.every(Number.isSafeInteger) || hint.beforeFrom < 0 || hint.beforeTo < hint.beforeFrom || hint.beforeTo > before.length || hint.afterFrom < 0 || hint.afterTo < hint.afterFrom || hint.afterTo > after.length || hint.beforeFrom === hint.beforeTo && hint.afterFrom === hint.afterTo || isSurrogateBoundary(before, hint.beforeFrom) || isSurrogateBoundary(before, hint.beforeTo) || isSurrogateBoundary(after, hint.afterFrom) || isSurrogateBoundary(after, hint.afterTo) || before.slice(0, hint.beforeFrom) !== after.slice(0, hint.afterFrom) || before.slice(hint.beforeTo) !== after.slice(hint.afterTo) || before.slice(0, hint.beforeFrom) + after.slice(hint.afterFrom, hint.afterTo) + before.slice(hint.beforeTo) !== after) { throw new DocumentManagerValidationError(url, 'invalid-input') }}
export type DocumentReadStatus = 'unavailable' | 'not-found' | 'not-text' | 'not-markdown' | 'missing-heads'
export type TextReadResult = | { readonly status: 'available'; readonly documentId: DocumentId; readonly heads: Heads; readonly content: string } | { readonly status: Exclude<DocumentReadStatus, 'not-markdown'>; readonly documentId: DocumentId }
export type MarkdownReadResult = | { readonly status: 'available'; readonly documentId: DocumentId; readonly heads: Heads; readonly content: string; readonly nodeIndex: NodeIndexResult; readonly listFolds: ListFoldState; readonly blockFacts: Readonly<Record<string, readonly BlockScalar[]>> } | { readonly status: DocumentReadStatus; readonly documentId: DocumentId }
export interface MarkdownProjectionSnapshot { readonly status: 'available' readonly documentId: DocumentId readonly heads: Heads readonly content: string readonly blocks: readonly NativeBlockSnapshot[] readonly blockFacts: Readonly<Record<string, readonly BlockScalar[]>> readonly nodeIndex?: NodeIndexSnapshot}
/** * DocumentManager provides CRUD operations on Automerge documents * using the Pushwork/Patchwork schema. * * Requires a Repo instance (dependency injection for testability). * DocumentManager is the boundary for canonical persisted content and heads-backed freshness. * Higher layers should use the named read/write seams here instead of reaching * through to raw DocHandles except at explicitly documented low-level seams. */export class DocumentManager { private readonly checkboxWork = { targetedChanged: 0, targetedUnchanged: 0, fallbackInvocations: 0 } private readonly flushQueues = new Map<AutomergeUrl, Promise<void>>() private readonly publicWriteQueues = new Map<AutomergeUrl, Promise<void>>() private readonly textFirstLeases = new Map<AutomergeUrl, Set<object>>() private sharedFrontmatterCatalog?: (url: AutomergeUrl) => Promise<TypeCatalogSnapshot | undefined> private readonly publicWriteCatalogs = new Map<AutomergeUrl, TypeCatalogSnapshot>()
/** Supply the effective Type catalog for external Markdown writes. */ setSharedFrontmatterCatalog(resolver: (url: AutomergeUrl) => Promise<TypeCatalogSnapshot | undefined>): () => void { this.sharedFrontmatterCatalog = resolver return () => { if (this.sharedFrontmatterCatalog === resolver) this.sharedFrontmatterCatalog = undefined } }
private normalizePublicFrontmatter(url: AutomergeUrl, before: string, proposed: string): string { const catalog = this.publicWriteCatalogs.get(url) if (!catalog) return proposed const companion = reconcileSharedFrontmatter(before, proposed, catalog) return companion ? proposed.slice(0, companion.from) + companion.insert + proposed.slice(companion.to) : proposed } private readonly textFirstRecords = new Map<AutomergeUrl, TextFirstRecord>() private textFirstDisposed = false
constructor(private readonly repo: Repository, private readonly onboardingOrigin?: 'anonymous') {}
dispose(): void { this.textFirstDisposed = true this.textFirstLeases.clear() for (const [url, record] of this.textFirstRecords) { record.disposed = true record.handle.off('change', record.onChange) ;(record.handle as unknown as { off: (event: string, listener: () => void) => void }).off('heads-changed', record.onChange) record.listeners.clear() this.textFirstRecords.delete(url) } }
private isTextFirstEligible(doc: PushworkFileDocument): boolean { return isPushworkFile(doc) && typeof doc.content === 'string' }
private assertTextDocument(url: AutomergeUrl, doc: PushworkFileDocument): void { if (!isPushworkFile(doc)) throw new DocumentManagerValidationError(url, 'not-file') if (doc.content instanceof Uint8Array) throw new DocumentManagerValidationError(url, 'binary-content') if (typeof doc.content !== 'string') throw new DocumentManagerValidationError(url, 'malformed-pushwork') }
private textFirstRecord(url: AutomergeUrl): TextFirstRecord { const record = this.textFirstRecords.get(url) if (!record || record.disposed) throw new Error(`Text-first document is not prepared: ${url}`) return record }
private nodeIndexFor(record: TextFirstRecord, heads: Heads, content: string, document: A.Doc<FrameworkDoc>): NodeIndexResult { const cached = record.nodeIndexCache if (cached && headsEqual(cached.heads, heads) && cached.content === content) return cached.nodeIndex const nodeIndex = buildRecordNodeIndex(document, { path: ['content'], recognizedTypeNames: record.recognizedTypeNames }) record.nodeIndexCache = { heads: [...heads], content, nodeIndex } return nodeIndex }
private textFirstWriteResult(url: AutomergeUrl, beforeHeads: Heads, beforeContent: string, afterContent: string, result: TextApplyResult): TextWriteResult { const heads = result.contributionHeads.length > 0 ? normalizeHeads(result.contributionHeads) : normalizeHeads(beforeHeads) return { documentId: url, beforeHeads, heads, content: afterContent, beforeContent, afterContent } }
private textFirstEnvelope(url: AutomergeUrl, baseHeads: Heads, before: string, after: string, hint?: FixedWidthTextEditHint, mutate?: (draft: A.Doc<FrameworkDoc>) => void): TextApplyResult { const record = this.textFirstRecord(url) if (hint) { assertValidFixedWidthHint(url, before, after, hint) record.writer.prepare(baseHeads) return this.applyTextFirstChanges(url, baseHeads, [{ from: hint.beforeFrom, to: hint.beforeTo, insert: after.slice(hint.afterFrom, hint.afterTo), }], undefined, mutate) } if (before === after) { record.writer.prepare(baseHeads) return record.writer.applyChanges(baseHeads, []) } return this.applyTextFirstChanges(url, baseHeads, diffTextChanges(before, after), undefined, mutate) }
private textFirstWholeValueChanges(before: string, after: string, hint?: FixedWidthTextEditHint): AttachmentTextChange[] { if (hint) return [{ from: hint.beforeFrom, to: hint.beforeTo, newFrom: hint.afterFrom, newTo: hint.afterTo, insert: after.slice(hint.afterFrom, hint.afterTo) }] let shift = 0 return diffTextChanges(before, after).map(change => { const newFrom = change.from + shift const newTo = newFrom + change.insert.length shift += change.insert.length - (change.to - change.from) return { ...change, newFrom, newTo } }) }
private textFirstAttachmentPlan(url: AutomergeUrl, before: string, after: string, changes: readonly AttachmentTextChange[]): RecordAttachmentPlan | undefined { if (changes.length === 0) return undefined const record = this.textFirstRecord(url) const document = record.handle.doc() as unknown as A.Doc<FrameworkDoc> const index = buildRecordNodeIndex(document, { path: ['content'], recognizedTypeNames: record.recognizedTypeNames }) if (index.status !== 'available') { throw new Error(`Cannot plan text-first attachment write at unavailable index: ${index.reason}`) } const rich = inspectRichText(document, { path: ['content'] }) const blocks = rich.blocks() const ownerIds = new Set(index.index.nodes.map(node => node.id)) const factsByOwner = new Map<string, Array<{ readonly key: string; readonly value: BlockScalar }>>() for (const key of Object.keys(document as unknown as Record<string, unknown>)) { const decoded = decodeBlockFactKey(key) if (!decoded) continue const candidates = readBlockFactCandidates(document as unknown as Record<string, unknown>, key) if (candidates.length !== 1) throw new Error(`Cannot plan ordinary text write through malformed record fact: ${key}`) if (!ownerIds.has(decoded[0])) { continue } const values = factsByOwner.get(decoded[0]) ?? [] values.push({ key, value: snapshotBlockFact(candidates[0]!) }) factsByOwner.set(decoded[0], values) } const owners = index.index.nodes.map(node => { const marker = blocks.find(block => block.attrs['reams.nodeId'] === node.id)! const lineStart = Math.max(before.lastIndexOf('\n', marker.plainIndex - 1), before.lastIndexOf('\r', marker.plainIndex - 1)) + 1 return { nodeId: node.id, lineStart, ...(marker.plainIndex === lineStart ? {} : { markerPlainIndex: marker.plainIndex }) } }) const astExtents = index.index.nodes.map(node => ({ nodeId: node.id, kind: node.kind, from: blocks.find(block => block.attrs['reams.nodeId'] === node.id)!.plainIndex, to: node.to, marker: blocks.find(block => block.attrs['reams.nodeId'] === node.id)?.markerValue, ...(node.parentId === undefined ? {} : { parentId: node.parentId }), ...(node.children.length ? { children: node.children } : {}), ...(factsByOwner.get(node.id)?.length ? { facts: factsByOwner.get(node.id) } : {}), })) const delta = planLineAttachmentDelta({ before, after, changes, owners, recognizedTypeNames: record.recognizedTypeNames, astExtents, prepareDelta: value => this.prepareTextFirstMarkerDelta(url, value) }) const orphanFacts = orphanFactCleanup(document as unknown as Record<string, unknown>, ownerIds) return orphanFacts.length ? { ...(delta ?? { before: [], after: [] }), facts: [...(delta?.facts ?? []), ...orphanFacts] } : delta }
private applyTextFirstWholeValue(url: AutomergeUrl, baseHeads: Heads, before: string, after: string, hint?: FixedWidthTextEditHint): TextApplyResult { const normalized = this.normalizePublicFrontmatter(url, before, after) if (normalized !== after) { after = normalized; hint = undefined } if (hint) assertValidFixedWidthHint(url, before, after, hint) const changes = this.textFirstWholeValueChanges(before, after, hint) const delta = this.textFirstAttachmentPlan(url, before, after, changes) if (!delta) return this.applyTextFirstChanges(url, baseHeads, changes) return this.applyTextFirstTransaction(url, baseHeads, { changes, markerDelta: delta }) }
private assertTextFirstAvailable(url: AutomergeUrl, handle: DocHandle<PushworkFileDocument>): void { if (this.textFirstDisposed) throw new Error('DocumentManager is disposed') if (!handle.isReady() || handle.isUnloaded?.()) throw new Error(`Text-first document is not ready: ${url}`) const doc = handle.doc() if (!isPushworkFile(doc) || !this.isTextFirstEligible(doc)) throw new DocumentManagerValidationError(url, 'not-file') }
async prepareTextFirstDocument(url: AutomergeUrl): Promise<TextFirstDocumentState> { assertDocumentId(url) if (this.textFirstDisposed) throw new Error('DocumentManager is disposed') const handle = await this.repo.find<PushworkFileDocument>(url) if (this.textFirstDisposed) throw new Error('DocumentManager is disposed') this.assertTextFirstAvailable(url, handle) const doc = handle.doc() const existing = this.textFirstRecords.get(url) if (existing && existing.handle === handle) { existing.writer.prepare(normalizeHeads(getHeads(doc as Doc<PushworkFileDocument>))) return this.getTextFirstDocumentState(url) } if (existing) { existing.disposed = true existing.handle.off('change', existing.onChange) ;(existing.handle as unknown as { off: (event: string, listener: () => void) => void }).off('heads-changed', existing.onChange) this.textFirstRecords.delete(url) } let record!: TextFirstRecord const writer = new TextFirstWriter(handle as unknown as ConstructorParameters<typeof TextFirstWriter>[0], () => this.flushTextWrite(url)) const onChange = () => { if (record.disposed) return const heads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) if (record.lastNotifiedHeads && headsEqual(record.lastNotifiedHeads, heads)) return record.lastNotifiedHeads = heads if (!record.nodeIndexCache || !headsEqual(record.nodeIndexCache.heads, heads)) record.nodeIndexCache = undefined const origin = record.applyingOrigin queueMicrotask(() => { if (record.disposed) return for (const listener of [...record.listeners]) { try { listener({ heads, ...(origin !== undefined ? { origin } : {}) }) } catch (error) { console.error(`Text-first observer failed for ${url}`, error) } } }) } record = { handle, writer, listeners: new Set(), onChange, recognizedTypeNames: [], leases: this.textFirstLeases.get(url) ?? new Set() } this.textFirstLeases.set(url, record.leases) handle.on('change', onChange) ;(handle as unknown as { on: (event: string, listener: () => void) => void }).on('heads-changed', onChange) this.textFirstRecords.set(url, record) writer.prepare(normalizeHeads(getHeads(doc as Doc<PushworkFileDocument>))) return this.getTextFirstDocumentState(url) }
getTextFirstDocumentState(url: AutomergeUrl): TextFirstDocumentState { const record = this.textFirstRecord(url) const heads = normalizeHeads(getHeads(record.handle.doc() as Doc<PushworkFileDocument>)) record.writer.prepare(heads) const document = record.handle.doc() as unknown as A.Doc<FrameworkDoc> const content = record.writer.getText().toString() const lineIndex = this.nodeIndexFor(record, heads, content, document) return { documentId: url, heads, content, lineOwners: lineIndex.status === 'available' ? lineIndex.index.nodes.map(node => ({ nodeId: node.id, lineStart: node.from, ...(node.markerPlainIndex === undefined ? {} : { markerPlainIndex: node.markerPlainIndex }) })) : [], nodeIndex: lineIndex, } }
/** Read the live Automerge projection without touching the writer cache. */ readTextFirstDocumentState(url: AutomergeUrl): TextFirstDocumentState { const record = this.textFirstRecord(url) const document = record.handle.doc() as unknown as A.Doc<FrameworkDoc> const heads = normalizeHeads(getHeads(document as Doc<PushworkFileDocument>)) const content = visibleTextFromDoc(document as unknown as PushworkFileDocument) const lineIndex = this.nodeIndexFor(record, heads, content, document) return { documentId: url, heads, content, lineOwners: lineIndex.status === 'available' ? lineIndex.index.nodes.map(node => ({ nodeId: node.id, lineStart: node.from, ...(node.markerPlainIndex === undefined ? {} : { markerPlainIndex: node.markerPlainIndex }) })) : [], nodeIndex: lineIndex, } }
isTextFirstWriterStopped(url: AutomergeUrl): boolean { return this.textFirstRecord(url).writer.stopped }
recoverTextFirstWriter(url: AutomergeUrl): void { const record = this.textFirstRecord(url) this.assertTextFirstAvailable(url, record.handle) record.nodeIndexCache = undefined record.attachmentMarkerCache = undefined const state = this.readTextFirstDocumentState(url) if (state.nodeIndex.status !== 'available') throw new Error('Cannot recover an invalid canonical Record projection') record.writer.recoverCommittedObserverFailure() }
/** Whether disabling record policy would hide durable identity or facts. */ hasTextFirstRecordData(url: AutomergeUrl): boolean { const record = this.textFirstRecord(url) const document = record.handle.doc() as unknown as A.Doc<FrameworkDoc> if (Object.keys(document as unknown as Record<string, unknown>).some(key => Boolean(decodeBlockFactKey(key)))) return true return inspectRichText(document, { path: ['content'] }).blocks().some(block => typeof block.attrs['reams.nodeId'] === 'string') }
getTextFirstWriterDiagnostics(url: AutomergeUrl): TextFirstWriter['diagnostics'] { return this.textFirstRecord(url).writer.diagnostics }
validateTextFirstProjection(url: AutomergeUrl): void { this.textFirstRecord(url).writer.validateProjection() }
async initializeTextFirstLineOwners(url: AutomergeUrl, effectiveTypeNames: readonly string[]): Promise<TextFirstAttachmentStatus> { const record = this.textFirstRecord(url) const previousTypeNames = record.recognizedTypeNames const nextTypeNames = [...new Set(effectiveTypeNames)] this.assertTextFirstAvailable(url, record.handle) if (this.contentTypeForFile(record.handle.doc()) !== 'public.markdown') { record.recognizedTypeNames = nextTypeNames return 'not-requested' } const baseHeads = normalizeHeads(getHeads(record.handle.doc() as Doc<PushworkFileDocument>)) let currentIndex: NodeIndexResult try { if (hasOpaqueBoundaryConflict(record.handle.doc() as unknown as A.Doc<FrameworkDoc>)) { record.recognizedTypeNames = previousTypeNames return 'unsupported' } currentIndex = buildRecordNodeIndex(record.handle.doc() as unknown as A.Doc<FrameworkDoc>, { path: ['content'], recognizedTypeNames: effectiveTypeNames, }) } catch (error) { console.warn(`Text-first Markdown identity initialization skipped for ${url}:`, error) record.recognizedTypeNames = previousTypeNames return 'unsupported' } if (currentIndex.status === 'invalid' && (currentIndex.reason === 'missing-node-id' || currentIndex.reason === 'unmapped-node' || currentIndex.reason === 'duplicate-node-id' || currentIndex.reason === 'duplicate-marker')) { // Validate the complete migration on a clone before opening the real // Automerge transaction. This keeps malformed/conflicted cleanup from // partially committing before the final index check. try { const source = record.handle.doc() as unknown as A.Doc<FrameworkDoc> const spans = A.spans(source, ['content']) const pristineText = spans.every(span => span.type === 'text') && A.marks(source, ['content']).length === 0 && !Object.keys(source).some(key => decodeBlockFactKey(key) !== undefined) // Plain source has no metadata to migrate or preserve. Bootstrap it // once; reserve the clone preflight for existing markers/facts/marks. if (!pristineText) { const validation = A.clone(source) A.change(validation, draft => bootstrapMarkdownDraft(draft as unknown as A.Doc<FrameworkDoc>, url, baseHeads, effectiveTypeNames)) } } catch (error) { record.recognizedTypeNames = previousTypeNames return 'unsupported' } try { record.applyingOrigin = undefined record.writer.applyMutation(baseHeads, [], draft => bootstrapMarkdownDraft(draft as unknown as A.Doc<FrameworkDoc>, url, baseHeads, effectiveTypeNames), { message: 'reams:text-first:initialize-record-attachments', markersChanged: true }) await record.writer.flush() record.recognizedTypeNames = nextTypeNames } catch (error) { const afterHeads = normalizeHeads(getHeads(record.handle.doc() as Doc<PushworkFileDocument>)) if (!headsEqual(afterHeads, baseHeads)) { record.recognizedTypeNames = nextTypeNames throw error } record.recognizedTypeNames = previousTypeNames return 'unsupported' } } // Only currently recognized subjects are native; ordinary Markdown remains // derived from visible text. record.nodeIndexCache = undefined const indexed = buildRecordNodeIndex(record.handle.doc() as unknown as A.Doc<FrameworkDoc>, { path: ['content'], recognizedTypeNames: nextTypeNames }) if (indexed.status !== 'available') { record.recognizedTypeNames = previousTypeNames return 'unsupported' } record.recognizedTypeNames = nextTypeNames return 'applied' }
/** Acquire identity context only for the duration of background bootstrap. */ async bootstrapTextFirstDocument(url: AutomergeUrl, effectiveTypeNames: readonly string[]): Promise<TextFirstAttachmentStatus> { return this.runPublicWrite(url, async () => { const alreadyPrepared = this.textFirstRecords.has(url) const releaseLease = this.acquireTextFirstDocumentLease(url) let prepared = false try { await this.prepareTextFirstDocument(url) prepared = true return await this.initializeTextFirstLineOwners(url, effectiveTypeNames) } finally { releaseLease() if (prepared && !alreadyPrepared) await this.releaseTextFirstDocument(url) } }) }
/** Retain a prepared writer while an editor or another async owner starts. */ acquireTextFirstDocumentLease(url: AutomergeUrl): () => void { let leases = this.textFirstLeases.get(url) if (!leases) { leases = new Set() this.textFirstLeases.set(url, leases) } const lease = {} leases.add(lease) let released = false return () => { if (released) return released = true leases!.delete(lease) if (leases!.size === 0 && this.textFirstRecords.get(url)?.leases !== leases) this.textFirstLeases.delete(url) } }
applyTextFirstChanges(url: AutomergeUrl, baseHeads: Heads, changes: readonly TextChange[], origin?: object, mutate?: (draft: A.Doc<FrameworkDoc>) => void): TextApplyResult { if (this.textFirstDisposed) throw new Error('DocumentManager is disposed') const record = this.textFirstRecord(url) this.assertTextFirstAvailable(url, record.handle) record.applyingOrigin = origin try { const recordMutation = mutate const result = recordMutation ? record.writer.applyMutation(baseHeads, changes, recordMutation, { message: 'reams:text-first:record-materialization', markersChanged: true }) : record.writer.applyChanges(baseHeads, changes) record.writer.prepare(result.liveHeads) return result } finally { record.applyingOrigin = undefined } }
applyTextFirstTransaction(url: AutomergeUrl, baseHeads: Heads, transaction: TextFirstTransaction, origin?: object): TextFirstTransactionResult { if (this.textFirstDisposed) throw new Error('DocumentManager is disposed') const record = this.textFirstRecord(url) this.assertTextFirstAvailable(url, record.handle) record.applyingOrigin = origin try { const liveHeads = normalizeHeads(getHeads(record.handle.doc() as Doc<PushworkFileDocument>)) if (!transaction.markerDelta) { const document = record.handle.doc() as unknown as A.Doc<FrameworkDoc> const beforeContent = record.writer.getText().toString() const beforeIndex = this.nodeIndexFor(record, liveHeads, beforeContent, document) const canMap = headsEqual(liveHeads, normalizeHeads(baseHeads)) && beforeIndex.status === 'available' && transaction.changes.length === 1 const result = record.writer.applyChanges(baseHeads, transaction.changes) record.writer.prepare(result.liveHeads) const afterContent = record.writer.getText().toString() const mapped = canMap && beforeIndex.status === 'available' ? safeIncrementalNodeIndex(beforeIndex.index, beforeContent, transaction.changes[0]!) : undefined if (mapped) { record.nodeIndexCache = { heads: normalizeHeads(result.liveHeads), content: afterContent, nodeIndex: { status: 'available', index: Object.freeze({ ...mapped, headsKey: JSON.stringify([...result.liveHeads].sort()) }) } } } else { record.nodeIndexCache = undefined } const afterIndex: NodeIndexResult = mapped ? record.nodeIndexCache!.nodeIndex : buildRecordNodeIndex(record.handle.doc() as unknown as A.Doc<FrameworkDoc>, { path: ['content'], recognizedTypeNames: record.recognizedTypeNames }) const attachment = beforeIndex.status === 'available' && afterIndex.status !== 'available' ? 'unsupported' : 'not-requested' return { ...result, attachment, ...(afterIndex.status === 'available' ? { localGeometry: { lineOwners: afterIndex.index.nodes.map(node => ({ nodeId: node.id, lineStart: node.from, ...(node.markerPlainIndex === undefined ? {} : { markerPlainIndex: node.markerPlainIndex }) })), nodeIndex: afterIndex } } : {}), } } if (!headsEqual(liveHeads, normalizeHeads(baseHeads))) { throw new Error('Line attachment transaction is stale; refusing text-only fallback') } let markerDelta = transaction.markerDelta! const richBefore = inspectRichText(record.handle.doc() as unknown as A.Doc<FrameworkDoc>, { path: ['content'] }) const beforeOwnersValid = markerDelta.before.every(owner => { const marker = richBefore.blocks().find(block => block.attrs['reams.nodeId'] === owner.nodeId) return marker !== undefined && marker.plainIndex === (owner.markerPlainIndex ?? owner.lineStart) }) if (!beforeOwnersValid) { throw new Error('Line attachment transaction source changed; refusing text-only fallback') } try { validateMarkerDeltaBeforeMutation(record.handle.doc() as unknown as A.Doc<FrameworkDoc>, { path: ['content'], delta: markerDelta }) const filteredFacts = this.filterTextFirstFactUndo(record.handle.doc() as A.Doc<PushworkFileDocument>, markerDelta.facts) const filteredDelta = filteredFacts.skipped ? { ...markerDelta, facts: filteredFacts.facts } : markerDelta const result = record.writer.applyMutation(baseHeads, transaction.changes, draft => { applyMarkerDelta(draft as unknown as A.Doc<FrameworkDoc>, { path: ['content'], delta: filteredDelta }) // getHeads on an Automerge draft finalizes pending operations. // This pre-commit validation must not consume changeAt's commit. assertTextFirstFactsMatchActiveTypes(draft as unknown as A.Doc<FrameworkDoc>, record.recognizedTypeNames) }, { message: 'reams:text-first:line-attachment', markersChanged: true }) const local = this.getTextFirstDocumentState(url) return { ...result, ...(filteredFacts.skipped ? { factUndoSkipped: true } : {}), attachment: 'applied', ...(local.nodeIndex.status === 'available' ? { localGeometry: { lineOwners: local.lineOwners, nodeIndex: local.nodeIndex } } : {}), } } catch (error) { if (error instanceof CommittedObserverError) throw error throw new Error(`Line attachment transaction was rejected; refusing text-only fallback: ${error instanceof Error ? error.message : String(error)}`) } } finally { record.applyingOrigin = undefined } }
private filterTextFirstFactUndo( live: A.Doc<PushworkFileDocument>, facts: MarkerDelta['facts'], ): { readonly facts: MarkerDelta['facts']; readonly skipped: boolean } { if (!facts?.length) return { facts, skipped: false } let skipped = false const safe = facts.filter(fact => { if (!fact.origin) return true try { if (!A.hasHeads(live, [...fact.origin.contributionHeads])) { skipped = true; return false } const later = A.getChanges(A.view(live, [...fact.origin.contributionHeads]), live) for (const encoded of later) { let change: { actor?: unknown; ops?: readonly { obj?: unknown; key?: unknown }[] } try { change = A.decodeChange(encoded) as unknown as typeof change } catch { skipped = true; return false } if (change.actor === fact.origin.actorId) continue if (change.ops?.some(op => (op.obj === '_root' && op.key === fact.key) || fact.origin!.beforeObjectIds.includes(String(op.obj)) || fact.origin!.afterObjectIds.includes(String(op.obj)))) { skipped = true return false } } return true } catch { skipped = true return false } }) return { facts: safe, skipped } }
prepareTextFirstMarkerDelta(url: DocumentId, delta: RecordAttachmentPlan, expectedHeads?: Heads, previousExtents?: readonly AttachmentAstExtent[]): MarkerDelta { const unique = (values: readonly string[], label: string) => { if (new Set(values).size !== values.length) throw new Error(`Clipboard transaction contains duplicate ${label}`) } unique(delta.before.map(owner => owner.nodeId), 'source owners') unique(delta.after.map(owner => owner.nodeId), 'destination owners') unique((delta.markers ?? []).map(marker => marker.nodeId), 'markers') unique((delta.markerUpdates ?? []).map(update => update.nodeId), 'marker updates') const factKeys = new Set<string>() for (const fact of delta.facts ?? []) { if (factKeys.has(fact.key)) throw new Error(`Concrete fact delta contains duplicate key: ${fact.key}`) factKeys.add(fact.key) } for (const marker of delta.markers ?? []) validateScalarPayload(marker.marker, `marker:${marker.nodeId}`) for (const update of delta.markerUpdates ?? []) { validateScalarPayload(update.before, `markerUpdate:${update.nodeId}:before`) validateScalarPayload(update.after, `markerUpdate:${update.nodeId}:after`) } for (const fact of delta.facts ?? []) { if (fact.before !== undefined) validateScalarPayload(fact.before, `fact:${fact.key}:before`) if (fact.after !== undefined) validateScalarPayload(fact.after, `fact:${fact.key}:after`) } const record = this.textFirstRecord(url) const canonical = record.handle.doc() as unknown as A.Doc<Record<string, unknown>> const liveHeads = normalizeHeads(getHeads(canonical as Doc<PushworkFileDocument>)) if (expectedHeads && !headsEqual(liveHeads, expectedHeads)) throw new Error(`Line attachment projection is stale for ${url}`) const markerBudget = { bytes: 0, maxBytes: NATIVE_MARKER_SNAPSHOT_MAX_BYTES } const previous: RecordPolicyPreviousState | undefined = expectedHeads && previousExtents ? { heads: [...expectedHeads], markers: new Map(previousExtents.flatMap(extent => extent.marker ? [[extent.nodeId, snapshotNativeMarker(extent.marker, markerBudget)] as const] : [])), facts: new Map(previousExtents.flatMap(extent => (extent.facts ?? []).map(fact => [fact.key, snapshotBlockFact(fact.value)] as const))), } : undefined const document = A.view(canonical, [...getHeads(canonical)]) as unknown as A.Doc<Record<string, unknown>> return freezeMarkerDelta(prepareTextFirstMarkerDelta(document, delta, previous), markerBudget) }
readTextFirstAttachmentPayload(url: DocumentId, nodeId: string): { marker: import('../automerge-markdown').BlockMarkerValue; facts: Record<string, import('../types-records/block-record-facts').BlockScalar> } | undefined { const handle = this.textFirstRecord(url).handle const rawMarkers: Array<{ readonly plainIndex: number; readonly marker: BlockMarkerValue }> = [] let plainIndex = 0 for (const span of A.spans(handle.doc() as unknown as A.Doc<FrameworkDoc>, ['content'])) { if (span.type === 'block') { rawMarkers.push({ plainIndex, marker: span.value as BlockMarkerValue }) } else { plainIndex += span.value.length } } const matches = rawMarkers.filter(({ marker }) => { const attrs = marker.attrs return typeof attrs === 'object' && attrs !== null && !Array.isArray(attrs) && (attrs as Record<string, unknown>)['reams.nodeId'] === nodeId }) if (matches.length > 1) throw new Error(`Clipboard source has duplicate native markers: ${nodeId}`) const match = matches[0] if (!match) return undefined const atOwnerPosition = rawMarkers.filter(candidate => candidate.plainIndex === match.plainIndex) if (atOwnerPosition.length !== 1) throw new Error(`Clipboard source has conflicting native markers at position ${match.plainIndex}`) const marker = snapshotNativeMarker(match.marker) validateScalarPayload(marker, `marker:${nodeId}`) const document = handle.doc() as unknown as Readonly<Record<string, unknown>> const facts: Record<string, import('../types-records/block-record-facts').BlockScalar> = {} for (const key of Object.keys(document ?? {})) { const decoded = decodeBlockFactKey(key) if (decoded?.[0] !== nodeId) continue const candidates = readBlockFactCandidates(document!, key) if (candidates.length > 1) throw new Error(`Clipboard cannot preserve conflicted source fact: ${key}`) if (candidates.length === 0) throw new Error(`Clipboard cannot preserve malformed source fact: ${key}`) facts[key] = snapshotBlockFact(candidates[0]!) } return { marker, facts } }
readTextFirstAttachmentFacts(url: DocumentId, expectedHeads: Heads): Readonly<Record<string, BlockScalar>> { const record = this.textFirstRecord(url) const document = record.handle.doc() as unknown as A.Doc<Record<string, unknown>> if (!headsEqual(normalizeHeads(getHeads(document as Doc<PushworkFileDocument>)), expectedHeads)) throw new Error(`Text-first attachment fact snapshot is stale: ${url}`) const facts: Record<string, BlockScalar> = {} for (const key of Object.keys(document)) { if (!decodeBlockFactKey(key)) continue const candidates = readBlockFactCandidates(document, key) if (candidates.length !== 1) throw new Error(`Cannot snapshot malformed record fact: ${key}`) facts[key] = snapshotBlockFact(candidates[0]!) } return Object.freeze(facts) }
/** Read all native attachment markers from one exact Automerge frontier. */ readTextFirstAttachmentMarkers(url: DocumentId, expectedHeads: Heads): Readonly<Record<string, BlockMarkerValue>> { const record = this.textFirstRecord(url) const document = record.handle.doc() as unknown as A.Doc<FrameworkDoc> const heads = normalizeHeads(getHeads(document as Doc<PushworkFileDocument>)) if (!headsEqual(heads, expectedHeads)) throw new Error(`Text-first attachment marker snapshot is stale: ${url}`) const cached = record.attachmentMarkerCache if (cached && headsEqual(cached.heads, heads)) return defensiveTextFirstAttachmentMarkers(cached.markers) const snapshot = snapshotTextFirstAttachmentMarkers(document) record.attachmentMarkerCache = { heads: [...heads], markers: snapshot } return defensiveTextFirstAttachmentMarkers(snapshot) }
flushTextFirstDocument(url: AutomergeUrl): Promise<void> { return this.textFirstRecord(url).writer.flush() }
async releaseTextFirstDocument(url: AutomergeUrl): Promise<void> { const record = this.textFirstRecords.get(url) if (!record || record.disposed) return await record.writer.flush() if (record.owner !== undefined || record.listeners.size > 0 || record.leases.size > 0 || this.textFirstRecords.get(url) !== record) return record.disposed = true record.handle.off('change', record.onChange) ;(record.handle as unknown as { off: (event: string, listener: () => void) => void }).off('heads-changed', record.onChange) this.textFirstRecords.delete(url) if (record.leases.size === 0 && this.textFirstLeases.get(url) === record.leases) this.textFirstLeases.delete(url) }
getTextFirstProgress(url: AutomergeUrl): TextWriterProgress { return this.textFirstRecord(url).writer.progress }
hasPendingTextFirstWrites(): boolean { return [...this.textFirstRecords.values()].some(record => !record.disposed && ( record.writer.progress.durableRevision < record.writer.progress.materializedRevision || record.writer.progress.flushError !== null )) }
async flushPreparedTextFirstDocuments(): Promise<void> { await Promise.all([...this.textFirstRecords.values()].filter(record => !record.disposed).map(record => record.writer.flush())) }
subscribeTextFirstDocument(url: AutomergeUrl, listener: (event: { readonly heads: readonly string[]; readonly origin?: object }) => void): () => void { const record = this.textFirstRecord(url) record.listeners.add(listener) return () => record.listeners.delete(listener) }
claimTextFirstEditor(url: AutomergeUrl, owner: object): () => void { const record = this.textFirstRecord(url) if (record.owner !== undefined && record.owner !== owner) throw new Error(`Text-first editor already claimed for ${url}`) record.owner = owner let released = false return () => { if (!released && record.owner === owner) record.owner = undefined released = true } }
private validateTextContentShape(url: AutomergeUrl, doc: PushworkFileDocument): 'scalar' | 'rich' { return validateTextContentShapeValue(url, doc) }
private sanitizeTextFileSnapshot(doc: PushworkFileDocument): PushworkFileDocument { if (typeof doc.content !== 'string') return doc return { ...doc, content: visibleTextFromDoc(doc as unknown as A.Doc<FrameworkDoc>), } }
private async flushTextWrite(url: AutomergeUrl, localOnly = false): Promise<void> { const previous = this.flushQueues.get(url) ?? Promise.resolve() const queued = previous.catch(() => {}).then(() => { const ids = [url.slice('automerge:'.length) as AutomergeRepoDocumentId] return localOnly && this.repo.flushLocal ? this.repo.flushLocal(ids) : this.repo.flush(ids) }) const tail = queued.finally(() => { if (this.flushQueues.get(url) === tail) this.flushQueues.delete(url) }) this.flushQueues.set(url, tail) await tail }
/** Host-internal durability barrier for a validated .reams state file. * State services own the capability; public Vault callers do not receive it. */ async flushInternalStateFile(url: AutomergeUrl): Promise<void> { await this.flushTextWrite(url, true) }
private awaitPendingFlush(url: AutomergeUrl): Promise<void> | undefined { return this.flushQueues.get(url) }
private async waitForPendingFlush(url: AutomergeUrl): Promise<void> { const pending = this.awaitPendingFlush(url) if (pending) await pending }
private async waitForPublicWriteFlush(url: AutomergeUrl): Promise<void> { // Let an already-started writer enter its repo flush before checking the // queue. This keeps overlapping public calls on separate flush attempts. await Promise.resolve() const pending = this.awaitPendingFlush(url) if (pending) await pending.catch(() => {}) }
private runPublicWrite<T>(url: AutomergeUrl, operation: () => Promise<T>): Promise<T> { const previous = this.publicWriteQueues.get(url) ?? Promise.resolve() const queued = previous.catch(() => {}).then(async () => { const catalog = await this.sharedFrontmatterCatalog?.(url) if (catalog) this.publicWriteCatalogs.set(url, catalog) try { return await operation() } finally { this.publicWriteCatalogs.delete(url) } }) const tail = queued.then(() => undefined, () => undefined) this.publicWriteQueues.set(url, tail) void tail.finally(() => { if (this.publicWriteQueues.get(url) === tail) this.publicWriteQueues.delete(url) }) return queued }
// Record authorizers derive from the current text-first frontier. private markdownSnapshot(handle: DocHandle<PushworkFileDocument>, heads: Heads): MarkdownSnapshot | undefined { try { const document = A.view(handle.doc() as A.Doc<PushworkFileDocument>, [...heads]) const content = visibleTextFromDoc(document as unknown as A.Doc<FrameworkDoc>) const nodeIndex = buildRecordNodeIndex(document as unknown as A.Doc<FrameworkDoc>, { path: ['content'], recognizedTypeNames: this.textFirstRecords.get(handle.url)?.recognizedTypeNames ?? [] }) const rich = inspectRichText(document as unknown as A.Doc<FrameworkDoc>, { path: ['content'] }) const blocks = rich.blocks() const marks = rich.marksInPlainCoords() const taskCheckboxes = new Map<string, TaskCheckboxSnapshot>() const authorizedTypesByNode = new Map<string, ReadonlySet<string>>() if (nodeIndex.status === 'available') { for (const node of nodeIndex.index.nodes) { const line = content.slice(node.from, node.to) const typeNames = new Set([...line.matchAll(/#([A-Za-z0-9_/-]+)/g)].map(match => match[1]!)) authorizedTypesByNode.set(node.id, typeNames) const checkbox = locateTaskCheckbox(content, node) const block = blocks.find(candidate => candidate.attrs['reams.nodeId'] === node.id) if (!checkbox || !block) continue const richRange = rich.toRichRange({ start: checkbox.markerFrom, end: checkbox.markerTo }) taskCheckboxes.set(node.id, { checkbox, nodeId: node.id, nodeKind: node.kind, nativeType: block.type, nativeRichIndex: block.richIndex, checkboxRichFrom: richRange.start, authorizedTypeNames: typeNames, hasTouchingMarks: marks.some(mark => mark.end >= checkbox.markerFrom && mark.start <= checkbox.markerTo), }) } } return { shape: this.validateTextContentShape(handle.url, document), content, nodeIndex, taskCheckboxes, authorizedTypesByNode, richCoordinates: { marks, toRichRange: range => rich.toRichRange(range) } } } catch { return undefined } }
getTaskCheckboxWorkDiagnostics(): Readonly<typeof this.checkboxWork> { return Object.freeze({ ...this.checkboxWork }) }
/** * Create a new file document. * Returns the AutomergeUrl of the created document. * * The document is immediately available (synchronous Repo.create). * Content starts as an empty string unless initialContent is supplied. */ createFile(fileName: string, options?: CreateFileOptions, initialContent = ''): AutomergeUrl { const ext = extensionFromFileName(fileName) const mimeType = mimeTypeFromExtension(ext) const contentType = options?.contentType ?? inferContentType(fileName, mimeType) const handle = this.repo.create<PushworkFileDocument>() handle.change((doc) => { doc['@patchwork'] = { type: 'file' } doc.name = fileName doc.extension = ext doc.mimeType = mimeType doc.contentType = contentType doc.content = initialContent doc.metadata = { permissions: 0o644 } if (options?.onboarding) doc.reamsOnboarding = { version: 1, origin: this.onboardingOrigin ?? 'vault' } if (options?.reamsState) doc[REAMS_STATE_MARKER] = { kind: 'file', version: 1 } if (options?.reamsState && options.reamsInitialValues) doc.reamsValues = { ...options.reamsInitialValues } }) return handle.url }
/** * Create a new binary file document. * Returns the AutomergeUrl of the created document. * * Unlike createFile(), this stores content as Uint8Array and uses * the provided mimeType directly (not inferred from extension). * Binary files must never use updateText() — their content is * replaced atomically. */ createBinaryFile(fileName: string, data: Uint8Array, mimeType: string, options?: CreateFileOptions): AutomergeUrl { const ext = extensionFromFileName(fileName) const contentType = options?.contentType ?? inferContentType(fileName, mimeType) const handle = this.repo.create<PushworkFileDocument>() handle.change((doc) => { doc['@patchwork'] = { type: 'file' } doc.name = fileName doc.extension = ext doc.mimeType = mimeType doc.contentType = contentType doc.content = new Uint8Array(data) doc.metadata = { permissions: 0o644 } if (options?.onboarding) doc.reamsOnboarding = { version: 1, origin: this.onboardingOrigin ?? 'vault' } }) return handle.url }
/** * Create a new folder document. * Returns the AutomergeUrl of the created document. * * The document is immediately available (synchronous Repo.create). * Starts with an empty docs array. */ createFolder(options?: { onboarding?: boolean }): AutomergeUrl { const handle = this.repo.create<PushworkDirectoryDocument>() handle.change((doc) => { doc['@patchwork'] = { type: 'folder' } doc.docs = [] if (options?.onboarding) doc.reamsOnboarding = { version: 1, origin: this.onboardingOrigin ?? 'vault' } }) return handle.url }
/** Vault-scoped hidden state uses common genesis and an idempotent CRDT link. * Existing linked pre-release documents are discovered by VaultStateRoot. */ async createOrOpenState(vaultId: AutomergeUrl, parentUrl: AutomergeUrl, name: string, kind: 'root' | 'file', initialContent = '', initialValues?: Record<string, string>): Promise<AutomergeUrl> { return withFolderMutationLock(this.repo, parentUrl, async () => { const parent = await this.repo.find<PushworkDirectoryDocument>(parentUrl) await this.waitForPendingFlush(parentUrl) assertFolderEntryVacant(parent.doc(), name) let handle: DocHandle<PushworkDirectoryDocument | PushworkFileDocument> if (kind === 'root') { handle = await openReservedDocument<PushworkDirectoryDocument>(this.repo, vaultId, 'root', { '@patchwork': { type: 'folder' }, docs: [], [REAMS_STATE_MARKER]: { kind: 'root', version: 1 }, }) } else { const extension = extensionFromFileName(name) const mimeType = mimeTypeFromExtension(extension) handle = await openReservedDocument<PushworkFileDocument>(this.repo, vaultId, `file:${name}`, { '@patchwork': { type: 'file' }, name, extension, mimeType, contentType: inferContentType(name, mimeType), content: '', metadata: { permissions: 0o644 }, [REAMS_STATE_MARKER]: { kind: 'file', version: 1 }, reamsValues: {}, }) validateStateDocument(handle.doc(), 'file', true) // Caller defaults are ordinary edits after shared genesis. Retry or a // fresh service must not seed over an already edited detached document. if (A.getAllChanges(handle.doc()).length === 1 && (initialContent || Object.keys(initialValues ?? {}).length)) { handle.change(draft => { const file = draft as PushworkFileDocument & { reamsValues: Record<string, string> } file.content = initialContent for (const [key, value] of Object.entries(initialValues ?? {})) file.reamsValues![key] = value }) } } validateStateDocument(handle.doc(), kind, kind === 'file') await this.flushInternalStateFile(handle.url) await linkReservedDocument(parent, { name, type: kind === 'root' ? 'folder' : 'file', url: handle.url }, () => { validateStateDocument(handle.doc(), kind, kind === 'file') if (kind === 'root') assertFolderAttachment(this.repo, parentUrl, handle as DocHandle<PushworkDirectoryDocument>) }) // A failed parent flush must preserve the child and its published link. await this.flushInternalStateFile(parentUrl) return handle.url }) }
/** * Create a new file and register it in a folder's docs array. * Returns the AutomergeUrl of the created file. */ async createFileInFolder(folderUrl: AutomergeUrl, fileName: string, options?: CreateFileOptions, initialContent = '', beforeLink?: (url: AutomergeUrl) => void): Promise<AutomergeUrl> { return withFolderMutationLock(this.repo, folderUrl, async () => { const folderHandle = await this.repo.find<PushworkDirectoryDocument>(folderUrl) await this.waitForPendingFlush(folderUrl) assertFolderEntryVacant(folderHandle.doc(), fileName) // Populate the detached child before publishing its URL in the parent. // Observers of the folder's create event must never race an empty file // shell followed by a separate content mutation. const fileUrl = this.createFile(fileName, options, initialContent) let published = false try { if (initialContent !== '') { await this.updateContent(fileUrl, initialContent) } else { await this.flushTextWrite(fileUrl) } // Internal operation-local metadata handoff: no events, objects or await. // Run inside the pre-link cleanup boundary after child durability. const prepared: unknown = beforeLink?.(fileUrl) if (prepared && (typeof prepared === 'object' || typeof prepared === 'function') && 'then' in prepared) { void Promise.resolve(prepared).catch(() => {}) throw new Error('File before-link handoff must be synchronous') } let mutationError: unknown let mutationFailed = false try { folderHandle.change(doc => { assertFolderEntryVacant(doc, fileName) doc.docs.push({ name: fileName, type: 'file', url: fileUrl }) }) published = true } catch (error) { published = folderHandle.doc().docs.some(entry => entry.url === fileUrl) mutationFailed = true mutationError = error } const settled = await Promise.allSettled([this.flushTextWrite(folderUrl)]) if (mutationFailed) throw mutationError const failed = settled.find(result => result.status === 'rejected') if (failed?.status === 'rejected') throw failed.reason } catch (error) { // Before publication the detached child can be discarded safely. Once // the parent change has emitted, preserve the child even if durability // reporting fails; deleting it would leave a dangling published URL. const parentContainsChild = published || folderHandle.doc().docs.some(entry => entry.url === fileUrl) if (!parentContainsChild) this.repo.delete(fileUrl) throw error } return fileUrl }) }
/** All devices start from one template identity and common genesis, so * signed-out adoption merges the welcome note instead of copying it. */ async createOrOpenWelcomeFile(folderUrl: AutomergeUrl): Promise<AutomergeUrl> { return withFolderMutationLock(this.repo, folderUrl, async () => { const folder = await this.repo.find<PushworkDirectoryDocument>(folderUrl) await this.waitForPendingFlush(folderUrl) const existing = folder.doc().docs.find(entry => entry.name === 'welcome.md') if (existing) return existing.url const handle = await openReservedDocument<PushworkFileDocument>(this.repo, this.onboardingOrigin ? folderUrl : WELCOME_DOCUMENT_SCOPE, this.onboardingOrigin ? `anonymous-welcome/v${WELCOME_DOCUMENT_VERSION}` : 'welcome.md', { '@patchwork': { type: 'file' }, name: 'welcome.md', extension: 'md', mimeType: 'text/markdown', contentType: 'public.markdown', content: WELCOME_DOCUMENT_CONTENT, metadata: { permissions: 0o644 }, [WELCOME_DOCUMENT_MARKER]: { version: WELCOME_DOCUMENT_VERSION }, ...(this.onboardingOrigin ? { reamsOnboarding: { version: 1, origin: this.onboardingOrigin } } : {}), }) this.markStarter(handle, Math.floor(Date.now() / 1000)) await this.flushTextWrite(handle.url) // A renamed welcome note is already present; do not add its old name. if (folder.doc().docs.some(entry => entry.url === handle.url)) return handle.url await linkReservedDocument(folder, { name: 'welcome.md', type: 'file', url: handle.url }) await this.flushTextWrite(folderUrl) return handle.url }) }
/** Install the companion graph with reserved identities and creation-time * provenance. Existing user paths win; retries preserve edits. Binary bytes * are a normal write after the shared genesis (which is JSON-shaped). */ async createOrOpenOnboardingFiles(rootUrl: AutomergeUrl, files: readonly { path: string; content: string | Uint8Array; mimeType: string }[], signal?: AbortSignal, rootedUrls?: ReadonlySet<AutomergeUrl>): Promise<readonly { path: string; url: AutomergeUrl }[]> { const prefix = this.onboardingOrigin ? 'anonymous-onboarding' : 'onboarding' const folders = new Map<string, AutomergeUrl>([['', rootUrl]]) const placements: Array<{ path: string; url: AutomergeUrl }> = [] for (const file of files) { signal?.throwIfAborted() const parts = file.path.split('/') const name = parts.pop()! let parentUrl = rootUrl let path = '' for (const part of parts) { path = path ? `${path}/${part}` : part const known = folders.get(path) if (known) { parentUrl = known; continue } const folderPath = path parentUrl = await withFolderMutationLock(this.repo, parentUrl, async () => { signal?.throwIfAborted() const parent = await this.repo.find<PushworkDirectoryDocument>(parentUrl) await this.waitForPendingFlush(parentUrl) const existing = parent.doc().docs.filter(entry => entry.name === part) if (existing.length > 1) throw new Error(`Onboarding folder is ambiguous: ${folderPath}`) if (existing[0] && !isFolderEntry(existing[0])) throw new OnboardingPathCollision(folderPath) if (existing[0]) return existing[0].url const folder = await openReservedDocument<PushworkDirectoryDocument>(this.repo, rootUrl, `${prefix}/${folderPath}/v2`, { '@patchwork': { type: 'folder' }, docs: [] }) signal?.throwIfAborted() this.markStarter(folder) await this.flushTextWrite(folder.url, true) signal?.throwIfAborted() if (rootedUrls?.has(folder.url)) return folder.url const assertPublicationPath = () => { assertFolderAttachment(this.repo, parentUrl, folder) const occupied = parent.doc().docs.filter(entry => entry.name === part) if (occupied.length === 1 && (occupied[0].url !== folder.url || !isFolderEntry(occupied[0]))) throw new OnboardingPathCollision(folderPath) } assertPublicationPath() await linkReservedDocument(parent, { name: part, type: 'folder', url: folder.url }, assertPublicationPath) await this.flushTextWrite(parentUrl, true) return folder.url }) folders.set(path, parentUrl) } const fileUrl = await withFolderMutationLock(this.repo, parentUrl, async () => { signal?.throwIfAborted() const parent = await this.repo.find<PushworkDirectoryDocument>(parentUrl) await this.waitForPendingFlush(parentUrl) const existing = parent.doc().docs.filter(entry => entry.name === name) if (existing.length > 1) throw new Error(`Onboarding file is ambiguous: ${file.path}`) if (existing[0] && isFolderEntry(existing[0])) throw new OnboardingPathCollision(file.path) if (existing[0]) return existing[0].url const binary = file.content instanceof Uint8Array const handle = await openReservedDocument<PushworkFileDocument>(this.repo, rootUrl, `${prefix}/${file.path}/v2`, { '@patchwork': { type: 'file' }, name, extension: name.split('.').pop()!, mimeType: file.mimeType, contentType: inferContentType(name, file.mimeType), metadata: { permissions: 0o644 }, content: binary ? '' : file.content as string, }) signal?.throwIfAborted() this.markStarter(handle, Math.floor(Date.now() / 1000) - 1) if (binary && handle.doc().content === '') handle.change(doc => { doc.content = new Uint8Array(file.content as Uint8Array) }) await this.flushTextWrite(handle.url, true) signal?.throwIfAborted() // A rename during an interrupted installation must not add an old link. if (!rootedUrls?.has(handle.url) && !parent.doc().docs.some(entry => entry.url === handle.url)) { const assertPublicationPath = () => { const occupied = parent.doc().docs.filter(entry => entry.name === name) if (occupied.length === 1 && (occupied[0].url !== handle.url || !isFileEntry(occupied[0]))) throw new OnboardingPathCollision(file.path) } assertPublicationPath() await linkReservedDocument(parent, { name, type: 'file', url: handle.url }, assertPublicationPath) } await this.flushTextWrite(parentUrl, true) return handle.url }) placements.push({ path: file.path, url: fileUrl }) } return placements }
/** One release refresh uses a root-scoped new document and swaps the old * folder reference. Concurrent devices install the same founding history; * they never concurrently rewrite an old CRDT text or overwrite later edits. * The old history is detached rather than tombstoned across shared roots. */ async replaceWelcomeForRelease(rootUrl: AutomergeUrl, folderUrl: AutomergeUrl, oldUrl: AutomergeUrl, signal?: AbortSignal, linkReplacement = true, content = WELCOME_DOCUMENT_CONTENT, companions: readonly { target: string; url: AutomergeUrl; binary: boolean }[] = [], beforePublication?: () => void, bundlePrefix = ''): Promise<AutomergeUrl | undefined> { return withFolderMutationLock(this.repo, folderUrl, async () => { signal?.throwIfAborted() const folder = await this.repo.find<PushworkDirectoryDocument>(folderUrl) await this.waitForPendingFlush(folderUrl) if (!folder.doc().docs.some(entry => entry.url === oldUrl && isFileEntry(entry))) return undefined if (!linkReplacement) { signal?.throwIfAborted() folder.change(doc => { const index = doc.docs.findIndex(entry => entry.url === oldUrl && isFileEntry(entry)) if (index >= 0) { beforePublication?.() doc.docs.splice(index, 1) } }) await this.flushTextWrite(folderUrl, true) return undefined } // Collision fallback paths change the immutable source. Bind identity to // that payload so independent plans cannot assert incompatible genesis. const digest = await reservedDigest('welcome-refresh-content', content, JSON.stringify(companions.map(({ target, url, binary }) => [target, url, binary])), bundlePrefix) const contentKey = Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('') const replacement = await openReservedDocument<PushworkFileDocument>(this.repo, rootUrl, `welcome-refresh/${WELCOME_REFRESH_UPGRADE.id}/v${WELCOME_DOCUMENT_VERSION}/${contentKey}`, { '@patchwork': { type: 'file' }, name: 'welcome.md', extension: 'md', mimeType: 'text/markdown', contentType: 'public.markdown', content, metadata: { permissions: 0o644 }, [WELCOME_DOCUMENT_MARKER]: { version: WELCOME_DOCUMENT_VERSION }, reamsWelcomeRefresh: WELCOME_REFRESH_UPGRADE.id, reamsWelcomeRefreshRoot: rootUrl, reamsWelcomeCompanions: companions.map(companion => ({ ...companion })), reamsWelcomeBundlePrefix: bundlePrefix, }) this.markStarter(replacement, Math.floor(Date.now() / 1000)) await this.flushTextWrite(replacement.url, true) signal?.throwIfAborted() let published = false folder.change(doc => { const index = doc.docs.findIndex(entry => entry.url === oldUrl && isFileEntry(entry)) if (index < 0) return beforePublication?.() // Multiple older welcomes converge to one visible replacement. if (doc.docs.some(entry => entry.url === replacement.url)) doc.docs.splice(index, 1) else doc.docs[index].url = replacement.url published = true }) await this.flushTextWrite(folderUrl, true) return published ? replacement.url : undefined }) }
/** Host-owned starter files are locally durable before startup continues. * Their normal outbox still publishes them; opening the UI needs no network * acknowledgement. Shared genesis/link operations also tolerate two devices * installing missing defaults concurrently. Existing user files win. */ async createOrOpenStarterTemplates(rootUrl: AutomergeUrl, templates: readonly { name: string; content: string }[]): Promise<void> { const folderUrl = await withFolderMutationLock(this.repo, rootUrl, async () => { const root = await this.repo.find<PushworkDirectoryDocument>(rootUrl) const matches = root.doc().docs.filter(entry => entry.name === 'templates') if (matches.length > 1) throw new Error('Templates folder is ambiguous') const existing = matches[0] if (existing) { if (!isFolderEntry(existing)) throw new Error('Templates is not a folder') return existing.url } const folder = await openReservedDocument<PushworkDirectoryDocument>(this.repo, rootUrl, this.onboardingOrigin ? 'anonymous-starter/templates/v1' : 'starter/templates/v1', { '@patchwork': { type: 'folder' }, docs: [], ...(this.onboardingOrigin ? { reamsOnboarding: { version: 1, origin: this.onboardingOrigin } } : {}) }) this.markStarter(folder) await this.flushTextWrite(folder.url, true) await linkReservedDocument(root, { name: 'templates', type: 'folder', url: folder.url }, () => assertFolderAttachment(this.repo, rootUrl, folder)) await this.flushTextWrite(rootUrl, true) return folder.url }) await withFolderMutationLock(this.repo, folderUrl, async () => { const folder = await this.repo.find<PushworkDirectoryDocument>(folderUrl) for (const template of templates) { const matches = folder.doc().docs.filter(entry => entry.name === template.name) if (matches.length > 1) throw new Error(`Template is ambiguous: ${template.name}`) const existing = matches[0] if (existing) { if (isFolderEntry(existing)) throw new Error(`Template is not a file: ${template.name}`) continue } const file = await openReservedDocument<PushworkFileDocument>(this.repo, rootUrl, `${this.onboardingOrigin ? 'anonymous-starter' : 'starter'}/${template.name}/v1`, { ...starterTemplateDocument(template.name, template.content), ...(this.onboardingOrigin ? { reamsOnboarding: { version: 1, origin: this.onboardingOrigin } } : {}), }) this.markStarter(file, Math.floor(Date.now() / 1000) - 2) await this.flushTextWrite(file.url, true) await linkReservedDocument(folder, { name: template.name, type: 'file', url: file.url }) } await this.flushTextWrite(folderUrl, true) }) }
/** Mark only host-created starters, never a user document reused by path. * Reserved genesis stays unchanged so existing document identities/history * remain compatible. Anonymous provenance survives first-account adoption. */ private markStarter(handle: DocHandle<PushworkFileDocument | PushworkDirectoryDocument>, initialTime?: number): void { if (handle.doc().reamsOnboarding && (initialTime === undefined || A.getChangesMetaSince(handle.doc(), []).some(change => (change.time ?? 0) > 0))) return // Founding reserved changes are timestamp-free. Separate first-install // stamps by a second so fast creation does not alphabetically tie templates, // companions and welcome. Later edits/retries keep their real history. handle.update(current => A.change(A.clone(current, { actor: A.getActorId(current) }), { time: initialTime ?? Math.floor(Date.now() / 1000) }, doc => { doc.reamsOnboarding = { version: 1, origin: this.onboardingOrigin ?? 'vault' } })) }
/** * Create a new binary file and register it in a folder's docs array. * Returns the AutomergeUrl of the created file. */ async createBinaryFileInFolder(folderUrl: AutomergeUrl, fileName: string, data: Uint8Array, mimeType: string, options?: CreateFileOptions, beforeLink?: (url: AutomergeUrl) => void): Promise<AutomergeUrl> { const inputName = fileName const inputData = data.slice() const inputMimeType = mimeType const inputOptions = options ? { ...options } : undefined return withFolderMutationLock(this.repo, folderUrl, async () => { const folderHandle = await this.repo.find<PushworkDirectoryDocument>(folderUrl) await this.waitForPendingFlush(folderUrl) assertFolderEntryVacant(folderHandle.doc(), inputName) const fileUrl = this.createBinaryFile(inputName, inputData, inputMimeType, inputOptions) let published = false try { await this.repo.flush([fileUrl.replace(/^automerge:/, '') as AutomergeRepoDocumentId]) const prepared: unknown = beforeLink?.(fileUrl) if (prepared && (typeof prepared === 'object' || typeof prepared === 'function') && 'then' in prepared) { void Promise.resolve(prepared).catch(() => {}) throw new Error('File before-link handoff must be synchronous') } let mutationError: unknown let mutationFailed = false try { folderHandle.change(doc => { assertFolderEntryVacant(doc, inputName) doc.docs.push({ name: inputName, type: 'file', url: fileUrl }) }) published = true } catch (error) { published = folderHandle.doc().docs.some(entry => entry.url === fileUrl) mutationFailed = true mutationError = error } const settled = await Promise.allSettled([this.flushTextWrite(folderUrl)]) if (mutationFailed) throw mutationError const failed = settled.find(result => result.status === 'rejected') if (failed?.status === 'rejected') throw failed.reason } catch (error) { if (!published && !folderHandle.doc().docs.some(entry => entry.url === fileUrl)) this.repo.delete(fileUrl) throw error } return fileUrl }) }
/** * Register an existing file in a folder's docs array. * No-op if the file is already present in the folder. */ async addFileToFolder(folderUrl: AutomergeUrl, fileUrl: AutomergeUrl, fileName: string, receipt?: (parent: DocHandle<PushworkDirectoryDocument>) => void, beforeMutation?: () => void): Promise<void> { return withFolderMutationLock(this.repo, folderUrl, async () => { const folderHandle = await this.repo.find<PushworkDirectoryDocument>(folderUrl) await this.waitForPendingFlush(folderUrl) const folderDoc = folderHandle.doc() receipt?.(folderHandle) if (folderDoc.docs.some((d) => d.url === fileUrl)) return beforeMutation?.() let mutationError: unknown let mutationFailed = false try { folderHandle.change(doc => { doc.docs.push({ name: fileName, type: 'file', url: fileUrl }) }) } catch (error) { mutationFailed = true; mutationError = error } const settled = await Promise.allSettled([this.flushTextWrite(folderUrl)]) if (mutationFailed) throw mutationError const failed = settled.find(result => result.status === 'rejected') if (failed?.status === 'rejected') throw failed.reason }) }
/** * Register an existing folder in a parent folder's docs array. * No-op if the folder URL is already present in the parent. * * Serializes the destination and child identities with folder deletion and * validates the child body inside the publication turn. */ async addFolderToFolder(folderUrl: AutomergeUrl, childUrl: AutomergeUrl, name: string, receipt?: (parent: DocHandle<PushworkDirectoryDocument>) => void, beforeMutation?: () => void): Promise<void> { return withFolderMutationLocks(this.repo, [folderUrl, childUrl], async () => { const child = await this.getFolderDeletionHandle(childUrl) if (!child) { throw Object.assign(new Error(`Cannot attach deleted folder ${childUrl}`), { code: 'DOCUMENT_DELETED' }) } const folderHandle = await this.repo.find<PushworkDirectoryDocument>(folderUrl) await this.waitForPendingFlush(folderUrl) const folderDoc = folderHandle.doc() receipt?.(folderHandle) if (folderDoc.docs.some((d) => d.url === childUrl)) return beforeMutation?.() let mutationError: unknown let mutationFailed = false try { folderHandle.change(doc => { assertFolderAttachment(this.repo, folderUrl, child) doc.docs.push({ name, type: 'folder', url: childUrl }) }) } catch (error) { mutationFailed = true; mutationError = error } const settled = await Promise.allSettled([this.flushTextWrite(folderUrl)]) if (mutationFailed) throw mutationError const failed = settled.find(result => result.status === 'rejected') if (failed?.status === 'rejected') throw failed.reason }) }
/** * Resolve the DocHandle for a Pushwork file URL. * * Returns the handle when the underlying document passes the * {@link isPushworkFile} type guard, or `undefined` for folder URLs * and URLs that fail the guard. * * Contract — this method DOES NOT trigger the contentType * heal-on-access path. It is a low-level raw-handle seam for code that * genuinely needs a live DocHandle: reactive sync, foreign-file adoption, * and active-set timestamp wiring. Higher-level consumers should use the * read methods below so raw handle semantics do not spread. * Use {@link getFile} when heal-on-access behavior is wanted. */ async findFileHandle(url: AutomergeUrl, options?: import('../storage/document-runtime-contract').DocumentLoadOptions): Promise<DocHandle<PushworkFileDocument> | undefined> { const handle = await (this.repo.load ? this.repo.load<PushworkFileDocument>(url, options) : this.repo.find<PushworkFileDocument>(url)) const doc = handle.doc() if (!isPushworkFile(doc)) return undefined return handle }
/** Read canonical text at the current or an exact historical frontier. */ async readText(url: DocumentId, requestedHeads?: Heads): Promise<TextReadResult> { assertDocumentId(url) if (requestedHeads !== undefined && requestedHeads.length === 0) return { status: 'missing-heads', documentId: url } let handle: DocHandle<PushworkFileDocument> try { handle = await this.repo.find<PushworkFileDocument>(url as AutomergeUrl) } catch (error) { return isAuthoritativeNotFound(error) ? { status: 'not-found', documentId: url } : { status: 'unavailable', documentId: url } } if (!handle.isReady() || handle.isUnloaded?.()) return { status: 'unavailable', documentId: url } try { const live = handle.doc() if (!isPushworkFile(live)) return { status: 'not-text', documentId: url } if (this.textFirstRecords.has(url) && requestedHeads === undefined) { try { const record = this.textFirstRecord(url) const heads = normalizeHeads(getHeads(record.handle.doc() as Doc<PushworkFileDocument>)) record.writer.prepare(heads) return { status: 'available', documentId: url, heads, content: record.writer.getText().toString() } } catch { // A committed observer failure stops the writer, but the raw CRDT remains readable. } } const document = requestedHeads === undefined ? live : A.hasHeads(live as Doc<PushworkFileDocument>, [...normalizeHeads(requestedHeads)]) ? A.view(live as Doc<PushworkFileDocument>, [...normalizeHeads(requestedHeads)]) : null if (!document) return { status: 'missing-heads', documentId: url } if (document.content instanceof Uint8Array || typeof document.content !== 'string') return { status: 'not-text', documentId: url } const heads = normalizeHeads(getHeads(document as Doc<PushworkFileDocument>)) const readStarted = isProfilingEnabled() ? performance.now() : 0 const content = visibleTextFromDoc(A.clone(document) as unknown as A.Doc<FrameworkDoc>) if (isProfilingEnabled()) recordPerfSample('markdown.reconcile', performance.now() - readStarted, { label: 'authoritative.readText' }) return { status: 'available', documentId: url, heads, content } } catch (error) { console.log('projection snapshot error', error) return { status: 'unavailable', documentId: url } } }
/** Lightweight authoritative frontier read for derived-index consumers. */ async readCurrentHeads(url: DocumentId): Promise<Heads | undefined> { try { const handle = await this.repo.find<PushworkFileDocument>(url) if (!handle.isReady() || handle.isUnloaded?.()) return undefined const doc = handle.doc() return isPushworkFile(doc) && this.contentTypeForFile(doc) === 'public.markdown' && typeof doc.content === 'string' ? normalizeHeads(getHeads(doc as Doc<PushworkFileDocument>)) : undefined } catch { return undefined } }
/** Read canonical Markdown and its NodeIndex from the same exact view. */ async readMarkdown(url: DocumentId, requestedHeads?: Heads, recognizedTypeNames?: readonly string[]): Promise<MarkdownReadResult> { assertDocumentId(url) if (requestedHeads !== undefined && requestedHeads.length === 0) return { status: 'missing-heads', documentId: url } let handle: DocHandle<PushworkFileDocument> try { handle = await this.repo.find<PushworkFileDocument>(url as AutomergeUrl) } catch (error) { return isAuthoritativeNotFound(error) ? { status: 'not-found', documentId: url } : { status: 'unavailable', documentId: url } } if (!handle.isReady() || handle.isUnloaded?.()) return { status: 'unavailable', documentId: url } try { const live = handle.doc() if (!isPushworkFile(live)) return { status: 'not-markdown', documentId: url } const normalizedHeads = requestedHeads === undefined ? undefined : normalizeHeads(requestedHeads) const document = normalizedHeads === undefined ? live : A.hasHeads(live as Doc<PushworkFileDocument>, [...normalizedHeads]) ? A.view(live as Doc<PushworkFileDocument>, [...normalizedHeads]) : null if (!document) return { status: 'missing-heads', documentId: url } if (document.content instanceof Uint8Array || typeof document.content !== 'string') return { status: 'not-markdown', documentId: url } if (this.contentTypeForFile(document) !== 'public.markdown') return { status: 'not-markdown', documentId: url } const heads = normalizeHeads(getHeads(document as Doc<PushworkFileDocument>)) const liveHeads = normalizeHeads(getHeads(live as Doc<PushworkFileDocument>)) if (recognizedTypeNames !== undefined) { const detached = A.clone(document) as unknown as A.Doc<FrameworkDoc> const content = visibleTextFromDoc(detached) const nodeIndex = buildRecordNodeIndex(detached, { path: ['content'], recognizedTypeNames }) return { status: 'available', documentId: url, heads, content, nodeIndex, listFolds: this.readListFoldsFromDoc(detached), blockFacts: this.snapshotBlockFactCandidates(detached) } } if (this.textFirstRecords.has(url) && (headsEqual(heads, liveHeads) || requestedHeads !== undefined)) { const record = this.textFirstRecords.get(url) let content = visibleTextFromDoc(A.clone(document) as unknown as A.Doc<FrameworkDoc>) let nodeIndex: NodeIndexResult if (record && headsEqual(heads, liveHeads)) { try { const state = this.getTextFirstDocumentState(url) content = state.content nodeIndex = state.nodeIndex } catch { nodeIndex = buildRecordNodeIndex(A.clone(document) as unknown as A.Doc<FrameworkDoc>, { path: ['content'], recognizedTypeNames: record?.recognizedTypeNames ?? [] }) } } else { nodeIndex = buildRecordNodeIndex(A.clone(document) as unknown as A.Doc<FrameworkDoc>, { path: ['content'], recognizedTypeNames: record?.recognizedTypeNames ?? [] }) } return { status: 'available', documentId: url, heads, content, nodeIndex, listFolds: this.readListFoldsFromDoc(document), blockFacts: this.snapshotBlockFactCandidates(document) } } const detached = A.clone(document) as unknown as A.Doc<FrameworkDoc> const readStarted = isProfilingEnabled() ? performance.now() : 0 const content = visibleTextFromDoc(detached) if (isProfilingEnabled()) recordPerfSample('markdown.reconcile', performance.now() - readStarted, { label: 'authoritative.readMarkdown' }) const indexStarted = isProfilingEnabled() ? performance.now() : 0 const nodeIndex = buildNodeIndex(detached, { path: ['content'] }) if (isProfilingEnabled()) recordPerfSample('markdown.reconcile', performance.now() - indexStarted, { label: 'authoritative.node-index' }) return { status: 'available', documentId: url, heads, content, nodeIndex, listFolds: this.readListFoldsFromDoc(detached), blockFacts: this.snapshotBlockFactCandidates(detached) } } catch { return { status: 'unavailable', documentId: url } } }
/** * Detached background read. Unlike readMarkdown this deliberately does not * parse Markdown or build NodeIndex; it captures text, native identity * anchors, and scalar facts needed by the metadata worker. */ async readMarkdownProjectionSnapshot(url: DocumentId, requestedHeads?: Heads): Promise<MarkdownProjectionSnapshot | { readonly status: DocumentReadStatus | 'not-markdown'; readonly documentId: DocumentId }> { assertDocumentId(url) try { const handle = await (this.repo.load ? this.repo.load<PushworkFileDocument>(url, { priority: 'background' }) : this.repo.find<PushworkFileDocument>(url)) if (!handle.isReady() || handle.isUnloaded?.()) return { status: 'unavailable', documentId: url } const live = handle.doc() if (!isPushworkFile(live)) return { status: 'not-markdown', documentId: url } const liveHeads = normalizeHeads(getHeads(live as Doc<PushworkFileDocument>)) const normalizedRequested = requestedHeads === undefined ? undefined : normalizeHeads(requestedHeads) if (normalizedRequested?.length === 0) return { status: 'missing-heads', documentId: url } if (normalizedRequested && !headsEqual(liveHeads, normalizedRequested) && !A.hasHeads(live as Doc<PushworkFileDocument>, [...normalizedRequested])) return { status: 'missing-heads', documentId: url } // Select the exact frontier before validating content type. The live // frontier may have changed to binary while an older Markdown view is // still a valid projection source. const useLive = normalizedRequested === undefined || headsEqual(liveHeads, normalizedRequested) const view = useLive ? live : A.view(live as Doc<PushworkFileDocument>, [...normalizedRequested!]) if (view.content instanceof Uint8Array || typeof view.content !== 'string' || this.contentTypeForFile(view) !== 'public.markdown') return { status: 'not-markdown', documentId: url } let spans: Span[] = [] let plainContentFallback: string | undefined try { spans = A.spans(view as unknown as A.Doc<FrameworkDoc>, ['content']) } catch { // Historical text-only frontiers predate native rich-text content. // They remain valid Markdown projection sources even though spans() // cannot address a scalar content value. if (typeof view.content !== 'string') throw new Error('invalid markdown content projection') plainContentFallback = view.content } const contentParts: string[] = [] const textFirst = this.textFirstRecords.get(url) const blocks: NativeBlockSnapshot[] = [] let plainIndex = 0 if (plainContentFallback !== undefined) contentParts.push(plainContentFallback) for (const span of spans) { if (span.type === 'text') { contentParts.push(span.value); plainIndex += span.value.length; continue } const marker = span.value as BlockMarkerValue if (typeof marker.type !== 'string') return { status: 'unavailable', documentId: url } const nodeId = isPlainRecord(marker.attrs) ? marker.attrs['reams.nodeId'] : undefined // Parentage is parsed from Markdown, not copied from opaque native ancestry. blocks.push({ plainIndex, type: marker.type, parents: [], attrs: typeof nodeId === 'string' ? { 'reams.nodeId': nodeId } : {} }) } const content = contentParts.join('') const cached = useLive && textFirst?.nodeIndexCache && headsEqual(textFirst.nodeIndexCache.heads, liveHeads) && textFirst.nodeIndexCache.content === content ? textFirst.nodeIndexCache.nodeIndex : undefined return { status: 'available', documentId: url, heads: useLive ? liveHeads : normalizeHeads(getHeads(view as Doc<PushworkFileDocument>)), content, blocks, blockFacts: this.snapshotBlockFactCandidates(view as unknown as Readonly<Record<string, unknown>>), ...(cached?.status === 'available' ? { nodeIndex: { headsKey: cached.index.headsKey, nodes: cached.index.nodes, hasUnmappedCustomMarkers: cached.index.hasUnmappedCustomMarkers } satisfies NodeIndexSnapshot } : {}) } } catch { return { status: 'unavailable', documentId: url } } }
private snapshotBlockFactCandidates(document: Readonly<Record<string, unknown>>): Readonly<Record<string, readonly BlockScalar[]>> { return Object.freeze(Object.fromEntries(Object.keys(document).flatMap(key => { if (!decodeBlockFactKey(key)) return [] return [[key, Object.freeze([...readBlockFactCandidates(document, key)])]] }))) }
/** Check whether the document is currently at the supplied exact frontier. */ async isCurrent(url: DocumentId, requestedHeads: Heads): Promise<boolean> { assertDocumentId(url) if (requestedHeads.length === 0) return false try { const handle = await this.repo.find<PushworkFileDocument>(url as AutomergeUrl) if (!handle.isReady() || handle.isUnloaded?.()) return false const document = handle.doc() return isPushworkFile(document) && headsEqual(getHeads(document as Doc<PushworkFileDocument>), requestedHeads) } catch { return false } }
/** Record-specific exact fact read; raw handles do not leave this boundary. */ async readBlockFactsAt(url: DocumentId, requestedHeads: Heads) { const handle = await this.findFileHandle(url as AutomergeUrl).catch(() => undefined) return handle ? readBlockFactDocumentAt(handle as unknown as Parameters<typeof readBlockFactDocumentAt>[0], normalizeHeads(requestedHeads)) : undefined }
async readBlockFacts(url: DocumentId) { const handle = await this.findFileHandle(url as AutomergeUrl).catch(() => undefined) // Automerge documents are immutable snapshots. The current handle snapshot // is already a safe read boundary; cloning it would copy the entire rich // Markdown document merely to inspect one top-level Record fact. return handle ? handle.doc() as unknown as Readonly<Record<string, unknown>> : undefined }
/** * Read a file document by URL. * Returns undefined if the document is not a valid Pushwork file. */ async getFile(url: AutomergeUrl): Promise<PushworkFileDocument | undefined> { const handle = await this.repo.find<PushworkFileDocument>(url) const doc = handle.doc() if (!isPushworkFile(doc)) return undefined return this.sanitizeTextFileSnapshot(doc) }
/** Read stored type metadata without materializing the file's text projection. */ async getFileContentType(url: AutomergeUrl): Promise<string | undefined> { const handle = await this.repo.find<PushworkFileDocument>(url) const doc = handle.doc() if (!isPushworkFile(doc)) return undefined return this.contentTypeForFile(doc) }
/** Read view-routing metadata without projecting the document's Markdown text. */ async getFileRoutingInfo(url: AutomergeUrl): Promise<{ contentType?: string; mimeType?: string } | undefined> { const handle = await this.repo.find<PushworkFileDocument>(url) const doc = handle.doc() if (!isPushworkFile(doc)) return undefined const contentType = this.contentTypeForFile(doc) return { contentType, mimeType: doc.mimeType } }
private contentTypeForFile(doc: PushworkFileDocument): string | undefined { return typeof doc.contentType === 'string' && doc.contentType.length > 0 ? doc.contentType : inferContentType(doc.name, doc.mimeType) }
/** Read Reams UI disclosure state without touching Markdown content. */ async readListFolds(url: DocumentId): Promise<ListFoldState> { if (!url) return {} const handle = await this.findFileHandle(url as AutomergeUrl).catch(() => undefined) return handle ? this.readListFoldsFromDoc(handle.doc()) : {} }
private readListFoldsFromDoc(doc: Readonly<Record<string, unknown>>): ListFoldState { return Object.fromEntries(Object.entries(doc).flatMap(([key, value]) => { const nodeId = decodeListFoldKey(key) return nodeId && typeof value === 'boolean' ? [[nodeId, { collapsed: value }]] : [] })) }
/** Write one fold entry as an independent Automerge change. */ async updateListFold(url: DocumentId, nodeId: string, collapsed: boolean): Promise<void> { assertDocumentId(url) if (!nodeId) throw new Error('List fold node ID is required') const handle = await this.findFileHandle(url as AutomergeUrl) if (!handle) throw new Error(`Document ${url} is not a writable file`) const ownership = semanticWriteOwnership(handle) ownership.enter() try { handle.change(doc => { ;(doc as unknown as Record<string, unknown>)[encodeListFoldKey(nodeId)] = collapsed }, { message: 'reams:ui:list-fold' }) rememberSemanticHeads(handle, normalizeHeads(getHeads(handle.doc()))) } finally { ownership.exit() } await this.flushTextWrite(url as AutomergeUrl) }
/** Attach first, then synchronously emit the snapshot from that same handle. */ async subscribeListFolds(url: DocumentId, listener: (folds: ListFoldState) => void): Promise<() => void> { if (!url) return () => {} const handle = await this.findFileHandle(url as AutomergeUrl) if (!handle) return () => {} let previous: ListFoldState | undefined const emit = (doc: Readonly<Record<string, unknown>>) => { const next = this.readListFoldsFromDoc(doc) const prior = previous if (prior && Object.keys(prior).length === Object.keys(next).length && Object.keys(prior).every(nodeId => prior[nodeId]?.collapsed === next[nodeId]?.collapsed)) return previous = next try { listener(next) } catch (error) { console.warn('[DocumentManager] fold subscriber failed', error) } } const onChange = (event: { doc?: unknown }) => { if (event.doc && typeof event.doc === 'object') emit(event.doc as Readonly<Record<string, unknown>>) } handle.on('change', onChange) emit(handle.doc()) return () => handle.off('change', onChange) }
/** * Read a folder document by URL. * Returns undefined if the document is not a valid Pushwork directory. */ async getFolder(url: AutomergeUrl): Promise<PushworkDirectoryDocument | undefined> { const handle = await this.repo.find<PushworkDirectoryDocument>(url) const doc = handle.doc() return isPushworkDirectory(doc) ? doc : undefined }
/** * Resolve the DocHandle for a folder URL. Used by FileIndex's live * folder subscription: FileIndex needs to bind to the handle's * 'change' event so peer-driven mutations propagate into the tree. * * Returns null if the repo can't find the URL (network-only doc that * has not yet been announced, deleted doc, etc.). Callers should * treat null as "no subscription possible right now" — typically * resulting in a static-after-init fallback for that folder. */ async getFolderHandle(url: AutomergeUrl, options?: import('../storage/document-runtime-contract').DocumentLoadOptions): Promise<DocHandle<PushworkDirectoryDocument> | null> { try { return await (options && this.repo.load ? this.repo.load<PushworkDirectoryDocument>(url, options) : this.repo.find<PushworkDirectoryDocument>(url)) } catch (err) { console.warn(`getFolderHandle(${url}) failed:`, err) return null } }
/** Deletion may unlink a confirmed tombstone, but never an unavailable body. */ async getFolderDeletionHandle(url: AutomergeUrl): Promise<DocHandle<PushworkDirectoryDocument> | null> { try { const handle = await this.repo.find<PushworkDirectoryDocument>(url) if (handle.isDeleted()) return null if (!isPushworkDirectory(handle.doc())) throw new Error(`Not a folder: ${url}`) return handle } catch (error) { if (error && typeof error === 'object' && 'code' in error && error.code === 'DOCUMENT_DELETED') return null throw error } }
/** Derive application heads from the authoritative document in a change event. */ headsFromEventDoc(doc: unknown): Heads { return normalizeHeads(getHeads(doc as Doc<PushworkFileDocument>)) }
/** * Read the binary content of a file. * Returns undefined if the URL is not a file or content is text (string). */ async readBinaryContent(url: AutomergeUrl): Promise<Uint8Array | undefined> { const file = await this.getFile(url) if (!file) return undefined return file.content instanceof Uint8Array ? file.content : undefined }
/** * Update the text content of a file using Automerge rich-text spans. * * When supplied, the binding-local ownership scope brackets the exact * synchronous `handle.change/changeAt` call. Other bindings remain * eligible to observe the same Automerge event. * * Returns a named result containing the post-write heads. `url` is the * current API's compatibility name for the stable document identity. * * Throws if the URL does not point to a valid Pushwork file document. */ async updateContent(url: AutomergeUrl, newContent: string, ownership?: WriteOwnership, hint?: FixedWidthTextEditHint): Promise<TextWriteResult> { return this.runPublicWrite(url, async () => { const handle = await this.repo.find<PushworkFileDocument>(url) this.assertTextDocument(url, handle.doc()) await this.waitForPublicWriteFlush(url) await this.prepareTextFirstDocument(url) const before = this.getTextFirstDocumentState(url) if (hint) assertValidFixedWidthHint(url, before.content, newContent, hint) const normalized = this.normalizePublicFrontmatter(url, before.content, newContent) if (normalized !== newContent) { const whole = hint?.beforeFrom === 0 && hint.beforeTo === before.content.length && hint.afterFrom === 0 && hint.afterTo === newContent.length newContent = normalized hint = whole ? { beforeFrom: 0, beforeTo: before.content.length, afterFrom: 0, afterTo: newContent.length } : undefined } const writeOwnership = ownership ?? semanticWriteOwnership(handle) writeOwnership.enter() let result: TextApplyResult // The public whole-content API has no CM transaction geometry. Treat it as // an explicit destructive replacement rather than guessing which old // subject survived. Native record creation is performed once for this // replacement; ordinary output remains scalar text. let mutate: ((draft: A.Doc<FrameworkDoc>) => void) | undefined try { const replacement = hint !== undefined && hint.beforeFrom === 0 && hint.beforeTo === before.content.length && hint.afterFrom === 0 && hint.afterTo === newContent.length if (replacement) { const blocks = inspectRichText(handle.doc() as unknown as A.Doc<FrameworkDoc>, { path: ['content'] }).blocks() const oldIds = new Set(blocks.map(block => block.attrs['reams.nodeId']).filter((id): id is string => typeof id === 'string')) mutate = draft => replaceExplicitWholeDocument(draft, url, before.heads, oldIds, this.textFirstRecord(url).recognizedTypeNames) } result = mutate ? this.textFirstEnvelope(url, before.heads, before.content, newContent, hint, mutate) : this.applyTextFirstWholeValue(url, before.heads, before.content, newContent, hint) } finally { writeOwnership.exit() } this.textFirstRecord(url).writer.prepare(result.liveHeads) const afterContent = this.textFirstRecord(url).writer.getText().toString() await this.flushTextFirstDocument(url) return this.textFirstWriteResult(url, before.heads, before.content, afterContent, result) }) }
async updateContentCurrent(url: AutomergeUrl, transform: (current: string) => string | null, ownership?: WriteOwnership): Promise<TextWriteResult> { return this.runPublicWrite(url, async () => { void ownership const handle = await this.repo.find<PushworkFileDocument>(url) this.assertTextDocument(url, handle.doc()) await this.waitForPublicWriteFlush(url) await this.prepareTextFirstDocument(url) const before = this.getTextFirstDocumentState(url) const next = transform(before.content) const result = this.applyTextFirstWholeValue(url, before.heads, before.content, next ?? before.content) const afterContent = this.textFirstRecord(url).writer.getText().toString() await this.flushTextFirstDocument(url) return this.textFirstWriteResult(url, before.heads, before.content, afterContent, result) }) }
/** Apply one current-state visible-text replacement without rebuilding rich Markdown structure. */ async updateTextRangeCurrent(url: AutomergeUrl, transform: (current: string) => { readonly from: number; readonly to: number; readonly insert: string } | null, ownership?: WriteOwnership, authorize?: () => Promise<boolean>): Promise<TextWriteResult> { return this.runPublicWrite(url, async () => { void ownership const handle = await this.repo.find<PushworkFileDocument>(url) if (authorize && !await authorize()) throw new Error('Visible text replacement authorization expired') this.assertTextDocument(url, handle.doc()) { await this.waitForPublicWriteFlush(url) await this.prepareTextFirstDocument(url) const before = this.getTextFirstDocumentState(url) const edit = transform(before.content) if (edit) assertTextRange(edit, before.content.length) const after = edit ? before.content.slice(0, edit.from) + edit.insert + before.content.slice(edit.to) : before.content const result = edit ? this.applyTextFirstWholeValue(url, before.heads, before.content, after, after === before.content ? undefined : { beforeFrom: edit.from, beforeTo: edit.to, afterFrom: edit.from, afterTo: edit.from + edit.insert.length, }) : this.textFirstEnvelope(url, before.heads, before.content, before.content) const afterContent = this.textFirstRecord(url).writer.getText().toString() await this.flushTextFirstDocument(url) return this.textFirstWriteResult(url, before.heads, before.content, afterContent, result) } }) }
async updateContentCurrentWithNodeIndex(url: AutomergeUrl, transform: (current: string, nodeIndex: NodeIndexResult) => string | null, ownership?: WriteOwnership, authorize?: () => Promise<boolean>, mutateDraft?: (draft: PushworkFileDocument) => void): Promise<TextWriteResult> { return this.runPublicWrite(url, async () => { void ownership const handle = await this.repo.find<PushworkFileDocument>(url) if (authorize && !await authorize()) { const content = visibleTextFromDoc(handle.doc() as unknown as A.Doc<FrameworkDoc>) const heads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) return { documentId: url, beforeHeads: heads, heads, content, beforeContent: content, afterContent: content } } this.assertTextDocument(url, handle.doc()) await this.waitForPublicWriteFlush(url) await this.prepareTextFirstDocument(url) const before = this.getTextFirstDocumentState(url) const next = transform(before.content, before.nodeIndex) if (mutateDraft) throw new Error('Draft callbacks are not supported by the text-first writer') const result = this.applyTextFirstWholeValue(url, before.heads, before.content, next ?? before.content) await this.flushTextFirstDocument(url) return this.textFirstWriteResult(url, before.heads, before.content, this.textFirstRecord(url).writer.getText().toString(), result) }) }
/** @internal Native block lookup at a rich position; throws when the position * is not a native block boundary. Kept as a seam so the caller's fallback * handling stays observable. */ private nativeBlockAt(doc: A.Doc<PushworkFileDocument>, richIndex: number): ReturnType<typeof A.block> { return A.block(doc, ['content'], richIndex) }
/** Guarded task-checkbox mutation for non-text-first documents. */ async updateTaskCheckboxCurrent( url: AutomergeUrl, request: { readonly nodeId: string; readonly typeName: string; readonly checked: boolean; readonly fieldId?: string; readonly factValue?: BlockScalar }, fallback: (current: string, nodeIndex: NodeIndexResult) => string | null, ownership?: WriteOwnership, authorize?: () => Promise<boolean>, requiredBaseHeads?: Heads, ): Promise<TaskCheckboxWriteResult> { await this.waitForPendingFlush(url) const handle = await this.repo.find<PushworkFileDocument>(url) await this.waitForPendingFlush(url) if (authorize && !await authorize()) { const content = visibleTextFromDoc(handle.doc() as unknown as A.Doc<FrameworkDoc>) const heads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) return { documentId: url, beforeHeads: heads, heads, content, beforeContent: content, afterContent: content, path: 'fallback', validated: false, changed: false, conflicted: false, checkboxBound: false, incrementalSafe: false } } await this.waitForPendingFlush(url) const beforeHeads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>))
if (this.isTextFirstEligible(handle.doc())) { await this.prepareTextFirstDocument(url) const record = this.textFirstRecord(url) this.assertTextFirstAvailable(url, record.handle) const liveHeads = normalizeHeads(getHeads(record.handle.doc() as Doc<PushworkFileDocument>)) const beforeContent = record.writer.getText().toString() const unchanged = (validated: boolean): TaskCheckboxWriteResult => ({ documentId: url, beforeHeads, heads: beforeHeads, content: beforeContent, beforeContent, afterContent: beforeContent, path: 'targeted', validated, changed: false, conflicted: false, checkboxBound: false, incrementalSafe: true, }) if (!headsEqual(beforeHeads, liveHeads) || (requiredBaseHeads && !headsEqual(beforeHeads, requiredBaseHeads))) return unchanged(false)
record.writer.prepare(beforeHeads) const nodeIndex = this.getTextFirstDocumentState(url).nodeIndex if (nodeIndex.status !== 'available' || !nodeIndex.index.getNode(request.nodeId)) return unchanged(false) const node = nodeIndex.index.getNode(request.nodeId) const geometry = node ? locateTaskCheckbox(beforeContent, node) : undefined const target = request.checked ? '[x]' : '[ ]' const ownLineEnd = beforeContent.indexOf('\n', node!.from) const ownLine = beforeContent.slice(node!.from, ownLineEnd < 0 ? node!.to : ownLineEnd) const ownsType = new RegExp(`(?:^|\\s)#${request.typeName}(?=$|\\s)`).test(ownLine) if (!ownsType) return unchanged(false) const safeCheckbox = geometry !== undefined && !this.markdownSnapshot(record.handle, beforeHeads)?.taskCheckboxes.get(request.nodeId)?.hasTouchingMarks const proposed = geometry && !safeCheckbox ? fallback(beforeContent, nodeIndex) : undefined const next = proposed ?? (safeCheckbox ? beforeContent.slice(0, geometry!.markerFrom) + target + beforeContent.slice(geometry!.markerTo) : geometry ? undefined : beforeContent) if (next === undefined || (geometry ? next !== beforeContent.slice(0, geometry.markerFrom) + target + beforeContent.slice(geometry.markerTo) : next !== beforeContent)) return unchanged(false)
const factKey = request.fieldId ? encodeBlockFactKey(request.nodeId, request.typeName, request.fieldId) : undefined if (!geometry) { if (!factKey) return unchanged(true) let factChanged = false const writeOwnership = ownership ?? semanticWriteOwnership(handle) writeOwnership.enter() try { handle.changeAt(encodeHeads([...beforeHeads]), draft => { factChanged = request.factValue === undefined ? clearBlockFactDraft(draft as unknown as Record<string, unknown>, factKey) : writeBlockFactDraft(draft as unknown as Record<string, unknown>, factKey, request.factValue) }, { message: 'reams:text-first:metadata' }) } finally { writeOwnership.exit() } if (!factChanged) return unchanged(true) const heads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) rememberSemanticHeads(handle, heads) await this.flushTextWrite(url) return { documentId: url, beforeHeads, heads, content: beforeContent, beforeContent, afterContent: beforeContent, path: 'targeted', validated: true, changed: true, conflicted: false, checkboxBound: false, incrementalSafe: true } } const exactFacts = record.handle.doc() as unknown as Readonly<Record<string, unknown>> const factCandidates = factKey ? readBlockFactCandidates(exactFacts, factKey) : [] const rawConflicts = factKey ? A.getConflicts(exactFacts as A.Doc<Record<string, unknown>>, factKey as never) as Record<string, unknown> | undefined : undefined const malformed = factKey !== undefined && ((Object.prototype.hasOwnProperty.call(exactFacts, factKey) && factCandidates.length === 0) || Object.values(rawConflicts ?? {}).some(value => !isBlockScalar(value))) if (malformed || factCandidates.length > 1) return { ...unchanged(true), conflicted: true } const factMatches = !factKey || (request.factValue === undefined ? factCandidates.length === 0 : factCandidates.length === 1 && factCandidates[0] === request.factValue) const markerCurrent = beforeContent.slice(geometry.markerFrom, geometry.markerTo) if (markerCurrent === target && factMatches) return { ...unchanged(true), checkboxBound: true }
const beforeFact = factKey ? factCandidates[0] : undefined let textChanged = false let factChanged = false let contributionHeads: Heads = [] const writeOwnership = ownership ?? semanticWriteOwnership(record.handle) writeOwnership.enter() let succeeded = false try { const applied = record.writer.applyMutation(beforeHeads, markerCurrent === target ? [] : [{ from: geometry.markerFrom, to: geometry.markerTo, insert: target }], draft => { if (markerCurrent !== target) textChanged = true if (factKey) factChanged = request.factValue === undefined ? clearBlockFactDraft(draft as unknown as Record<string, unknown>, factKey) : writeBlockFactDraft(draft as unknown as Record<string, unknown>, factKey, request.factValue) }, { message: 'reams:text-first:record-checkbox' }) contributionHeads = normalizeHeads(applied.contributionHeads) succeeded = true if (contributionHeads.length > 0) rememberSemanticHeads(record.handle, contributionHeads) } finally { if (succeeded) rememberSemanticHeads(record.handle, normalizeHeads(getHeads(record.handle.doc() as Doc<PushworkFileDocument>))) writeOwnership.exit() } if (!textChanged && !factChanged) return unchanged(true) const postObserverHeads = normalizeHeads(getHeads(record.handle.doc() as Doc<PushworkFileDocument>)) const isolated = headsEqual(postObserverHeads, contributionHeads) const heads = contributionHeads.length > 0 ? postObserverHeads : beforeHeads const afterContent = isolated ? record.writer.getText().toString() : visibleTextFromDoc(record.handle.doc() as unknown as A.Doc<FrameworkDoc>) const origin = factChanged && factKey && contributionHeads.length > 0 ? captureFactOrigin(record.handle.doc() as unknown as A.Doc<unknown>, beforeHeads, contributionHeads, factKey) : undefined await record.writer.flush() this.checkboxWork.targetedChanged += 1 return { documentId: url, beforeHeads, heads, content: afterContent, beforeContent, afterContent, path: 'targeted', validated: true, changed: true, conflicted: false, checkboxBound: true, incrementalSafe: isolated, ...(isolated && textChanged ? { structuralChange: { kind: 'task-checkbox', nodeId: request.nodeId, checked: request.checked, isolated: true } } : {}), ...(origin && factKey ? { factChange: { key: factKey, before: beforeFact === undefined ? undefined : snapshotBlockFact(beforeFact), after: request.factValue === undefined ? undefined : snapshotBlockFact(request.factValue), origin } } : {}), } }
const cached = this.markdownSnapshot(handle, beforeHeads) const shape = cached?.shape ?? this.validateTextContentShape(url, handle.doc()) const target = request.checked ? '[x]' : '[ ]' const factKey = request.fieldId ? encodeBlockFactKey(request.nodeId, request.typeName, request.fieldId) : undefined if (factKey) { const exactFacts = handle.doc() as unknown as Readonly<Record<string, unknown>> const rawConflicts = A.getConflicts(exactFacts as A.Doc<Record<string, unknown>>, factKey as never) as Record<string, unknown> | undefined const malformed = Object.prototype.hasOwnProperty.call(exactFacts, factKey) && !isBlockScalar(exactFacts[factKey]) || Object.values(rawConflicts ?? {}).some(value => !isBlockScalar(value)) if (malformed || readBlockFactCandidates(exactFacts, factKey).length > 1) { const content = cached?.content ?? visibleTextFromDoc(handle.doc() as unknown as A.Doc<FrameworkDoc>) return { documentId: url, beforeHeads, heads: beforeHeads, content, beforeContent: content, afterContent: content, path: 'targeted', validated: true, changed: false, conflicted: true, checkboxBound: cached?.taskCheckboxes.has(request.nodeId) === true, incrementalSafe: true } } } const runFallback = async (): Promise<TaskCheckboxWriteResult> => { this.checkboxWork.fallbackInvocations += 1 let validated = false let factChanged = false const result = await this.updateContentCurrentWithNodeIndex(url, (current, currentNodeIndex) => { const next = fallback(current, currentNodeIndex) if (next !== null) validated = true return next }, ownership, authorize, factKey ? draft => { factChanged = request.factValue === undefined ? clearBlockFactDraft(draft as unknown as Record<string, unknown>, factKey) : writeBlockFactDraft(draft as unknown as Record<string, unknown>, factKey, request.factValue) } : undefined) return { ...result, path: 'fallback', validated, changed: factChanged || result.beforeContent !== result.afterContent, conflicted: false, checkboxBound: cached?.taskCheckboxes.has(request.nodeId) === true, incrementalSafe: false } } if (requiredBaseHeads && !headsEqual(beforeHeads, requiredBaseHeads)) return runFallback() const checkbox = cached?.taskCheckboxes.get(request.nodeId) const node = cached?.nodeIndex.status === 'available' ? cached.nodeIndex.index.getNode(request.nodeId) : undefined let marker: ReturnType<typeof A.block> | null = null try { if (checkbox) marker = this.nativeBlockAt(handle.doc() as A.Doc<PushworkFileDocument>, checkbox.nativeRichIndex) } catch { marker = null } const markerAttrs = marker?.attrs const markerCurrent = checkbox ? cached?.content.slice(checkbox.checkbox.markerFrom, checkbox.checkbox.markerTo) : undefined const recordAuthorized = shape === 'rich' && this.contentTypeForFile(handle.doc()) === 'public.markdown' && cached !== undefined && cached.nodeIndex.status === 'available' && node !== undefined && cached.authorizedTypesByNode.get(request.nodeId)?.has(request.typeName) === true const fastAuthorized = recordAuthorized && checkbox !== undefined && !checkbox.hasTouchingMarks && node.kind === checkbox.nodeKind && checkbox.authorizedTypeNames.has(request.typeName) && marker?.type === checkbox.nativeType && typeof markerAttrs === 'object' && markerAttrs !== null && !Array.isArray(markerAttrs) && (markerAttrs as Record<string, unknown>)['reams.nodeId'] === request.nodeId && (markerCurrent === '[ ]' || markerCurrent === '[x]' || markerCurrent === '[X]')
if (recordAuthorized && !checkbox && factKey && cached) { const writeOwnership = ownership ?? semanticWriteOwnership(handle) let factChanged = false let operationHeads: Heads | undefined writeOwnership.enter() try { const encoded = handle.changeAt(encodeHeads([...beforeHeads]), draft => { factChanged = request.factValue === undefined ? clearBlockFactDraft(draft as unknown as Record<string, unknown>, factKey) : writeBlockFactDraft(draft as unknown as Record<string, unknown>, factKey, request.factValue) }, { message: 'reams:text-first:metadata' }) if (factChanged && encoded) { operationHeads = normalizeHeads(decodeHeads(encoded)) rememberSemanticHeads(handle, operationHeads) } } finally { writeOwnership.exit() } const heads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) if (factChanged) rememberSemanticHeads(handle, heads) const isolated = operationHeads !== undefined && headsEqual(operationHeads, heads) if (factChanged) await this.flushTextWrite(url) return { documentId: url, beforeHeads, heads, content: cached.content, beforeContent: cached.content, afterContent: cached.content, path: 'targeted', validated: true, changed: factChanged, conflicted: false, checkboxBound: false, incrementalSafe: !factChanged || isolated } }
if (!fastAuthorized || !checkbox || !cached) { return runFallback() }
const factCandidates = factKey ? readBlockFactCandidates(handle.doc() as unknown as Readonly<Record<string, unknown>>, factKey) : [] const factMatches = !factKey || (request.factValue === undefined ? factCandidates.length === 0 : factCandidates.length === 1 && factCandidates[0] === request.factValue) if (markerCurrent === target && factMatches) { this.checkboxWork.targetedUnchanged += 1 return { documentId: url, beforeHeads, heads: beforeHeads, content: cached.content, beforeContent: cached.content, afterContent: cached.content, path: 'targeted', validated: true, changed: false, conflicted: false, checkboxBound: true, incrementalSafe: true, } }
let cursor: A.Cursor | undefined if (markerCurrent !== target) { cursor = A.getCursor(handle.doc() as A.Doc<PushworkFileDocument>, ['content'], checkbox.checkboxRichFrom) if (A.getCursorPosition(handle.doc() as A.Doc<PushworkFileDocument>, ['content'], cursor) !== checkbox.checkboxRichFrom) return runFallback() } const writeOwnership = ownership ?? semanticWriteOwnership(handle) let textChanged = false let factChanged = false let operationHeads: Heads | undefined let postWriteHeads: Heads | undefined if ((globalThis as { __reamsPerfDiagnostics__?: unknown }).__reamsPerfDiagnostics__) globalThis.performance?.mark('reams:record-edit-prepared') writeOwnership.enter() try { const encoded = handle.changeAt(encodeHeads([...beforeHeads]), draft => { if (cursor) { A.splice(draft as A.Doc<unknown>, ['content'], cursor, 3, target) textChanged = true } if (factKey) factChanged = request.factValue === undefined ? clearBlockFactDraft(draft as unknown as Record<string, unknown>, factKey) : writeBlockFactDraft(draft as unknown as Record<string, unknown>, factKey, request.factValue) }, { message: 'reams:text-first:metadata' }) if ((textChanged || factChanged) && encoded) { operationHeads = normalizeHeads(decodeHeads(encoded)) postWriteHeads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) rememberSemanticHeads(handle, operationHeads) rememberSemanticHeads(handle, postWriteHeads) } } finally { writeOwnership.exit() } if ((globalThis as { __reamsPerfDiagnostics__?: unknown }).__reamsPerfDiagnostics__) globalThis.performance?.mark('reams:record-edit-changed') if (!textChanged && !factChanged) return runFallback() if (!operationHeads || !postWriteHeads) throw new Error('Targeted checkbox mutation did not return operation heads')
const heads = postWriteHeads const isolated = headsEqual(heads, operationHeads) const expectedContent = textChanged ? cached.content.slice(0, checkbox.checkbox.markerFrom) + target + cached.content.slice(checkbox.checkbox.markerTo) : cached.content const afterContent = isolated ? expectedContent : visibleTextFromDoc(handle.doc() as unknown as A.Doc<FrameworkDoc>) const factOrigin = factChanged && factKey ? captureFactOrigin(handle.doc() as unknown as A.Doc<unknown>, beforeHeads, operationHeads, factKey) : undefined this.checkboxWork.targetedChanged += 1 globalThis.performance?.mark('reams:record-checkbox-canonical') const diagnostics = globalThis as { __reamsPerfDiagnostics__?: unknown; __reamsPerfSampleId__?: unknown } const durableMark = diagnostics.__reamsPerfDiagnostics__ && typeof diagnostics.__reamsPerfSampleId__ === 'string' ? `reams:record-checkbox-durable:${diagnostics.__reamsPerfSampleId__}` : undefined await this.flushTextWrite(url) if (durableMark) globalThis.performance?.mark(durableMark) return { documentId: url, beforeHeads, heads, content: afterContent, beforeContent: cached.content, afterContent, path: 'targeted', validated: true, changed: true, conflicted: false, checkboxBound: true, incrementalSafe: isolated, ...(isolated && textChanged ? { structuralChange: { kind: 'task-checkbox' as const, nodeId: request.nodeId, checked: request.checked, isolated: true as const } } : {}), ...(factOrigin && factKey ? { factChange: { key: factKey, before: factCandidates[0] === undefined ? undefined : snapshotBlockFact(factCandidates[0]), after: request.factValue === undefined ? undefined : snapshotBlockFact(request.factValue), origin: factOrigin } } : {}), } }
/** Guarded native title mutation for non-text-first documents. */ async updateBlockTitleCurrent( url: AutomergeUrl, request: { readonly nodeId: string; readonly typeName: string; readonly title: string; readonly typeMarkers: readonly string[] }, fallback: (current: string, nodeIndex: NodeIndexResult) => string | null, ownership?: WriteOwnership, authorize?: () => Promise<boolean>, requiredBaseHeads?: Heads, ): Promise<BlockTitleWriteResult> { await this.waitForPendingFlush(url) const handle = await this.repo.find<PushworkFileDocument>(url) await this.waitForPendingFlush(url) if (authorize && !await authorize()) { const content = visibleTextFromDoc(handle.doc() as unknown as A.Doc<FrameworkDoc>) const heads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) return { documentId: url, beforeHeads: heads, heads, content, beforeContent: content, afterContent: content, path: 'fallback', validated: false, changed: false, incrementalSafe: false } } await this.waitForPendingFlush(url) const beforeHeads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) const cached = this.markdownSnapshot(handle, beforeHeads) const shape = cached?.shape ?? this.validateTextContentShape(url, handle.doc()) const runFallback = async (): Promise<BlockTitleWriteResult> => { if (this.isTextFirstEligible(handle.doc())) { let targeted = false const currentRange = await this.updateTextRangeCurrent(url, current => { const currentIndex = this.getTextFirstDocumentState(url).nodeIndex if (currentIndex.status !== 'available') return null const currentNode = currentIndex.index.getNode(request.nodeId) if (!currentNode) return null const currentGeometry = locateBlockTitle(current, currentNode, [`#${request.typeName}`]) if (!currentGeometry) return null const currentSnapshot = this.markdownSnapshot(handle, normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>))) if (!currentSnapshot || currentSnapshot.shape !== 'rich' || current.slice(currentGeometry.titleFrom, currentGeometry.titleTo).length !== request.title.length || currentSnapshot.richCoordinates.marks.some(mark => mark.end >= currentGeometry.titleFrom && mark.start <= currentGeometry.titleTo)) return null targeted = true return { from: currentGeometry.titleFrom, to: currentGeometry.titleTo, insert: request.title } }, ownership, authorize) if (targeted) { return { ...currentRange, path: 'targeted', validated: true, changed: currentRange.beforeContent !== currentRange.afterContent, incrementalSafe: true } } } if (!cached) { let targeted = false const currentRange = await this.updateTextRangeCurrent(url, current => { const currentIndex = this.isTextFirstEligible(handle.doc()) ? this.getTextFirstDocumentState(url).nodeIndex : safeBuildNodeIndex(handle.doc() as unknown as A.Doc<FrameworkDoc>) if (currentIndex.status !== 'available') return null const currentNode = currentIndex.index.getNode(request.nodeId) if (!currentNode) return null let currentGeometry try { currentGeometry = locateBlockTitle(current, currentNode, [`#${request.typeName}`]) } catch { return null } if (!currentGeometry) return null targeted = true return { from: currentGeometry.titleFrom, to: currentGeometry.titleTo, insert: request.title } }, ownership, authorize) if (targeted) { return { ...currentRange, path: 'targeted', validated: true, changed: currentRange.beforeContent !== currentRange.afterContent, incrementalSafe: true } } } let validated = false const result = await this.updateContentCurrentWithNodeIndex(url, (current, currentNodeIndex) => { const next = fallback(current, currentNodeIndex) if (next !== null) validated = true return next }, ownership, authorize) return { ...result, path: 'fallback', validated, changed: result.beforeContent !== result.afterContent, incrementalSafe: false } } // The text-first writer owns the live Markdown cache. Applying a title // through the legacy native-block path changes the handle without moving // that cache, leaving the editor and typed projection on the old heads. if (this.isTextFirstEligible(handle.doc()) || requiredBaseHeads && !headsEqual(beforeHeads, requiredBaseHeads)) return runFallback() const node = cached?.nodeIndex.status === 'available' ? cached.nodeIndex.index.getNode(request.nodeId) : undefined const geometry = cached && node ? locateBlockTitle(cached.content, node, request.typeMarkers) : undefined if (shape !== 'rich' || this.contentTypeForFile(handle.doc()) !== 'public.markdown' || !cached || !node || !geometry || !cached.authorizedTypesByNode.get(request.nodeId)?.has(request.typeName)) return runFallback() const currentTitle = cached.content.slice(geometry.titleFrom, geometry.titleTo) if (currentTitle === request.title) { return { documentId: url, beforeHeads, heads: beforeHeads, content: cached.content, beforeContent: cached.content, afterContent: cached.content, path: 'targeted', validated: true, changed: false, incrementalSafe: true } } if (currentTitle.length !== request.title.length || cached.richCoordinates.marks.some(mark => mark.end >= geometry.titleFrom && mark.start <= geometry.titleTo)) return runFallback() let richRange: { readonly start: number; readonly end: number } let cursor: A.Cursor try { richRange = cached.richCoordinates.toRichRange({ start: geometry.titleFrom, end: geometry.titleTo }) if (richRange.end - richRange.start !== currentTitle.length) return runFallback() cursor = A.getCursor(handle.doc() as A.Doc<PushworkFileDocument>, ['content'], richRange.start) if (A.getCursorPosition(handle.doc() as A.Doc<PushworkFileDocument>, ['content'], cursor) !== richRange.start) return runFallback() } catch { return runFallback() } const writeOwnership = ownership ?? semanticWriteOwnership(handle) let mutated = false let operationHeads: Heads | undefined let postWriteHeads: Heads | undefined if ((globalThis as { __reamsPerfDiagnostics__?: unknown }).__reamsPerfDiagnostics__) globalThis.performance?.mark('reams:record-edit-prepared') writeOwnership.enter() try { const encoded = handle.changeAt(encodeHeads([...beforeHeads]), draft => { A.splice(draft as A.Doc<unknown>, ['content'], cursor, currentTitle.length, request.title) mutated = true }, { message: 'reams:text-first:metadata' }) if (mutated && encoded) { operationHeads = normalizeHeads(decodeHeads(encoded)) postWriteHeads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) rememberSemanticHeads(handle, operationHeads) rememberSemanticHeads(handle, postWriteHeads) } } finally { writeOwnership.exit() } if ((globalThis as { __reamsPerfDiagnostics__?: unknown }).__reamsPerfDiagnostics__) globalThis.performance?.mark('reams:record-edit-changed') if (!mutated) return runFallback() if (!operationHeads || !postWriteHeads) throw new Error('Targeted title mutation did not return operation heads') const heads = postWriteHeads const isolated = headsEqual(heads, operationHeads) const expectedContent = cached.content.slice(0, geometry.titleFrom) + request.title + cached.content.slice(geometry.titleTo) const afterContent = isolated ? expectedContent : visibleTextFromDoc(handle.doc() as unknown as A.Doc<FrameworkDoc>) await this.flushTextWrite(url) return { documentId: url, beforeHeads, heads, content: afterContent, beforeContent: cached.content, afterContent, path: 'targeted', validated: true, changed: true, incrementalSafe: isolated, ...(isolated ? { structuralChange: { kind: 'block-title' as const, nodeId: request.nodeId, title: request.title, isolated: true as const } } : {}) } }
async updateContentAt(url: AutomergeUrl, baseHeads: Heads, newContent: string, ownership?: WriteOwnership, hint?: FixedWidthTextEditHint): Promise<TextWriteResult> { return this.runPublicWrite(url, async () => { const handle = await this.repo.find<PushworkFileDocument>(url) this.assertTextDocument(url, handle.doc()) await this.waitForPublicWriteFlush(url) await this.prepareTextFirstDocument(url) const record = this.textFirstRecord(url) const normalizedBase = normalizeHeads(baseHeads) const liveHeads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) if (!headsEqual(normalizedBase, liveHeads)) { // Historical callers intentionally write a captured branch. Keep that // branch independent from the active editor writer's contribution guard. const externalWriter = new TextFirstWriter(handle as unknown as ConstructorParameters<typeof TextFirstWriter>[0], () => this.flushTextWrite(url)) externalWriter.prepare(normalizedBase) const before = externalWriter.getText().toString() if (record.recognizedTypeNames.length > 0) { const historical = A.view(handle.doc() as Doc<PushworkFileDocument>, [...normalizedBase]) as unknown as A.Doc<FrameworkDoc> const historicalStructures = collectMarkdownStructures(before) const historicalRecords = recordTypeNamesByStructure(before, historicalStructures, record.recognizedTypeNames) const historicalFacts = Object.keys(historical as unknown as Record<string, unknown>).some(key => Boolean(decodeBlockFactKey(key))) const nextRecords = recordTypeNamesByStructure(newContent, collectMarkdownStructures(newContent), record.recognizedTypeNames) if (historicalRecords.size > 0 || historicalFacts || nextRecords.size > 0) { throw new Error(`Cannot apply stale record-affecting text write for ${url}; retry against the current frontier`) } } if (hint) assertValidFixedWidthHint(url, before, newContent, hint) if (this.normalizePublicFrontmatter(url, before, newContent) !== newContent) { throw new Error(`Cannot synchronize shared frontmatter from stale heads for ${url}; retry against the current frontier`) } if (hint && hint.beforeFrom === 0 && hint.beforeTo === before.length && hint.afterFrom === 0 && hint.afterTo === newContent.length) { throw new Error(`Cannot apply explicit whole-document replacement from stale heads for ${url}; retry against the current frontier`) } externalWriter.applyChanges(normalizedBase, hint ? [{ from: hint.beforeFrom, to: hint.beforeTo, insert: newContent.slice(hint.afterFrom, hint.afterTo), }] : diffTextChanges(before, newContent)) await externalWriter.flush() const afterContent = visibleTextFromDoc(handle.doc() as unknown as A.Doc<FrameworkDoc>) return { documentId: url, beforeHeads: normalizedBase, heads: normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)), content: afterContent, beforeContent: before, afterContent } } record.writer.prepare(normalizedBase) const before = record.writer.getText().toString() if (hint) assertValidFixedWidthHint(url, before, newContent, hint) const normalizedContent = this.normalizePublicFrontmatter(url, before, newContent) if (normalizedContent !== newContent) { const whole = hint?.beforeFrom === 0 && hint.beforeTo === before.length && hint.afterFrom === 0 && hint.afterTo === newContent.length newContent = normalizedContent hint = whole ? { beforeFrom: 0, beforeTo: before.length, afterFrom: 0, afterTo: newContent.length } : undefined } const beforeIndex = safeBuildNodeIndex(handle.doc() as unknown as A.Doc<FrameworkDoc>) const explicitWholeReplacement = before.length > 0 && hint !== undefined && hint.beforeFrom === 0 && hint.beforeTo === before.length && hint.afterFrom === 0 && hint.afterTo === newContent.length let mutate: ((draft: A.Doc<FrameworkDoc>) => void) | undefined if (explicitWholeReplacement) { try { const blocks = inspectRichText(handle.doc() as unknown as A.Doc<FrameworkDoc>, { path: ['content'] }).blocks() const oldIds = new Set(blocks.map(block => block.attrs['reams.nodeId']).filter((id): id is string => typeof id === 'string')) mutate = draft => replaceExplicitWholeDocument(draft, url, normalizedBase, oldIds, record.recognizedTypeNames) } catch { /* The text write remains available; initialization will report unsupported. */ } } const writeOwnership = ownership ?? semanticWriteOwnership(handle) writeOwnership.enter() let result: TextApplyResult try { result = mutate ? this.textFirstEnvelope(url, normalizedBase, before, newContent, hint, mutate) : this.applyTextFirstWholeValue(url, normalizedBase, before, newContent, hint) } finally { writeOwnership.exit() } await this.flushTextFirstDocument(url) const after = this.getTextFirstDocumentState(url).content const baseResult = this.textFirstWriteResult(url, normalizedBase, before, after, result) if (beforeIndex.status === 'available' && before !== after) { const changes = diffTextChanges(before, newContent) if (changes.length === 1) { const change = changes[0]! const node = beforeIndex.index.nodes.find(candidate => candidate.from <= change.from && candidate.to >= change.to) if (node) { const checkbox = locateTaskCheckbox(before, node) const beforeMarker = checkbox ? before.slice(checkbox.markerFrom, checkbox.markerTo) : '' const delta = change.insert.length - (change.to - change.from) const updated = before.slice(0, change.from) + change.insert + before.slice(change.to) const afterMarker = checkbox && change.from >= checkbox.markerFrom && change.to <= checkbox.markerTo ? updated.slice(checkbox.markerFrom, checkbox.markerTo) : '' if (checkbox && ['[ ]', '[x]', '[X]'].includes(beforeMarker) && ['[ ]', '[x]', '[X]'].includes(afterMarker)) { return { ...baseResult, structuralChange: { kind: 'task-checkbox' as const, nodeId: node.id, checked: afterMarker.toLowerCase() === '[x]', isolated: true as const } } } const nodeMarkers = [...before.slice(node.from, node.to).matchAll(/#[A-Za-z0-9_/-]+/g)] const markerFrom = nodeMarkers.length === 1 ? node.from + nodeMarkers[0]!.index! : -1 const linePrefix = before.slice(node.from, markerFrom < 0 ? node.to : markerFrom) const titlePrefix = /^(?:[ \t]*(?:[-+*]|\d+[.)])[ \t]+)+(?:\[[ xX]\][ \t]+)?/.exec(linePrefix) const titleTo = markerFrom >= 0 ? markerFrom - 1 : -1 const title = markerFrom >= 0 && titlePrefix ? { titleFrom: node.from + titlePrefix[0].length, titleTo } : undefined if (title && change.from >= title.titleFrom && change.to <= title.titleTo) { const titleTo = title.titleTo + delta return { ...baseResult, structuralChange: { kind: 'block-title' as const, nodeId: node.id, title: updated.slice(title.titleFrom, titleTo), isolated: true as const } } } } } } return baseResult }) } async updateContentWithInlineSemanticOps(url: AutomergeUrl, baseHeads: Heads, newContent: string, semantic: InlineSemanticChange, ownership?: WriteOwnership): Promise<TextWriteResult> { return this.runPublicWrite(url, async () => { void ownership const handle = await this.repo.find<PushworkFileDocument>(url) if (!this.isTextFirstEligible(handle.doc())) throw new DocumentManagerValidationError(url, 'not-file') await this.waitForPublicWriteFlush(url) await this.prepareTextFirstDocument(url) const record = this.textFirstRecord(url) record.writer.prepare(normalizeHeads(baseHeads)) const before = record.writer.getText().toString() for (const op of semantic.ops) { assertValidInlineSemanticRange(op.range, newContent.length) if (op.kind !== 'synthesize-paste' && !getCoreSemanticRegistry().getSpec(op.specName)) throw new Error(`Unknown inline semantic spec: ${op.specName}`) } const result = this.textFirstEnvelope(url, normalizeHeads(baseHeads), before, newContent) await this.flushTextFirstDocument(url) return this.textFirstWriteResult(url, normalizeHeads(baseHeads), before, record.writer.getText().toString(), result) }) } /** Write one block Record fact at an exact frontier and durably flush it. */ async writeBlockRecordFactAt(url: AutomergeUrl, baseHeads: Heads, key: string, value: BlockScalar | undefined): Promise<{ readonly changed: boolean; readonly heads: Heads }> { const handle = await this.repo.find<PushworkFileDocument>(url) if (!this.isTextFirstEligible(handle.doc())) throw new DocumentManagerValidationError(url, 'not-file') await this.prepareTextFirstDocument(url) const record = this.textFirstRecord(url) const heads = normalizeHeads(baseHeads) record.writer.prepare(heads) let changed = false const result = record.writer.applyMutation(heads, [], draft => { changed = value === undefined ? clearBlockFactDraft(draft as unknown as Record<string, unknown>, key) : writeBlockFactDraft(draft as unknown as Record<string, unknown>, key, value) assertTextFirstFactsMatchActiveTypes(draft as unknown as A.Doc<FrameworkDoc>, record.recognizedTypeNames) }, { message: 'reams:text-first:metadata' }) if (changed) await this.flushTextWrite(url) return { changed, heads: normalizeHeads(result.liveHeads) } }
/** Write related block Record facts together at one exact frontier. */ async writeBlockRecordFactsAt(url: AutomergeUrl, baseHeads: Heads, entries: readonly { readonly key: string; readonly value: BlockScalar | undefined }[], authorize?: () => Promise<boolean>): Promise<{ readonly changed: boolean; readonly operationHeads?: Heads; readonly heads: Heads; readonly isolated: boolean }> { return this.runPublicWrite(url, async () => { const handle = await this.repo.find<PushworkFileDocument>(url) if (!this.isTextFirstEligible(handle.doc())) throw new DocumentManagerValidationError(url, 'not-file') await this.prepareTextFirstDocument(url) const record = this.textFirstRecord(url) const heads = normalizeHeads(baseHeads) record.writer.prepare(heads) if (authorize && !await authorize()) return { changed: false, heads, isolated: true } let changed = false const result = record.writer.applyMutation(heads, [], draft => { for (const entry of entries) { changed = (entry.value === undefined ? clearBlockFactDraft(draft as unknown as Record<string, unknown>, entry.key) : writeBlockFactDraft(draft as unknown as Record<string, unknown>, entry.key, entry.value)) || changed } assertTextFirstFactsMatchActiveTypes(draft as unknown as A.Doc<FrameworkDoc>, record.recognizedTypeNames) }, { message: 'reams:text-first:metadata' }) if (changed) await this.flushTextWrite(url) const operationHeads = normalizeHeads(result.contributionHeads) const liveHeads = normalizeHeads(result.liveHeads) return { changed, ...(changed ? { operationHeads } : {}), heads: liveHeads, isolated: !changed || headsEqual(operationHeads, liveHeads) } }) }
async writeBlockRecordFact(url: AutomergeUrl, key: string, value: BlockScalar | undefined): Promise<TextWriteResult & { readonly changed: boolean }> { const handle = await this.repo.find<PushworkFileDocument>(url) if (this.isTextFirstEligible(handle.doc())) { await this.prepareTextFirstDocument(url) const record = this.textFirstRecord(url) const beforeHeads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) const beforeContent = record.writer.getText().toString() let changed = false record.applyingOrigin = {} try { const result = record.writer.applyMutation(beforeHeads, [], draft => { changed = value === undefined ? clearBlockFactDraft(draft as unknown as Record<string, unknown>, key) : writeBlockFactDraft(draft as unknown as Record<string, unknown>, key, value) assertTextFirstFactsMatchActiveTypes(draft as unknown as A.Doc<FrameworkDoc>, record.recognizedTypeNames) }, { message: 'reams:text-first:metadata' }) await this.flushTextFirstDocument(url) const afterContent = record.writer.getText().toString() return { ...this.textFirstWriteResult(url, beforeHeads, beforeContent, afterContent, result), changed } } finally { record.applyingOrigin = undefined } } await this.waitForPendingFlush(url) // Non-Markdown text retains the generic root-fact compatibility path. await this.waitForPendingFlush(url) this.validateTextContentShape(url, handle.doc()) const beforeContent = visibleTextFromDoc(handle.doc() as unknown as A.Doc<FrameworkDoc>) const beforeHeads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) const ownership = semanticWriteOwnership(handle) let changed = false ownership.enter() let succeeded = false try { handle.change(draft => { changed = value === undefined ? clearBlockFactDraft(draft as unknown as Record<string, unknown>, key) : writeBlockFactDraft(draft as unknown as Record<string, unknown>, key, value) }) succeeded = true } finally { if (succeeded) { const heads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) rememberSemanticHeads(handle, heads) } ownership.exit() } const heads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) const afterContent = visibleTextFromDoc(handle.doc() as unknown as A.Doc<FrameworkDoc>) if (changed) await this.flushTextWrite(url) return { documentId: url, beforeHeads, heads, content: afterContent, beforeContent, afterContent, changed } }
/** Write a native fact only when its node is still present in this change boundary. */ async writeBlockRecordFactCurrent( url: AutomergeUrl, key: string, value: BlockScalar | undefined, request: { readonly nodeId: string; readonly typeName: string }, validate: (current: string, nodeIndex: NodeIndexResult) => boolean, authorize?: () => Promise<boolean>, ): Promise<TextWriteResult & { readonly changed: boolean; readonly validated: boolean; readonly conflicted: boolean; readonly incrementalSafe: boolean }> { await this.waitForPendingFlush(url) const handle = await this.repo.find<PushworkFileDocument>(url) await this.waitForPendingFlush(url) if (authorize && !await authorize()) { const content = visibleTextFromDoc(handle.doc() as unknown as A.Doc<FrameworkDoc>) const heads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) return { documentId: url, beforeHeads: heads, heads, content, beforeContent: content, afterContent: content, changed: false, validated: false, conflicted: false, incrementalSafe: true } } await this.waitForPendingFlush(url) const beforeHeads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>))
// Text-first facts are metadata on the prepared writer's exact physical // line frontier. Do not let the legacy structural index or guard authorize // this narrow path, and do not allow a stale caller frontier through. if (this.isTextFirstEligible(handle.doc())) { await this.prepareTextFirstDocument(url) const record = this.textFirstRecord(url) this.assertTextFirstAvailable(url, record.handle) if (!headsEqual(beforeHeads, normalizeHeads(getHeads(record.handle.doc() as Doc<PushworkFileDocument>)))) { throw new Error('Text-first fact write requires the exact current frontier') } record.writer.prepare(beforeHeads) const nodeIndex = buildRecordNodeIndex(handle.doc() as unknown as A.Doc<FrameworkDoc>, { path: ['content'], recognizedTypeNames: record.recognizedTypeNames, }) const decoded = decodeBlockFactKey(key) const beforeContent = record.writer.getText().toString() const exactFacts = handle.doc() as unknown as Readonly<Record<string, unknown>> const beforeFact = readBlockFactCandidates(exactFacts, key)[0] const factCandidates = readBlockFactCandidates(exactFacts, key) const rawConflicts = A.getConflicts(exactFacts as A.Doc<Record<string, unknown>>, key as never) as Record<string, unknown> | undefined const malformed = Object.prototype.hasOwnProperty.call(exactFacts, key) && factCandidates.length === 0 || Object.values(rawConflicts ?? {}).some(value => !isBlockScalar(value) && !isRecordValueForRead(value)) if (malformed || factCandidates.length > 1) { return { documentId: url, beforeHeads, heads: beforeHeads, content: beforeContent, beforeContent, afterContent: beforeContent, changed: false, validated: true, conflicted: true, incrementalSafe: true } } if (nodeIndex.status !== 'available' || decoded?.[0] !== request.nodeId || decoded[1] !== request.typeName) { return { documentId: url, beforeHeads, heads: beforeHeads, content: beforeContent, beforeContent, afterContent: beforeContent, changed: false, validated: false, conflicted: false, incrementalSafe: true } } const node = nodeIndex.index.getNode(request.nodeId) if (!node || !validate(beforeContent, nodeIndex)) { return { documentId: url, beforeHeads, heads: beforeHeads, content: beforeContent, beforeContent, afterContent: beforeContent, changed: false, validated: false, conflicted: false, incrementalSafe: true } } let changed = false let contributionHeads: Heads = [] const ownership = semanticWriteOwnership(handle) ownership.enter() let succeeded = false try { const encoded = record.writer.applyMutation(beforeHeads, [], draft => { changed = value === undefined ? clearBlockFactDraft(draft as unknown as Record<string, unknown>, key) : writeBlockFactDraft(draft as unknown as Record<string, unknown>, key, value) assertTextFirstFactsMatchActiveTypes(draft as unknown as A.Doc<FrameworkDoc>, record.recognizedTypeNames) }, { message: 'reams:text-first:metadata' }) contributionHeads = normalizeHeads(encoded.contributionHeads) succeeded = true if (encoded.contributionHeads.length > 0) rememberSemanticHeads(handle, normalizeHeads(encoded.contributionHeads)) } finally { if (succeeded) rememberSemanticHeads(handle, normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>))) ownership.exit() } const heads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) const origin = changed && contributionHeads.length > 0 ? captureFactOrigin(handle.doc() as unknown as A.Doc<unknown>, beforeHeads, contributionHeads, key) : undefined if (changed) await record.writer.flush() return { documentId: url, beforeHeads, heads, content: beforeContent, beforeContent, afterContent: beforeContent, changed, validated: true, conflicted: false, incrementalSafe: true, ...(origin ? { factChange: { key, before: beforeFact === undefined ? undefined : snapshotBlockFact(beforeFact), after: value === undefined ? undefined : snapshotBlockFact(value), origin } } : {}) } }
const cached = this.markdownSnapshot(handle, beforeHeads) if (!cached) this.validateTextContentShape(url, handle.doc()) const beforeContent = cached?.content ?? visibleTextFromDoc(handle.doc() as unknown as A.Doc<FrameworkDoc>) const decoded = decodeBlockFactKey(key) const exactFacts = handle.doc() as unknown as Readonly<Record<string, unknown>> if ((globalThis as { __reamsPerfDiagnostics__?: unknown }).__reamsPerfDiagnostics__) globalThis.performance?.mark('reams:record-edit-fact-read') const rawConflicts = A.getConflicts(exactFacts as A.Doc<Record<string, unknown>>, key as never) as Record<string, unknown> | undefined const factCandidates = readBlockFactCandidates(exactFacts, key) const malformed = Object.prototype.hasOwnProperty.call(exactFacts, key) && factCandidates.length === 0 || Object.values(rawConflicts ?? {}).some(value => !isBlockScalar(value) && !isRecordValueForRead(value)) if (malformed || factCandidates.length > 1) { return { documentId: url, beforeHeads, heads: beforeHeads, content: beforeContent, beforeContent, afterContent: beforeContent, changed: false, validated: true, conflicted: true, incrementalSafe: true } } const fastValidated = decoded?.[0] === request.nodeId && decoded[1] === request.typeName && cached?.nodeIndex.status === 'available' && cached.nodeIndex.index.getNode(request.nodeId) !== undefined && cached.authorizedTypesByNode.get(request.nodeId)?.has(request.typeName) === true const ownership = semanticWriteOwnership(handle) let changed = false let validated = false let conflicted = false let operationHeads: Heads | undefined if ((globalThis as { __reamsPerfDiagnostics__?: unknown }).__reamsPerfDiagnostics__) globalThis.performance?.mark('reams:record-edit-prepared') ownership.enter() let succeeded = false try { const encoded = handle.changeAt(encodeHeads([...beforeHeads]), draft => { if (!fastValidated) { const current = visibleTextFromDoc(draft as unknown as A.Doc<FrameworkDoc>) const nodeIndex = safeBuildNodeIndex(draft as unknown as A.Doc<FrameworkDoc>) if (nodeIndex.status !== 'available' || !validate(current, nodeIndex)) return } validated = true changed = value === undefined ? clearBlockFactDraft(draft as unknown as Record<string, unknown>, key) : writeBlockFactDraft(draft as unknown as Record<string, unknown>, key, value) }, { message: 'reams:text-first:metadata' }) if (changed && encoded) operationHeads = normalizeHeads(decodeHeads(encoded)) succeeded = true } finally { if (succeeded) { if (operationHeads) rememberSemanticHeads(handle, operationHeads) rememberSemanticHeads(handle, normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>))) } ownership.exit() } if ((globalThis as { __reamsPerfDiagnostics__?: unknown }).__reamsPerfDiagnostics__) globalThis.performance?.mark('reams:record-edit-fact-changed') if ((globalThis as { __reamsPerfDiagnostics__?: unknown }).__reamsPerfDiagnostics__) globalThis.performance?.mark('reams:record-edit-changed') const heads = normalizeHeads(getHeads(handle.doc() as Doc<PushworkFileDocument>)) const isolated = operationHeads !== undefined && headsEqual(operationHeads, heads) const afterContent = !changed || isolated ? beforeContent : visibleTextFromDoc(handle.doc() as unknown as A.Doc<FrameworkDoc>) if (changed) await this.flushTextWrite(url) return { documentId: url, beforeHeads, heads, content: afterContent, beforeContent, afterContent, changed, validated, conflicted, incrementalSafe: !changed || isolated } }
/** * Update the binary content of a file. * Binary files are replaced atomically (not using updateText). * * Throws if the URL does not point to a valid Pushwork file document. */ async updateBinaryContent(url: AutomergeUrl, data: Uint8Array): Promise<void> { await this.waitForPendingFlush(url) const handle = await this.repo.find<PushworkFileDocument>(url) await this.waitForPendingFlush(url) const doc = handle.doc() if (!isPushworkFile(doc)) { throw new Error(`Document ${url} is not a Pushwork file`) } handle.change((doc) => { doc.content = new Uint8Array(data) }) await this.flushTextWrite(url) }
/** * Rename a file. Updates: * 1. The file document's name, extension, and mimeType * 2. The matching entry in the parent folder's docs array * * Throws if the file is not found in the folder's docs array. */ async renameFile(fileUrl: AutomergeUrl, folderUrl: AutomergeUrl, newName: string, expectedName?: string, receipt?: (folder: DocHandle<PushworkDirectoryDocument>) => void): Promise<void> { const newExt = extensionFromFileName(newName)
// Find both handles first, then mutate both documents without yielding. const fileHandle = await this.repo.find<PushworkFileDocument>(fileUrl) const folderHandle = await this.repo.find<PushworkDirectoryDocument>(folderUrl) await this.waitForPendingFlush(fileUrl) await this.waitForPendingFlush(folderUrl)
// Validate: file must exist in folder (fail-fast before any mutations) const folderDoc = folderHandle.doc() const entryIndex = folderDoc.docs.findIndex((d) => d.url === fileUrl && (expectedName === undefined || d.name === expectedName) && !isFolderEntry(d)) if (entryIndex === -1) { throw new Error(`File ${fileUrl} not found in folder ${folderUrl}`) } receipt?.(folderHandle) const changed = new Set<AutomergeUrl>() let mutationFailed = false let mutationError: unknown try { changed.add(fileUrl) fileHandle.change((doc) => { doc.name = newName doc.extension = newExt doc.mimeType = mimeTypeFromExtension(newExt) const newContentType = inferContentType(newName, doc.mimeType) if (doc.contentType !== newContentType) { doc.contentType = newContentType } }) changed.add(folderUrl) folderHandle.change((doc) => { const current = doc.docs.find(d => d.url === fileUrl && d.name === (expectedName ?? folderDoc.docs[entryIndex]!.name) && !isFolderEntry(d)) if (!current) throw new Error(`File ${fileUrl} changed in folder ${folderUrl}`) current.name = newName }) } catch (error) { mutationFailed = true mutationError = error } const settled = await Promise.allSettled([...changed].map(url => this.flushTextWrite(url))) if (mutationFailed) throw mutationError const failed = settled.find(result => result.status === 'rejected') if (failed?.status === 'rejected') throw failed.reason }
/** * Rename an entry (file or folder) in a parent folder's docs array. * Only updates the name in the parent's docs[] — does NOT modify * the entry's own Automerge document. * * Use this for folder renames (folder docs don't store their own name) * or when you only need to update the directory listing name. * * Throws if the entry is not found in the folder's docs array. */ async renameEntry(entryUrl: AutomergeUrl, parentFolderUrl: AutomergeUrl, newName: string, expectedName?: string, receipt?: (folder: DocHandle<PushworkDirectoryDocument>) => void): Promise<void> { const folderHandle = await this.repo.find<PushworkDirectoryDocument>(parentFolderUrl) await this.waitForPendingFlush(parentFolderUrl) const folderDoc = folderHandle.doc() const entryIndex = folderDoc.docs.findIndex((d) => d.url === entryUrl && (expectedName === undefined || d.name === expectedName)) if (entryIndex === -1) { throw new Error(`Entry ${entryUrl} not found in folder ${parentFolderUrl}`) }
receipt?.(folderHandle) let mutationFailed = false let mutationError: unknown try { folderHandle.change((doc) => { const current = doc.docs.find(d => d.url === entryUrl && d.name === (expectedName ?? folderDoc.docs[entryIndex]!.name)) if (!current) throw new Error(`Entry ${entryUrl} changed in folder ${parentFolderUrl}`) current.name = newName }) } catch (error) { mutationFailed = true mutationError = error } const settled = await Promise.allSettled([this.flushTextWrite(parentFolderUrl)]) if (mutationFailed) throw mutationError const failed = settled.find(result => result.status === 'rejected') if (failed?.status === 'rejected') throw failed.reason }
/** * Delete a file. Removes the entry from the parent folder and * deletes the Automerge document from the repo. * * Unlike moveFile/renameFile, this does NOT throw if the file is * not found in the folder. Delete is a cleanup operation — the * Automerge document is always deleted regardless of folder state. * This allows cleaning up orphaned documents. */ async deleteFile(fileUrl: AutomergeUrl, folderUrl: AutomergeUrl, receipt?: (parent: DocHandle<PushworkDirectoryDocument>) => void, beforeMutation?: (parent: DocHandle<PushworkDirectoryDocument>) => void | Promise<void | (() => void)>): Promise<void> { // Remove from parent folder first (while the document still exists) const folderHandle = await this.repo.find<PushworkDirectoryDocument>(folderUrl) await this.waitForPendingFlush(folderUrl) const linked = folderHandle.doc().docs.find(d => d.url === fileUrl) receipt?.(folderHandle) if (beforeMutation && (!linked || !isFileEntry(linked))) throw new Error(`Entry ${fileUrl} changed placement in folder ${folderUrl}`) const finalGuard = await beforeMutation?.(folderHandle) if (finalGuard) finalGuard() let mutationError: unknown let mutationFailed = false try { folderHandle.change(doc => { const index = doc.docs.findIndex(d => d.url === fileUrl && (!linked || (d.name === linked.name && d.type === linked.type)) && isFileEntry(d)) if (index < 0 && linked) throw new Error(`Entry ${fileUrl} changed placement in folder ${folderUrl}`) if (index >= 0) doc.docs.splice(index, 1) }) } catch (error) { mutationFailed = true; mutationError = error } const settled = await Promise.allSettled([this.flushTextWrite(folderUrl)]) if (mutationFailed) throw mutationError const failed = settled.find(result => result.status === 'rejected') if (failed?.status === 'rejected') throw failed.reason
// Delete the Automerge document const record = this.textFirstRecords.get(fileUrl) await this.repo.delete(fileUrl) if (record) { record.disposed = true record.handle.off('change', record.onChange) ;(record.handle as unknown as { off: (event: string, listener: () => void) => void }).off('heads-changed', record.onChange) record.listeners.clear() this.textFirstRecords.delete(fileUrl) } }
/** * Move a file or folder entry from one parent folder to another. * The underlying Automerge document URL does not change — only * the directory entries are updated (analogous to a Unix rename(2)). * * Works for both file and folder entries. */ async moveFile( fileUrl: AutomergeUrl, srcFolderUrl: AutomergeUrl, dstFolderUrl: AutomergeUrl, finalName?: string, receipt?: (source: DocHandle<PushworkDirectoryDocument>, destination: DocHandle<PushworkDirectoryDocument>) => void, ): Promise<void> { const parents = [...new Set([srcFolderUrl, dstFolderUrl])].sort() const mutate = async (): Promise<void> => { // Resolve every required handle and settle writes already in flight before // either directory edge is touched. const [srcHandle, dstHandle] = await Promise.all([ this.repo.find<PushworkDirectoryDocument>(srcFolderUrl), srcFolderUrl === dstFolderUrl ? this.repo.find<PushworkDirectoryDocument>(srcFolderUrl) : this.repo.find<PushworkDirectoryDocument>(dstFolderUrl), ]) await Promise.all(parents.map(url => this.waitForPendingFlush(url))) const srcDoc = srcHandle.doc() const entry = srcDoc.docs.find(d => d.url === fileUrl) if (!entry) throw new Error(`File ${fileUrl} not found in source folder ${srcFolderUrl}`) const entryName = entry.name const entryType = entry.type const destinationName = finalName ?? entryName const sameParent = srcFolderUrl === dstFolderUrl const dstDoc = dstHandle.doc() if (!sameParent && dstDoc.docs.some(d => d.name === destinationName)) throw new DocumentManagerExistingEntryError(destinationName) if (sameParent && destinationName !== entryName && dstDoc.docs.some(d => d.url !== fileUrl && d.name === destinationName)) throw new DocumentManagerExistingEntryError(destinationName)
// File metadata is part of the same in-memory segment as directory // publication. Folder documents carry their name only on the parent edge. let fileHandle: DocHandle<PushworkFileDocument> | undefined let sourceBodyName: string | undefined if (!isFolderEntry(entry) && destinationName !== entryName) { fileHandle = await this.repo.find<PushworkFileDocument>(fileUrl) await this.waitForPendingFlush(fileUrl) if (!isPushworkFile(fileHandle.doc())) throw new Error(`Document ${fileUrl} is not a Pushwork file`) sourceBodyName = fileHandle.doc().name } receipt?.(srcHandle, dstHandle) const changed = new Set<AutomergeUrl>() let mutationFailed = false let mutationError: unknown try { const assertSource = (doc: PushworkDirectoryDocument) => { if (!doc.docs.some(d => d.url === fileUrl && d.name === entryName && d.type === entryType)) throw new Error(`Entry ${fileUrl} changed in source folder ${srcFolderUrl}`) } if (sameParent) { changed.add(srcFolderUrl) srcHandle.change(doc => { assertSource(doc) if (destinationName !== entryName && doc.docs.some(d => d.url !== fileUrl && d.name === destinationName)) throw new DocumentManagerExistingEntryError(destinationName) const current = doc.docs.find(d => d.url === fileUrl && d.name === entryName && d.type === entryType)! current.name = destinationName }) } else { // Destination first: observers always see the canonical object under // its new edge before the old parent can report it missing. changed.add(dstFolderUrl) dstHandle.change(doc => { if (doc.docs.some(d => d.name === destinationName)) throw new DocumentManagerExistingEntryError(destinationName) doc.docs.push({ name: destinationName, type: entryType, url: fileUrl }) }) changed.add(srcFolderUrl) srcHandle.change(doc => { assertSource(doc) const index = doc.docs.findIndex(d => d.url === fileUrl && d.name === entryName && d.type === entryType) if (index < 0) throw new Error(`Entry ${fileUrl} changed in source folder ${srcFolderUrl}`) doc.docs.splice(index, 1) }) } if (fileHandle && destinationName !== entryName) { changed.add(fileUrl) fileHandle.change(doc => { if (doc.name !== sourceBodyName) throw new Error(`File ${fileUrl} changed while being renamed`) doc.name = destinationName doc.extension = extensionFromFileName(destinationName) doc.mimeType = mimeTypeFromExtension(doc.extension) doc.contentType = inferContentType(destinationName, doc.mimeType) }) } } catch (error) { mutationFailed = true mutationError = error } // Attempt durability for every changed document, including earlier // mutation phases when a later phase fails; never roll back CRDT edges. const settled = await Promise.allSettled([...changed].map(url => this.flushTextWrite(url))) if (mutationFailed) throw mutationError const failed = settled.find(result => result.status === 'rejected') if (failed?.status === 'rejected') throw failed.reason } await withFolderMutationLocks(this.repo, [fileUrl, ...parents], mutate) }
/** * Create a new subfolder inside a parent folder. * Returns the AutomergeUrl of the created subfolder. */ async createSubfolder(parentFolderUrl: AutomergeUrl, folderName: string, options?: { reamsStateRoot?: true; onboarding?: boolean }, beforeLink?: (url: AutomergeUrl) => void): Promise<AutomergeUrl> { const inputName = folderName const inputOptions = options ? { ...options } : undefined return withFolderMutationLock(this.repo, parentFolderUrl, async () => { const parentHandle = await this.repo.find<PushworkDirectoryDocument>(parentFolderUrl) await this.waitForPendingFlush(parentFolderUrl) assertFolderEntryVacant(parentHandle.doc(), inputName) const subFolderUrl = this.createFolder(inputOptions) let published = false try { await this.flushTextWrite(subFolderUrl) if (inputOptions?.reamsStateRoot) { const stateHandle = await this.repo.find<PushworkDirectoryDocument>(subFolderUrl) stateHandle.change(doc => { doc[REAMS_STATE_MARKER] = { kind: 'root', version: 1 } }) await this.flushTextWrite(subFolderUrl) } const child = await this.getFolderDeletionHandle(subFolderUrl) if (!child) throw Object.assign(new Error(`Cannot attach deleted folder ${subFolderUrl}`), { code: 'DOCUMENT_DELETED' }) const prepared: unknown = beforeLink?.(subFolderUrl) if (prepared && (typeof prepared === 'object' || typeof prepared === 'function') && 'then' in prepared) { void Promise.resolve(prepared).catch(() => {}) throw new Error('Folder before-link handoff must be synchronous') } let mutationError: unknown let mutationFailed = false try { parentHandle.change((doc) => { assertFolderAttachment(this.repo, parentFolderUrl, child) assertFolderEntryVacant(doc, inputName) doc.docs.push({ name: inputName, type: 'folder', url: subFolderUrl }) }); published = true } catch (error) { published = parentHandle.doc().docs.some(entry => entry.url === subFolderUrl) mutationFailed = true mutationError = error } const settled = await Promise.allSettled([this.flushTextWrite(parentFolderUrl)]) if (mutationFailed) throw mutationError const failed = settled.find(result => result.status === 'rejected') if (failed?.status === 'rejected') throw failed.reason } catch (error) { // Another accepted attachment may have won while child durability was // pending. Never discard the now-owned body during failed creation. const orphan = this.repo.handles?.[subFolderUrl.slice('automerge:'.length) as AutomergeRepoDocumentId] const orphanDoc = orphan?.isReady() && !orphan.isDeleted() ? orphan.doc() : undefined // A nested publication may already own the child's mutation turn or // have completed inside it. Preserve that accepted work for recovery. if (!published && isPushworkDirectory(orphanDoc) && orphanDoc.docs.length === 0 && !folderMutationTails.get(this.repo)?.has(subFolderUrl) && !hasFolderParent(this.repo, subFolderUrl) && !retiringFolders.get(this.repo)?.has(subFolderUrl)) { // Reserve cleanup ownership before native deletion can yield. retireFolder(this.repo, subFolderUrl) await this.repo.delete(subFolderUrl) } throw error } return subFolderUrl }) }
/** * Delete a folder. Removes the entry from the parent folder and * deletes the folder's Automerge document. * * NOTE: This does NOT recursively delete child documents. * Children become orphaned. Recursive deletion is a future concern. */ async deleteFolder(folderUrl: AutomergeUrl, parentFolderUrl: AutomergeUrl, receipt?: (parent: DocHandle<PushworkDirectoryDocument>) => void, beforeMutation?: (parent: DocHandle<PushworkDirectoryDocument>) => void | Promise<void | (() => void)>): Promise<void> { // Share creation/move serialization through the durability-to-tombstone // gap. The target lock drains earlier accepted child publications before // the caller's final empty guard, and blocks later ones until deletion ends. const mutate = async (): Promise<void> => { const folderHandle = await this.getFolderDeletionHandle(folderUrl) // Remove from parent const parentHandle = await this.repo.find<PushworkDirectoryDocument>(parentFolderUrl) await this.waitForPendingFlush(parentFolderUrl) const linked = parentHandle.doc().docs.find(d => d.url === folderUrl) receipt?.(parentHandle) if (!linked || !isFolderEntry(linked)) throw new Error(`Entry ${folderUrl} changed placement in folder ${parentFolderUrl}`) const finalGuard = await beforeMutation?.(parentHandle) if (finalGuard) finalGuard() let mutationError: unknown let mutationFailed = false try { parentHandle.change(doc => { const index = doc.docs.findIndex(d => d.url === folderUrl && d.name === linked.name && isFolderEntry(d)) if (index < 0) throw new Error(`Entry ${folderUrl} changed placement in folder ${parentFolderUrl}`) doc.docs.splice(index, 1) }) } catch (error) { mutationFailed = true; mutationError = error } if (!parentHandle.doc().docs.some(entry => entry.url === folderUrl)) { retireFolder(this.repo, folderUrl) } const settled = await Promise.allSettled([this.flushTextWrite(parentFolderUrl)]) if (mutationFailed) throw mutationError const failed = settled.find(result => result.status === 'rejected') if (failed?.status === 'rejected') throw failed.reason
// Delete the folder document if (folderHandle && !folderHandle.isDeleted()) await this.repo.delete(folderUrl) } await withFolderMutationLocks(this.repo, [folderUrl, parentFolderUrl], mutate, folderUrl) }
/** * List the entries in a folder. * Returns an empty array if the URL is not a folder. */ async listFolder(folderUrl: AutomergeUrl): Promise<readonly DirectoryEntry[]> { const folder = await this.getFolder(folderUrl) if (!folder) return [] return folder.docs }}