Skip to content

read_changes: the blocks that moved, not the whole document again (#87) - #112

Merged
alcor merged 1 commit into
mainfrom
feat/87-read-changes
Sep 14, 2026
Merged

alcor merged 1 commit into
mainfrom
feat/87-read-changes

Conversation

@alcor

@alcor alcor commented Sep 14, 2026

Copy link
Copy Markdown
Member

Closes #87.

document.changed carries a doc id and an actor and nothing else, so an agent polling a long document pulled the full markdown into context every time to find one edit. read_changes(doc_id, cursor) returns the blocks that changed since its cursor, each with its anchor and its markdown as they are now, plus the ids of blocks that left.

read_changes(doc_id, cursor?) -> {
  blocks:   [{ anchor, text, change: "added" | "changed" }],   // document order
  removed:  [block_id],
  cursor:   number,
  truncated: boolean,
}

Its cursor is its own

The issue asked for the events cursor. It cannot be: doc_changed is digested to one event per 30 seconds while blocks change on every keystroke, so a block change has no event seq to borrow. Sharing the namespace would silently drop every change that landed between digests, which is the exact failure the tool exists to fix. The tool's description says the cursor is its own.

What the observer had to learn

DocumentAgent keeps a block_changes table appended by the fragment observer. The observer now maintains a set of the fragment's block ids ahead of every guard it has, for two reasons that only came out of running it:

  • A deleted block cannot name itself. event.changes.deleted gives the item, but its attributes are no longer readable, so the id is gone by the time the observer sees it. The id set is the only place it survives. The first attempt read the id off the deleted item and the integration test failed with expected [] to deeply equal ['nyboimy7'].
  • A system write must not look like stillness. Import and restore write under the bare "agent" origin and deliberately fire no event. If block tracking sat behind that guard, an agent polling across a version restore would be told the document had not moved. There is a test for it.

Limits, stated in the tool

  • No cursor on the first call returns truncated and a cursor to start from. There is no baseline to diff against, so the honest answer is "read the document".
  • Rows are capped at 5,000 per document; a cursor older than the rows still kept also returns truncated.
  • Rows are written only while an agent is on the roster, matching how events already behaves, and are dropped with the document at expiry.
  • Blocks in documents written before block ids have no id to track and never appear.

Cost

The id set is refreshed on every observer firing, including for documents with no agents, where the old code did one roster query and returned. That is frag.toArray() plus one attribute lookup per top-level block. It is far below the mention scan that already runs per touched block (each of which serialises the whole document through getBlocks), but it is new work on the agentless path, and I have not measured it.

Tests

app/shared/block-changes.ts holds the pure part: 11 unit tests over collapsing, ordering, added-versus-changed, deleted-and-re-added, and both truncation cases. The observer, the table, and the RPC are covered by seven new DocumentAgent integration tests, including the deleted-block and system-write cases above.

The SQL fake in the integration harness gained autoincrement for block_changes and </> in DELETE, which the trim uses.

953 tests run, 937 pass. The 16 failures are the three localStorage suites on Node 25, which fail identically on main and are recorded in CLAUDE.md as an environment issue. Lint and typecheck clean.

Not verified: no agent has driven this over a live MCP connection. It is exercised through the DO's own RPC, not through the transport.

🤖 Generated with Claude Code

`document.changed` carries a doc id and an actor, so an agent polling a long
document pulled the full markdown into context every time to find one edit.
`read_changes(doc_id, cursor)` returns the blocks that changed since its
cursor, each with its anchor and its markdown as they are now, plus the ids
of blocks that left.

The cursor is its own sequence, not the events one. `doc_changed` is digested
to one event per 30 seconds while blocks change on every keystroke, so a block
change has no event seq of its own to borrow, and sharing the namespace would
silently drop every change that landed between digests.

`DocumentAgent` keeps a `block_changes` table appended by the fragment
observer. The observer now maintains a set of the fragment's block ids ahead
of every guard it has, for two reasons: a deleted block's own attributes are
no longer readable from the Yjs event, so the set is the only place its id
survives; and a system write like a restore fires no event, which must not
read to a poller as the document holding still.

Rows are written only while an agent is on the roster, capped at 5,000, and
pruned with the document. A caller with no cursor, or one older than the rows
still kept, is told `truncated` and reads the whole document: there is no
baseline to diff against. Blocks in documents written before block ids have no
id to track and never appear.

`app/shared/block-changes.ts` holds the pure part, which is what the unit
tests exercise; the observer, the table, and the RPC are covered by the
DocumentAgent integration tests, including the deleted-block and system-write
cases that drove the design.

Closes #87

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alcor
alcor merged commit a45af34 into main Sep 14, 2026
3 checks passed
@alcor
alcor deleted the feat/87-read-changes branch September 14, 2026 04:14
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.

A since-cursor read: only the blocks that changed

1 participant