Skip to content

feat(docs): BlockNote version history on our snapshot storage, server-side live restore - #4632

Draft
Movm wants to merge 3 commits into
chore/blocknote-0.56from
feat/docs-blocknote-version-history
Draft

Movm wants to merge 3 commits into
chore/blocknote-0.56from
feat/docs-blocknote-version-history

Conversation

@Movm

@Movm Movm commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #4628 (BlockNote 0.56 upgrade). Review that one first. This PR targets its branch, so the Closes #4624 lives in the commit message (GitHub ignores body keywords while the base is not master).

Replaces our own docs version history UI with BlockNote's (TypeCellOS/BlockNote#3090), backed by the snapshots we already store. Restore moves to the server and works on the live document.

What changes for users

How restore works now

POST /docs/:id/snapshots/:version/restore with { apply: 'live' }:

  1. Reads the live state from Hocuspocus (GET /internal/doc/:id/yjs, falls back to Postgres there), so unsaved typing is included, and stores it as "Vor der Wiederherstellung".
  2. buildRestore (services/docs/snapshotRestore.ts) replaces the fragment's children with deep clones of the version's in one transaction. That is a forward edit, and it keeps block ids, attributes and text formats, comment marks included. Applying the old state alone reverts nothing. Yjs merges it, and the first test proves that trap.
  3. Sends only the diff to POST /internal/doc/:id/yjs. Hocuspocus applies it and broadcasts it to every connected client.
  4. Stores the result as "Wiederhergestellt von vN".

The old server overwrite of yjs_document_updates with the old state is gone (#4624 part 2). Tabs loaded before this deploy still call the endpoint without apply. They get step 1 only and then apply the version themselves as before. No data path depends on them.

Version preview vs. AI review

BlockNote's version view forks the doc exactly like the AI review. Without a gate, our review bar would have offered "Übernehmen" on a version preview, which merges the preview into the live document. isAIReviewFork (forked and the versioning store not in versions mode) now drives useDocAIReviewState, usePendingDocAI and isDocAIForked. Both stores change in the same synchronous open() call, so the hooks never see half a transition.

Verification

  • packages/docs: 72/72 tests (new: snapshotVersionStorage.vitest.ts), apps/api: docSnapshotsContractRouter.vitest.ts 9/9. apps/api: snapshotRestore.vitest.ts 4/4 (revert after later edits, ids and formats kept, comment threads untouched, a collaborator converges) plus trashReaders.vitest.ts. tsc clean for docs/web/api, check:internal green.
  • Browser, dev bypass, isolated stack (own empty Postgres DB, own Hocuspocus, Qdrant pointed away from prod):
    • named "Stand A", changed the doc to B, previewed A (read-only, no review bar), restored A from the row menu.
    • Live doc block-for-block identical to A, same block ids. History shows "Vor der Wiederherstellung" + "Wiederhergestellt von v2".
    • Restored "Vor der Wiederherstellung": B came back exactly, and the open editor received it live.
    • Reload: identical from Postgres. Editing and undo work after closing the sidebar. No console errors.

Contract (second commit)

The snapshot endpoints are now docSnapshotsContract (Zod schemas in @gruenerator/contracts, ts-rest router docSnapshotsContractRouter.ts). The web side calls them through getContractsClient().docSnapshots. Paths and bodies stay the same, so old tabs keep working.

  • Access fix: write access now uses checkDocumentWriteAccess, the mirror of the Hocuspocus connection check. The old snapshot-only check left out editors who come through an editor share link or a group share with write: they could edit the document but could not save or restore versions. Restore writes around Hocuspocus auth through its internal API, so it has to apply exactly that rule.
  • Row types derive from the Drizzle table. Timestamps go out as ISO strings. The routes keep the authenticatedReadLimiter they used to inherit from docsRouter.
  • docSnapshotsContractRouter.vitest.ts (7 tests) pins the order (backup before apply), no restore without a readable live state, the legacy path (backup only, no yjs_document_updates write), and 403/404/401 before anything is touched.

Paging (third commit)

The list used to return every snapshot in one response. It now pages newest first on a version keyset (limit, default 50, max 100; cursor) and returns next_cursor, which BlockNote's sidebar follows on scroll or "load more". BlockNote merges pages by id.

  • A page reads on until the entry after it has begun, so an auto-save group at a page end is counted in full. A long editing session (one auto-save every 5 minutes) collapses into one entry and can span several reads.
  • Postgres check on a throwaway DB: the query walks yjs_document_snapshots_document_id_version_key backwards (Index Scan Backward, version <= $2), so no new index is needed. A NULL cursor reads the first page.
  • docSnapshotsContractRouter.vitest.ts is now 9 tests. The boundary test fails against a naive one-query-per-page version (red-probed). snapshotVersionStorage.vitest.ts passes the cursor through and ends the history on next_cursor: null.
  • Old tabs ignore next_cursor and see the newest 50 entries. Restore and preview work by version, so nothing depends on the full list.

BlockNote 0.56 ships a version history that runs on the Yjs 13 binding
we use (YjsVersioningExtension + VersioningSidebar). Its storage
interface takes raw Yjs updates, which is what yjs_document_snapshots
already stores. VersionHistory.tsx and the HTML preview/restore path go.

Restore now happens on the server against the live document:
- The current content is saved first as "Vor der Wiederherstellung",
  read from Hocuspocus so unsaved typing is included.
- The version is applied as a forward edit. The live fragment's
  children are replaced by deep clones of the version's, which keeps
  block ids and comment marks. Applying the old state alone reverts
  nothing, because Yjs merges it.
- Hocuspocus broadcasts the change to every connected client.
- The old overwrite of yjs_document_updates with the old state is gone.
  Tabs from before this change still get the safe half: the before-
  restore snapshot, then their own client-side apply.

New endpoints: GET /snapshots/:version/content (raw update) and
PUT /snapshots/:version (rename). The list also returns created_by,
for author avatars. There is no delete, because a snapshot can be the
base a document loads from.

BlockNote's version view forks the doc like the AI review does.
useDocAIReviewState and usePendingDocAI now tell the two apart, so
the "Übernehmen" bar can no longer merge a version preview into the
live document.

Closes #4624
@github-actions

Copy link
Copy Markdown
Contributor

📝 Docs-Freshness-Check

⚠️ Diese Änderung betrifft 1 Doku-Artikel, die evtl. veraltet sind. Bitte prüfen und ggf. anpassen — dieser Kommentar blockiert den Merge nicht.

apps/web/src/features/tours/workplaceTour.ts — 2 mögliche Abweichung(en)

1. The Arbeiten tools tile row lists a tile labeled 'Agentura' among Office, Studio, Wissen, Projekte (medium)

  • Doku sagt: Die Kacheln führen in alle Bereiche: Agentura, Office, Studio, Wissen und Projekte — unter „Weitere“ liegen Extras wie Scanner oder Transkription.
  • Code zeigt: apps/web/src/config/workplaceToolsConfig.ts:234-240 defines the tile with id 'agents' and title 'Grüneratoren' (path /grueneratoren), not 'Agentura'; 'Agentura' only survives as an internal/dev code name and as the title of the market popup reachable from inside that page (apps/web/src/features/agentura/AgenturaPage.tsx:20, AgenturaDialog.tsx:184).
  • Vorschlag: Replace 'Agentura' with 'Grüneratoren' in the tour description, since that is the label actually shown on the tile.

2. The sidebar nav has a 'Neu' control that starts chats, documents or boards from anywhere (medium)

  • Doku sagt: Suche (⌘K), deine Grüneratoren und Projekte als Schnellzugriff — und über „Neu“ startest du von überall Chats, Dokumente oder Boards.
  • Code zeigt: apps/web/src/components/layout/Sidebar/Sidebar.tsx:398-448 (the [data-tour="sidebar-nav"] element) renders only the Workplace link, the search button, direct menu items and ProjekteSidebarSection; the only 'Neu'-style action is 'Neuer Chat in diesem Projekt' scoped to one project (apps/web/src/components/layout/Sidebar/ProjekteSidebarSection.tsx:128-129) — there is no general 'Neu' entry point for starting a chat, document or board from anywhere.
  • Vorschlag: Drop the 'Neu' claim or rephrase to describe the per-project 'Neuer Chat' shortcut inside the Projekte popup, since there is no app-wide 'Neu' launcher in the sidebar.

KI-generiert von check-docs-freshness.ts am 2026-10-16. Findings sind automatisch erzeugt — vor dem Übernehmen verifizieren.

@Movm
Movm added this pull request to stack #4633 October 16, 2026 00:15
Movm added 2 commits October 16, 2026 02:16
The snapshot endpoints were a plain Express router with hand-written
row types and their own access checks. They now have
docSnapshotsContract (Zod schemas in @gruenerator/contracts) and a
ts-rest router. The web side calls them through getContractsClient()
and narrows each answer by status instead of catching exceptions.

- Write access now uses checkDocumentWriteAccess, the mirror of the
  Hocuspocus connection check. The old snapshot-only check left out
  editors who come through an editor share link or a group share with
  write. They could edit the document but could not save or restore
  versions. Restore writes around Hocuspocus auth via its internal
  API, so it has to apply exactly the Hocuspocus rule.
- Read access uses checkDocumentAccess like the rest of /api/docs.
- Row types derive from the Drizzle table (YjsDocumentSnapshotRow).
- Timestamps go out as ISO strings, and the next version number is
  taken in the same INSERT statement.
- The routes keep authenticatedReadLimiter, which they used to inherit
  from docsRouter.

No change on the wire for existing callers: same paths and bodies.
Tabs that still restore without `apply` keep working.
The list returned every snapshot of a document in one response. It now
pages newest first with a version keyset (limit 50, max 100) and returns
next_cursor, which BlockNote's sidebar follows on scroll. A page reads on
until the entry after it has begun, so an auto-save group at a page end is
counted in full. The query walks the (document_id, version) unique index.

This branch has not been deployed

No deployments
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