Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
f47b29d
refactor(workspace): move timeline into workspace package
braden-w Mar 14, 2026
251d787
feat(workspace): add timeline-backed content property to DocumentHandle
braden-w Mar 14, 2026
3dcc158
refactor(filesystem): delegate content helpers to handle.content
braden-w Mar 14, 2026
1de177f
docs: mark promote-timeline-to-workspace spec as implemented
braden-w Mar 14, 2026
aecca22
docs: update content model references to use handle.content
braden-w Mar 14, 2026
3c8fc67
refactor(workspace): remove legacy Y.Text migration from handle.content
braden-w Mar 14, 2026
29ce562
feat(workspace): remove binary mode and legacy migration
braden-w Mar 14, 2026
2ecdbb9
feat(workspace): remove BinaryEntry, pushBinary, and migrateIfNeeded
braden-w Mar 14, 2026
9489963
feat(filesystem): remove binary handling from content helpers and fil…
braden-w Mar 14, 2026
d4b0293
refactor(filesystem): inline content helpers into file-system.ts
braden-w Mar 14, 2026
919253c
refactor(filesystem): remove binary mode from content helpers
braden-w Mar 14, 2026
4aa5b83
refactor(filesystem): delete ContentHelpers type, inline content into…
braden-w Mar 14, 2026
45d0576
test(workspace): update tests to use handle.content instead of legacy…
braden-w Mar 14, 2026
b2d97cb
docs: remove legacy migration references and binary mode from content…
braden-w Mar 14, 2026
59ab89e
refactor: replace raw Yjs access with handle.content API in Fuji and …
braden-w Mar 14, 2026
d6ecebe
feat(workspace): add createdAt metadata and ValidatedEntry reader to …
braden-w Mar 14, 2026
71c04dd
refactor(workspace): flatten handle.content namespace into DocumentHa…
braden-w Mar 14, 2026
e8a1557
docs: update AGENTS.md files to reflect flattened handle API
braden-w Mar 14, 2026
a4230a7
docs: update workspace README to reflect flattened handle API
braden-w Mar 14, 2026
4f064fa
docs: mark spec complete with review section
braden-w Mar 14, 2026
0f07891
feat(workspace): add handle.batch() and mark ydoc as escape hatch
braden-w Mar 14, 2026
83fcc2d
fix(apps): flatten handle.content.getText/getFragment in Fuji and Hon…
braden-w Mar 14, 2026
80fa54a
docs: update workspace-api and yjs skills for handle.batch() and ydoc…
braden-w Mar 14, 2026
0bf0ddb
fix: update remaining stragglers — stale handle.content refs, createT…
braden-w Mar 14, 2026
eb98249
refactor(workspace): remove readAsBuffer() from timeline
braden-w Mar 14, 2026
943175d
refactor(filesystem): eliminate unsafe as-casts in write/append using…
braden-w Mar 14, 2026
1708066
docs: add superseded notes to specs referencing old handle.content API
braden-w Mar 14, 2026
106853e
feat(workspace): add content conversion foundation
braden-w Mar 14, 2026
20b19fd
feat(workspace): add asText/asRichText/asSheet to DocumentHandle
braden-w Mar 14, 2026
131f04e
feat(fuji,honeycrisp): migrate to asText/asRichText handle API
braden-w Mar 14, 2026
d4459f3
docs: add handle content conversion API spec
braden-w Mar 14, 2026
1ebbf05
refactor(workspace): drop Result from as*() — all conversions are inf…
braden-w Mar 14, 2026
a823731
fix(fuji,honeycrisp): simplify as*() calls — no more .data unwrap
braden-w Mar 14, 2026
0ea6128
docs: update spec — as*() returns plain values, explain infallibility…
braden-w Mar 14, 2026
2d413c3
refactor(workspace): rename content/ to timeline/, rename files for c…
braden-w Mar 14, 2026
acd4b35
refactor(workspace): extract fractional ordering to shared/fractional…
braden-w Mar 14, 2026
4573847
style: replace inline import() types with standard imports in Documen…
braden-w Mar 14, 2026
af89f4a
docs: update all references to new handle API (asText/asRichText/asSh…
braden-w Mar 14, 2026
f93745f
docs: fix stale skill files, remove redundant AGENTS.md, update spec …
braden-w Mar 14, 2026
e39a7bf
docs: fix stale content/ path reference in workspace README
braden-w Mar 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 40 additions & 25 deletions .agents/skills/workspace-api/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,53 +275,68 @@ return { ...row, views: 0, _v: 2 as const }; // Also works — redundant

## Document Content (Per-Row Y.Docs)

Tables with `.withDocument()` create a content Y.Doc per row. Content is stored using a **timeline model**: a `Y.Array('timeline')` inside the Y.Doc, where each entry is a typed `Y.Map` supporting text, binary, and sheet modes.
Tables with `.withDocument()` create a content Y.Doc per row. Content is stored using a **timeline model**: a `Y.Array('timeline')` inside the Y.Doc, where each entry is a typed `Y.Map` supporting text, richtext, and sheet modes.

### Reading and Writing Content

Use the filesystem package's content helpers, which read/write via the timeline:
Use `handle.read()`/`handle.write()` on the document handle:

```typescript
import { createYjsFileSystem } from '@epicenter/filesystem';

const fs = createYjsFileSystem(ws.tables.files, ws.documents.files.content);
const handle = await documents.open(fileId);

// Read content (timeline-backed)
const text = await fs.content.read(fileId);
const text = handle.read();

// Write content (timeline-backed)
await fs.content.write(fileId, 'hello');
handle.write('hello');

// Editor binding — Y.Text (converts from other modes if needed)
const ytext = handle.asText();

// Richtext editor binding — Y.XmlFragment (converts if needed)
const fragment = handle.asRichText();

// Spreadsheet binding — SheetBinding (converts if needed)
const { columns, rows } = handle.asSheet();

// Current content mode
handle.mode; // 'text' | 'richtext' | 'sheet' | undefined

// Advanced timeline operations
const tl = handle.timeline;
```

### Anti-Patterns
For filesystem operations, `fs.content.read(fileId)` and `fs.content.write(fileId, data)` open the handle and delegate to these methods internally.

### Batching Mutations

**Do not use `handle.read()`/`handle.write()`** for apps that also use the filesystem API. These methods read/write from `Y.Text('content')`—a different shared type than the timeline—causing silent data loss:
Use `handle.batch()` to group multiple mutations into a single Yjs transaction:

```typescript
// ❌ BAD: handle uses Y.Text('content'), filesystem uses Y.Array('timeline')
const handle = await ws.documents.files.content.open(id);
handle.read(); // reads from wrong shared type
handle.write('x'); // writes to wrong shared type

// ✅ GOOD: use fs.content which reads/writes via timeline
await fs.content.read(id);
await fs.content.write(id, 'hello');
handle.batch(() => {
handle.write('hello');
// ...other mutations
});
```

**Do not access `handle.ydoc` directly for content:**
**Do NOT call `handle.ydoc.transact()` directly.** Use `handle.batch()` instead.

### Anti-Patterns

**Do not access `handle.ydoc` for content operations:**

```typescript
// ❌ BAD: bypasses all abstractions
// ❌ BAD: bypasses timeline abstraction
const ytext = handle.ydoc.getText('content');
const fragment = handle.ydoc.getXmlFragment('content');
handle.ydoc.transact(() => { ... });

// ✅ GOOD: use timeline abstraction
import { createTimeline } from '@epicenter/filesystem';
const tl = createTimeline(handle.ydoc);
const text = tl.readAsString();
// ✅ GOOD: use handle methods
const ytext = handle.asText();
const fragment = handle.asRichText();
handle.batch(() => { ... });
```

See `specs/20260313T224500-unify-document-content-model.md` for the full unification plan.
`handle.ydoc` is an **escape hatch** for document extensions (persistence, sync providers) and tests. App code should never need it.

## References

Expand Down
25 changes: 9 additions & 16 deletions .agents/skills/yjs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,28 +209,21 @@ Any concurrent edits to the "moved" item are lost because you deleted the origin

### 6. Accessing Raw Y.Doc Shared Types for Document Content

Document Y.Docs in this codebase use a timeline model (`Y.Array('timeline')` with nested typed entries). Accessing top-level shared types directly writes to a different location than the timeline, causing silent data loss:
Document Y.Docs use a timeline model (`Y.Array('timeline')` with nested typed entries). Never access raw shared types on the ydoc directly—use the handle methods:

```typescript
// BAD: writes to Y.Text('content'), not the timeline
// BAD: bypasses the timeline
const ytext = handle.ydoc.getText('content');
ytext.insert(0, 'hello'); // invisible to fs.readFile()

// BAD: handle.read/write also uses Y.Text('content')
handle.read(); // reads from wrong shared type
handle.write('x'); // writes to wrong shared type

// GOOD: use filesystem content helpers (timeline-backed)
await fs.content.read(id);
await fs.content.write(id, 'hello');

// GOOD: use timeline abstraction directly if needed
import { createTimeline } from '@epicenter/filesystem';
const tl = createTimeline(handle.ydoc);
const text = tl.readAsString();
// GOOD: use handle methods (timeline-backed)
handle.read(); // string I/O
handle.write('hello');
handle.asText(); // Y.Text for editor binding
handle.asRichText(); // Y.XmlFragment for richtext binding
handle.asSheet(); // SheetBinding for spreadsheet binding
```

See `specs/20260313T224500-unify-document-content-model.md`.
See the **workspace-api** skill for the full `DocumentHandle` API.

## Debugging Tips

Expand Down
2 changes: 0 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,3 @@ Destructive actions need approval: Force pushes, hard resets (`--hard`), branch
Token-efficient execution: When possible, delegate to sub-agent with only the command. Instruct it to execute without re-analyzing.

Writing conventions: Load `writing-voice` skill for any user-facing text—UI strings, tooltips, error messages, docs. Em dashes are always closed (no spaces).

Content model: Document content uses the timeline model (`Y.Array('timeline')`). Never access `handle.ydoc.getText('content')` or use `handle.read()`/`handle.write()` alongside filesystem APIs—use `fs.content.read/write` or `createTimeline(ydoc)` from `@epicenter/filesystem` instead. See `specs/20260313T224500-unify-document-content-model.md`.
2 changes: 1 addition & 1 deletion apps/fuji/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@
workspaceClient.documents.entries.body.open(entryId).then((handle) => {
if (cancelled) return;
currentDocHandle = handle;
currentYText = handle.ydoc.getText('content');
currentYText = handle.asText();
});

return () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/honeycrisp/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
workspaceClient.documents.notes.body.open(noteId).then((handle) => {
if (cancelled) return;
currentDocHandle = handle;
currentYXmlFragment = handle.ydoc.getXmlFragment('content');
currentYXmlFragment = handle.asRichText();
});

return () => {
Expand Down
2 changes: 1 addition & 1 deletion docs/articles/only-the-leaves-need-revision-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Metadata Y.Doc (gc: true, always loaded)
└── { key: 'theme', val: 'dark', ts: ... }

Content Y.Doc (gc: false, loaded on demand) ← one per file
└── Y.Text('content') or Y.XmlFragment('content')
└── Y.Array('timeline') → [{ type: 'text', content: Y.Text }]
```

The metadata doc is a single document containing all your structural data: file names, parent IDs, timestamps, settings. It's small and always in memory. Garbage collection is on, so tombstones from updates get merged into a few bytes.
Expand Down
4 changes: 2 additions & 2 deletions docs/articles/updated-at-sentinel-for-external-yjs-docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ Metadata Y.Doc (gc: true, always loaded)
│ └── { id: 'def', name: 'index.ts', size: 512, updatedAt: ... }

Content Y.Doc 'abc' (gc: false, loaded on demand)
└── Y.Text('content') → "# API Reference\n..."
└── Y.Array('timeline') → [{ type: 'text', content: Y.Text('# API Reference\n...') }]

Content Y.Doc 'def' (gc: false, loaded on demand)
└── Y.Text('content') → "export function main() {..."
└── Y.Array('timeline') → [{ type: 'text', content: Y.Text('export function main() {...') }]
```

A user opens `api.md` and starts typing. Content doc `abc` gets updated, but the files table row still has the same name, same size, same `updatedAt`. No observer fires. Persistence doesn't save. The UI doesn't reflect the edit. The reference doesn't propagate change events.
Expand Down
127 changes: 0 additions & 127 deletions packages/filesystem/src/content/content.ts

This file was deleted.

34 changes: 9 additions & 25 deletions packages/filesystem/src/content/entry-types.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,11 @@
import type * as Y from 'yjs';

/**
* Timeline entry shapes — a discriminated union on 'type'.
* These describe the SHAPE of what's stored. At runtime, entries are Y.Map
* instances accessed via .get('type'), .get('content'), etc.
* Re-export entry types from @epicenter/workspace where the canonical
* definitions now live.
*/
export type TextEntry = { type: 'text'; content: Y.Text };
export type RichTextEntry = {
type: 'richtext';
content: Y.XmlFragment;
frontmatter: Y.Map<unknown>;
};
export type BinaryEntry = { type: 'binary'; content: Uint8Array };
export type SheetEntry = {
type: 'sheet';
columns: Y.Map<Y.Map<string>>;
rows: Y.Map<Y.Map<string>>;
};
export type TimelineEntry =
| TextEntry
| RichTextEntry
| BinaryEntry
| SheetEntry;

/** Content modes supported by timeline entries */
export type ContentMode = TimelineEntry['type'];
export type {
ContentMode,
RichTextEntry,
SheetEntry,
TextEntry,
TimelineEntry,
} from '@epicenter/workspace';
5 changes: 0 additions & 5 deletions packages/filesystem/src/content/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
export {
type ContentHelpers,
createContentHelpers,
} from './content.js';
export type {
BinaryEntry,
ContentMode,
RichTextEntry,
SheetEntry,
Expand Down
Loading
Loading