Skip to content

feat(workspace): unify document content model — timeline-backed DocumentHandle#1532

Merged
braden-w merged 40 commits intomainfrom
opencode/neon-forest
Mar 14, 2026
Merged

feat(workspace): unify document content model — timeline-backed DocumentHandle#1532
braden-w merged 40 commits intomainfrom
opencode/neon-forest

Conversation

@braden-w
Copy link
Member

@braden-w braden-w commented Mar 14, 2026

Promotes the timeline abstraction from packages/filesystem into packages/workspace, kills the dual-store trap on DocumentHandle, removes binary mode, and adds mode-aware asText()/asRichText()/asSheet() conversion methods. Every app that uses .withDocument() now gets timeline-backed content access through the handle—no one imports createTimeline() or works with raw shared types directly.

The problem

DocumentHandle had read()/write() methods that operated on a raw Y.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:

BEFORE: Two stores, handle methods are traps

Y.Doc (per row)
├── Y.Text('content')          ← handle.read()/write() — nobody reads this
├── Y.Array('timeline')        ← fs.content only
└── ...

Opensidian writes via handle.write()  → goes to Y.Text('content')
Filesystem reads via fs.content.read() → reads from Y.Array('timeline')
Result: content appears empty

The solution

One content model. The handle IS the interface:

AFTER: Single store, handle is the canonical API

DocumentHandle
├── read()            → string           (always works, flattens any mode)
├── write(text)       → void             (replaces or pushes text entry)
├── asText()          → Y.Text           (converts if needed — editor binding)
├── asRichText()      → Y.XmlFragment    (converts if needed — Tiptap binding)
├── asSheet()         → SheetBinding     (converts if needed — spreadsheet)
├── mode              → 'text' | 'richtext' | 'sheet' | undefined
├── timeline          → Timeline         (escape hatch for advanced ops)
├── batch(fn)         → void             (wraps ydoc.transact)
├── ydoc              → Y.Doc            (escape hatch — prefer handle methods)
└── exports           → Record           (extension exports)

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. No Result wrapping, no error handling at call sites.

What moved where

packages/filesystem/                    packages/workspace/src/timeline/
├── content/entry-types.ts ─────────►   ├── entries.ts
├── content/timeline.ts    ─────────►   ├── timeline.ts  (+ readEntry, ValidatedEntry)
├── formats/sheet.ts (CSV) ─────────►   ├── sheet.ts
└── content/content.ts (deleted) ────   ├── richtext.ts  (new: conversion helpers)
                                        └── index.ts

filesystem now re-exports from workspace (no consumer breakage)

The dependency direction required this move—workspace cannot import from filesystem, but filesystem already depends on workspace.

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 from Y.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.

ContentHelpers type: Deleted entirely. Content operations are inlined into createYjsFileSystem() using handle.content directly. One less indirection layer.

readAsBuffer(): With binary mode gone, this was just TextEncoder.encode(readAsString()). Callers that need bytes encode themselves.

App migrations

// Fuji (text editor with Tiptap)
// BEFORE: raw Yjs access
const ytext = handle.ydoc.getText('content');
// AFTER: handle API, auto-creates entry if needed
const ytext = handle.asText();

// Honeycrisp (richtext editor with Tiptap)
// BEFORE: raw Yjs access
const fragment = handle.ydoc.getXmlFragment('content');
// AFTER: handle API, converts from text/sheet if needed
const fragment = handle.asRichText();

// Filesystem content read/write
// BEFORE: created its own timeline
const { ydoc } = await documents.open(fileId);
return createTimeline(ydoc).readAsString();
// AFTER: delegates to handle
const handle = await documents.open(fileId);
return handle.read();

Timeline internals

Each entry now has a createdAt timestamp for future revision history:

type TextEntry = { type: 'text'; content: Y.Text; createdAt: number };
type RichTextEntry = { type: 'richtext'; content: Y.XmlFragment; frontmatter: Y.Map; createdAt: number };
type SheetEntry = { type: 'sheet'; columns: Y.Map; rows: Y.Map; createdAt: number };

readEntry() validates raw Y.Map entries into a ValidatedEntry discriminated union, eliminating all as Y.Text casts. Corrupt entries gracefully degrade to { mode: 'empty' }.

Also in this PR

  • Opensidian UI modernization: replace raw inputs with @epicenter/ui primitives, adopt tree-view from shadcn-svelte-extras, replace inline SVGs with lucide-svelte icons
  • Content model fix: switch Opensidian from old handle.read()/handle.write() (wrong store) to fs.content (timeline-backed), then further to handle.read() in the new model
  • Documentation sweep: update all AGENTS.md, README, and skill files to reference the new handle API

Deferred

  • SheetBinding usage in a real spreadsheet app
  • Revision history UI using createdAt timestamps

braden-w added 30 commits March 13, 2026 22:41
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.
@braden-w braden-w changed the title feat(workspace): promote timeline to workspace, add handle.content API feat(workspace): unify document content model — timeline-backed DocumentHandle Mar 14, 2026
…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.
@braden-w braden-w merged commit 133a00a into main Mar 14, 2026
1 of 9 checks passed
@braden-w braden-w deleted the opencode/neon-forest branch March 14, 2026 08:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant