feat(workspace): unify document content model — timeline-backed DocumentHandle#1532
Merged
feat(workspace): unify document content model — timeline-backed DocumentHandle#1532
Conversation
Move createTimeline, entry-types, and sheet CSV helpers from packages/filesystem into packages/workspace/src/content/. Filesystem now re-exports from workspace. Replace branded generateColumnId/ generateRowId with generateId() in sheet-csv. Spec items: 1.1–1.6
Define DocumentContent type with read(), write(), getText(), getFragment(),
and timeline. Wire content property in makeHandle() with automatic migration
from legacy Y.Text('content') to Y.Array('timeline'). Remove old read()/write()
from DocumentHandle type. Update tests to use handle.content.
Spec items: 2.1–2.4
Update createContentHelpers to use handle.content for read and handle.content.timeline for advanced operations (binary, sheet, append). Move timeline.test.ts from filesystem to workspace. All 555 tests pass. Spec items: 3.1, 3.3, 3.4
Replace stale warnings about dual Y.Text/Y.Array stores with accurate documentation pointing to handle.content as the canonical interface. Update AGENTS.md, workspace README, and workspace API README.
Drop migrateIfNeeded() — clean break from Y.Text('content') store.
Old data is orphaned, not migrated. Timeline is the only content model.
- Remove BinaryEntry type, pushBinary(), readBuffer() from timeline - Remove binary cases from readAsString() and readAsBuffer() - Remove migrateIfNeeded() and all call sites from create-document.ts - Remove readBuffer, binary write/append branches from filesystem content.ts - Simplify file-system.ts: cp copies text, writeFile converts Uint8Array to text - Remove binary re-exports from all index files - Update filesystem tests to remove binary-specific assertions
- Remove BinaryEntry type and binary cases from timeline - Remove pushBinary() from Timeline type and implementation - Remove migrateIfNeeded() and all call sites from makeHandle() - Remove BinaryEntry from all index exports
…e-system - Remove readBuffer() and binary write/append branches from content.ts - Simplify cp() to copy text (no binary detection) - Convert Uint8Array to text in writeFile() via TextDecoder - Reimplement readFileBuffer() as text-to-bytes encoding - Remove BinaryEntry re-exports from entry-types.ts and index.ts
Remove createContentHelpers factory — content operations are now built inline using document handles directly. Keeps binary, sheet, and append support. ContentHelpers type remains for the public API contract.
Drop readBuffer, pushBinary, and binary branch from write/append/cp. Content is text-only (or sheet CSV). readFileBuffer now encodes from text.
… FileSystem return - Delete content/content.ts — the type was only used once - Move content methods directly into the FileSystem return object - Inline handle.content.read() at readFile and cp call sites - Remove ContentHelpers from all barrel exports - Add detailed JSDoc to each content method
… Y.Text
Replace ydoc.getText('content').insert() with handle.content.write()
in onUpdate callback tests. Fix BinaryEntry re-export in filesystem.
… model docs Update AGENTS.md files and READMEs to reflect clean break: no migration, no binary mode, timeline is the only content model.
…Honeycrisp
- Fuji: handle.ydoc.getText('content') → handle.content.getText()
- Honeycrisp: handle.ydoc.getXmlFragment('content') → handle.content.getFragment()
- getText()/getFragment() now auto-create empty entries for editor binding
(matches Y.Text lazy-create semantics — no empty editor regression)
- Update JSDoc on DocumentContent to document auto-create behavior
…timeline - Add createdAt: number to all entry type shapes - Stamp Date.now() on all push*() methods - Add ValidatedEntry discriminated union with runtime instanceof checks - Add readEntry() for safe entry access (eliminates unsafe as-casts) - Rewrite readAsString()/readAsBuffer() to use readEntry() internally
…ndle - Delete DocumentContent type, inline fields into DocumentHandle - handle.read() replaces handle.content.read(), etc. - Update makeHandle(), all tests, and filesystem consumers - Remove DocumentContent export from workspace index
- handle.read()/write()/getText()/getFragment() replace handle.content.* - Remove binary mode and legacy migration references
- handle.read()/write()/getText()/getFragment() replace handle.content.* - Remove binary mode and sheet references from content model docs
- All waves checked off (Wave 4 skipped — write() already string-only) - Added review section with deviations and summary
- Add batch(fn) to DocumentHandle — wraps ydoc.transact() - Replace handle.ydoc.transact() with handle.batch() in file-system.ts - Replace handle.ydoc.transact() + createTimeline(handle.ydoc) in tests with handle.batch() + handle.timeline - Mark handle.ydoc JSDoc as escape hatch with strong guidance to prefer handle.read/write/getText/getFragment/batch instead
…eycrisp - handle.content.getText() → handle.getText() in Fuji - handle.content.getFragment() → handle.getFragment() in Honeycrisp
… escape hatch - Rewrite workspace-api Document Content section: handle.read/write/getText/ getFragment is the API, handle.batch() for transactions, handle.ydoc is escape hatch only - Add Epicenter Content Model section to yjs skill with same guidance - Remove all references to legacy createContentHelpers, createTimeline, and direct ydoc access patterns
…imeline, Y.Text('content')
- workspace README: handle.content → handle.read/write (flat API)
- file-system.test.ts: createTimeline(handle.ydoc) → handle.timeline
- doc articles: Y.Text('content') → Y.Array('timeline') diagrams
Dead code — with binary mode gone, readAsBuffer() was just TextEncoder.encode(readAsString()). The filesystem's readFileBuffer already handles its own encoding.
… readEntry()
- Import readEntry from @epicenter/workspace
- write(): use readEntry() to safely access sheet columns/rows
- append(): use readEntry() to safely access text Y.Text content
- No more 'as import("yjs").Text' or 'as import("yjs").Map' casts
- Mark promote-timeline and unify-content-model specs as superseded - Link to document-handle-cleanup spec for current API
Add ContentConversionError (defineErrors), xmlFragmentToPlaintext (block-aware plaintext extraction from Y.XmlFragment), populateFragmentFromText (write paragraphs into doc-backed fragment), and pushRichtext() to Timeline. Fix readAsString() returning '' for richtext entries—now extracts plaintext with newlines between block elements.
Replace getText()/getFragment() with mode-aware as*() methods that return Result types and automatically convert between content modes inside ydoc.transact(). Add handle.mode getter. Breaking change: getText() and getFragment() are removed entirely.
Update Fuji to use handle.asText().data and Honeycrisp to use handle.asRichText().data. Update workspace AGENTS.md content model docs to reference the new API.
…allible Every pairwise content conversion (text↔richtext↔sheet) always succeeds: any string is valid CSV, richtext→text always extracts plaintext, etc. Wrapping infallible operations in Result misled callers into handling errors that don't exist. The as*() methods now return plain values (Y.Text, Y.XmlFragment, SheetBinding). ContentConversionError deleted.
asText() and asRichText() now return plain values directly.
…larity Rename content/ → timeline/ to match the core abstraction. Rename files: entry-types.ts → entries.ts, conversions.ts → richtext.ts, sheet-csv.ts → sheet.ts, conversions.test.ts → richtext.test.ts. Update all internal and external import paths.
…-index computeMidpoint and generateInitialOrders are generic ordering utilities with no sheet/timeline dependency. Move them to shared/ so they can be reused without pulling in the timeline module.
…eet) Sweep stale getText()/getFragment() references from AGENTS.md, workspace READMEs, workspace-api skill, and yjs skill. Check off remaining doc items from the prior cleanup spec.
…statuses The yjs skill had an 'Epicenter Content Model' section that belongs in the workspace-api skill—moved it out, kept only the anti-pattern warning with a cross-reference. Deleted packages/workspace/AGENTS.md which was redundant with README.md and had a false claim about as*() returning Result types. Updated three specs: promote-timeline note had wrong method names (getText/getFragment→asText/asRichText/asSheet), handle-cleanup was still marked Planned, and conversion-api was still In Progress with stale Ok() success criteria.
The timeline directory was renamed from content/ to timeline/ in the extract-fractional-ordering refactor, but this README still referenced the old path.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes the timeline abstraction from
packages/filesystemintopackages/workspace, kills the dual-store trap onDocumentHandle, removes binary mode, and adds mode-awareasText()/asRichText()/asSheet()conversion methods. Every app that uses.withDocument()now gets timeline-backed content access through the handle—no one importscreateTimeline()or works with raw shared types directly.The problem
DocumentHandlehadread()/write()methods that operated on a rawY.Text('content')shared type. The filesystem had a separate timeline (Y.Array('timeline')) for the same content. Two stores in one Y.Doc, and the handle methods silently wrote to the one nothing else read:The solution
One content model. The handle IS the interface:
The
as*()methods are the key design decision. All conversions between modes are infallible—richtext→text strips formatting, text→sheet parses as CSV (any string is valid CSV, worst case: single cell), sheet→text serializes to CSV. NoResultwrapping, no error handling at call sites.What moved where
The dependency direction required this move—
workspacecannot import fromfilesystem, butfilesystemalready depends onworkspace.What was removed
Binary mode:
pushBinary(),BinaryEntry,readAsBuffer()all deleted. Binary content was an artifact of the filesystem's in-memory map days—no app ever used it via the timeline. Content is text, richtext, or sheet.Legacy migration: No
migrateIfNeeded(). Clean break fromY.Text('content'). Old data is orphaned, not migrated. The dual-store period was short, and Opensidian was already switched to the filesystem path before this PR.ContentHelperstype: Deleted entirely. Content operations are inlined intocreateYjsFileSystem()usinghandle.contentdirectly. One less indirection layer.readAsBuffer(): With binary mode gone, this was justTextEncoder.encode(readAsString()). Callers that need bytes encode themselves.App migrations
Timeline internals
Each entry now has a
createdAttimestamp for future revision history:readEntry()validates rawY.Mapentries into aValidatedEntrydiscriminated union, eliminating allas Y.Textcasts. Corrupt entries gracefully degrade to{ mode: 'empty' }.Also in this PR
@epicenter/uiprimitives, adopt tree-view from shadcn-svelte-extras, replace inline SVGs with lucide-svelte iconshandle.read()/handle.write()(wrong store) tofs.content(timeline-backed), then further tohandle.read()in the new modelDeferred
SheetBindingusage in a real spreadsheet appcreatedAttimestamps