You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Replace AgentNote's whole-document last-write-wins note body sync with a Yjs CRDT document model bound to CodeMirror 6 via y-codemirror.next, persisted in the existing Postgres as an append-only update log plus periodic snapshots, and synced over plain HTTP route handlers first (no new infrastructure), with a WebSocket transport as a drop-in Phase 2.
notes.body stays in the schema as a server-derived plaintext projection of the CRDT, so publish//p/..., note_revisions, title derivation, archive, and the sidebar preview keep working with zero changes.
Goal: make silent body truncation structurally impossible rather than defended against by an ever-growing set of guards (#51, #53, #57), and put true concurrent editing (same user across tabs/devices now, multiple users later) on the table without a rewrite.
Why This Matters
Body-loss incidents keep recurring on this repo despite three rounds of hardening:
Every one of those fixes is correct, and the current code on main is genuinely careful. But they all share one root cause: the unit of synchronization is the entire note body string. With a whole-document write, "who wins" is a policy decision, and every policy has a losing side. lib/remote-apply-guard.ts now holds six separate predicates (canApplyRemoteBody, isRemoteNoteNewer, shouldMarkSavedAfterPersist, canAdvanceBaseWithoutAdopting, isDraftBaseCurrent, shouldAcceptDraftSeq) whose only job is to decide which whole body to discard. Under OCC the loss mode changed shape but did not disappear — it became "409 → user must choose", i.e. one side's typing is still thrown away.
A CRDT changes the unit of synchronization to operations on a shared text. Concurrent inserts/deletes from two tabs both survive and merge deterministically. There is no "stale writer", no generation token to get wrong, and no discard prompt. This is the same model Obsidian Sync, Linear, and Figma converged on, and the reason Yjs exists.
Secondary benefits that matter for this specific product:
Offline editing becomes possible (y-indexeddb) — today an offline edit is a pending PUT that will 409.
The agentnote premise ("sits next to the agent tab") implies the same note is open in several places at once. That is exactly the workload LWW is worst at.
Multi-user collaboration later is an ACL change, not a rewrite.
Conversation Context
Condensed from the design conversation that produced this issue:
The operator is the primary (currently only) user. Real workload: one human, many tabs and devices, not many humans. Multi-user is a future requirement — the design must not preclude it, but v1 must not build a Figma-style multiplayer UI.
Prior research/drafts on this topic were lost twice. This issue is deliberately written to be self-contained: an implementing agent should never need the chat.
Explicit ask: research best-practice code (real repos, real packages, real API shapes), not blog-level hand-waving, and compare (A) stronger OCC, (B) field-level / 3-way text merge, (C) CRDT — then pick one.
Explicit ask: phased rollout. Phase 1 must stop the data loss on its own and be shippable without new infrastructure.
Hard constraint from the operator: do not boil the ocean. No presence avatars, no comments, no multiplayer UX in v1.
Deployment reality: Next.js App Router 16 on Vercel, Postgres (pg) hosted on Railway, Clerk for auth (agentnote Clerk app, GitHub OAuth). Any transport choice must respect that Vercel functions are not a durable WebSocket hub (see Source Of Truth).
All paths below are on origin/main as of commit 247aa89 ("fix: optimistic concurrency + buffer guards to stop stale-tab truncation (#57) (#58)"). A local checkout may be dozens of commits behind — read from origin/main.
Data model (lib/db.ts → ensureSchema())
CREATETABLEIF NOT EXISTS notes (
id TEXTPRIMARY KEY, -- Meet-style "abc-defg-hij"
user_id TEXTNOT NULL, -- Clerk user id
title TEXTNOT NULL DEFAULT '',
body TEXTNOT NULL DEFAULT '', -- <-- the whole-document field
created_at TIMESTAMPTZNOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZNOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ, -- soft delete (Archived)
is_public BOOLEANNOT NULL DEFAULT FALSE,
public_id TEXT,
published_at TIMESTAMPTZ,
author_handle TEXT
);
CREATETABLEIF NOT EXISTS note_aliases (
alias TEXTPRIMARY KEY,
note_id TEXTNOT NULLREFERENCES notes(id) ONUPDATE CASCADE ON DELETE CASCADE
);
-- Recovery net from #52, 30-day retention, 60s coalesce windowCREATETABLEIF NOT EXISTS note_revisions (
id BIGSERIALPRIMARY KEY,
note_id TEXTNOT NULLREFERENCES notes(id) ONUPDATE CASCADE ON DELETE CASCADE,
user_id TEXTNOT NULL,
title TEXTNOT NULL,
body TEXTNOT NULL,
created_at TIMESTAMPTZNOT NULL DEFAULT NOW()
);
notes.updated_at doubles as the optimistic-concurrency token.
Write path — PUT /api/notes/[id] (app/api/notes/[id]/route.ts → lib/notes.ts::updateNote)
expected_updated_at is required; a missing token is a hard 400 ("fail closed: old clients without a concurrency token must not LWW-write").
The guard compares millisecond epochs, using FLOOR(EXTRACT(EPOCH FROM updated_at) * 1000)::bigint — FLOOR, not a rounding ::bigint cast, because clients send ms truncated by Date#toISOString() and half-up rounding would false-409 roughly half of all notes.
The write is a single CTE (WITH prev AS (SELECT ...), updated AS (UPDATE ... FROM prev WHERE ... AND FLOOR(...)= $5)) so a concurrent save cannot interleave between the read of the previous body and the overwrite. The FLOOR gate is repeated on the UPDATE row because under READ COMMITTED the EvalPlanQual recheck would otherwise let two same-token writers both win after the CTE snapshot.
lastAckedBodyRef — body from the last successful server ack or clean open.
baseUpdatedAtRef — the generation the buffer was built on, deliberately not the newest list row. persist() sends this as expected_updated_at. It advances only when the buffer adopts server state.
saveSeqRef / isCurrentSaveAttempt() — drops stale persist results so an older body can never land after a newer save.
persistInFlightRef — flushPendingSave() awaits it, because a second same-token PUT races into a self-409.
A draft carries the entire body string. Receivers gate it through isDraftBaseCurrent() (missing base fails closed) and shouldAcceptDraftSeq().
Apply guards (lib/remote-apply-guard.ts)
Predicate
Rule
canApplyRemoteBody
local unsaved work always wins — dirty/saving/error all refuse, and forceBody is explicitly ignored (void opts?.forceBody). Additionally refuses when localBody !== lastAckedBody even in saved state.
isRemoteNoteNewer
strict > on updated_at — equal timestamps keep local, so client-clock drafts cannot win a tie.
shouldMarkSavedAfterPersist
only mark saved if the live buffer still equals what was persisted.
canAdvanceBaseWithoutAdopting
a refused remote body must not advance baseUpdatedAtRef — otherwise a stale buffer gets a valid ticket (the exact #57 bug). Only body-neutral bumps (publish/unpublish/restore) may advance it.
isDraftBaseCurrent
peer draft applies only when generations match; undefined base fails closed.
shouldAcceptDraftSeq
ignore out-of-order peer drafts.
noteAfterConflictKeepLocalBuffer
on 409, rebase the token but keep the local buffer so autosave does not auto-PUT the stale short body.
shouldAutoRetrySave() deliberately returns false for conflict: "Conflict must not auto-retry — rebasing + PUT would recreate LWW loss." The user is left in error state with a manual Retry.
CM6 with @codemirror/lang-markdown, custom Enter list continuation, Tab list indent, strikethrough, arrow substitution, inline image widgets, indentedLineWrapping(), scrollPastEnd().
The editor is controlled by a value: string prop. Parent remounts it with key={noteId}.
External values go through applyExternalValue(view, next), a Zed Buffer::apply_diff analogue:
minimalChange() computes one common-prefix/suffix-trimmed replace range, snapping off UTF-16 surrogate-pair interiors;
returns false (skip) when the local selection intersects the remote hunk (sel.from <= to && sel.to >= from);
returns false while view.composing (IME);
dispatches with scrollIntoView: false and no explicit selection so ChangeSet mapping preserves caret + scroll.
On skip, onExternalReconcile syncs React from CM without marking dirty (reconcileBodyFromEditor sets skipNextSave.current = true).
No merge, anywhere. Every sync path picks one whole body and discards the other.
No operation history on the wire.draft and upsert carry final strings, so intent (insert "x" at offset 40) is unrecoverable.
No server push. 1.5s poll only, and only while the tab is visible. A background tab is arbitrarily stale.
No offline story. An offline edit becomes a failed PUT; on reconnect its token is stale → 409 → manual choice.
No presence. No cursors, no "editing elsewhere" indicator.
Conflicts are user-visible losses.saveErrorKind === "conflict" requires a human to pick a side.
Cross-device is strictly worse than cross-tab. No BroadcastChannel between devices — only the 1.5s poll, and applyRemoteNote refuses to adopt whenever the local tab is dirty, so two devices editing the same note diverge until one gives up its work.
Desired Behavior
After this issue:
A note's body is a Yjs Y.Text. Concurrent edits from any number of tabs/devices converge to a single deterministic document. No edit is ever discarded to resolve a conflict.
Typing in tab A appears in tab B within ~1 frame (BroadcastChannel) and on another device within one sync round-trip (~1.5s in Phase 1, <100ms in Phase 2).
Editing the same note on two devices simultaneously merges character-level. There is no 409, no discard prompt, and no "unsaved changes" modal for the body path.
notes.body remains a correct plaintext mirror of the CRDT at all times (eventually consistent within one sync round), so /p/{handle}/{id}, note_revisions, sidebar previews, and derived titles are unchanged.
Reloading mid-edit loses nothing: local state is in IndexedDB, and unsynced updates flush on reconnect.
The editor stops being a controlled value: string component for CRDT-backed notes; CM6 is driven by yCollab, which preserves caret/scroll/IME by construction — making applyExternalValue's heuristics unnecessary on that path.
Rollout is gated by a flag so the legacy OCC path remains available for one release.
Source Of Truth
Internal repo/source — read these first
Path
Why it matters
components/agentnote-app.tsx
persist(), pullFromServer(), applyRemoteNote(), applyRemoteDraft(), broadcastDraft(), flushPendingSave(), ensureSafeToLeaveActive(), POLL_MS, autosave 400. The whole body sync state machine lives here.
components/codemirror-editor.tsx
CM6 construction, editorExtensions(), the value → applyExternalValue effect, applyingExternal guard, key={noteId} remount contract.
lib/editor/apply-external.ts
minimalChange() + selection/IME skip semantics being replaced on the CRDT path.
lib/remote-apply-guard.ts
The six discard predicates. Their tests encode the #51/#57 invariants — do not delete them while the legacy path exists.
lib/save-failure.ts
SAVE_RETRY_BACKOFF_MS, classifySaveHttpStatus, shouldAutoRetrySave (conflict must not auto-retry), isCurrentSaveAttempt.
lib/tab-sync.ts
openSyncChannel(), syncChannelName(userId), the SyncMessage union to extend.
lib/notes.ts
updateNote() CTE with the FLOOR(EXTRACT(EPOCH ...)) OCC gate; recordPreviousBodyRevision(); resolveCanonicalNoteId() (aliases!).
lib/db.ts
ensureSchema() — idempotent, runs at query time behind global.__agentnoteSchemaReady. New tables go here, same style.
app/api/notes/[id]/route.ts
PUT handler, expected_updated_at 400/409 contract.
app/api/notes/route.ts
GET list / POST create.
lib/require-user.ts
requireUserId() → `{ userId }
proxy.ts
clerkMiddleware; public matcher list (/login(.*), /p(.*), /api/version, /api/cron(.*)). New /api/notes/** routes are protected by default.
lib/types.ts
Note / PublicNote shapes returned to the client.
vercel.json
Existing cron entries — the compaction cron goes here.
Key property: updates are commutative, associative, and idempotent — apply in any order, any number of times. This is the entire reason the truncation class of bug disappears.
Caveat from the docs: mergeUpdates does not garbage-collect deleted content; only a full Y.Doc load + re-encode shrinks a document meaningfully.
Version note: the repo's main branch is the unstable @y/codemirror line targeting Yjs v14 (@y/y). Stay on the published y-codemirror.next + yjs@13.x for this issue. The README example on GitHub shows import * as Y from '@y/y' — that is the v14 branch; use from 'yjs'.
yCollab installs its own undo/redo. It must replace, not stack on, @codemirror/commands' history()/historyKeymap for CRDT-backed notes, or ⌘Z will fight itself. See Edge Cases.
Persistence reference implementations (read these, they solve exactly this problem)
y-postgresql — https://github.com/MaxNoetzold/y-postgresql — npm y-postgresql. API: PostgresqlPersistence.build(connectionOptions, persistenceOptions), then getYDoc(docName), storeUpdate(docName, update), getStateVector(docName), getDiff(docName, stateVector), clearDocument(docName), destroy(). Stores individual updates and merges them into one document after a threshold — default 200 transactions. Implements the y-websocket persistence interface { bindState, writeState }: bindState subscribes to doc.on('update') and stores incrementally; writeState persists when the last client disconnects. This is the closest existing analogue to what this issue builds — use it as a design reference even if not adopted as a dependency.
Hocuspocus Database extension — https://tiptap.dev/docs/hocuspocus/server/extensions/database — npm @hocuspocus/extension-database@4.4.0. fetch({ documentName }) => Promise<Uint8Array | null> and store({ documentName, state }) => Promise<void>. Documented pitfall, applies to us too:"Make sure to return the same Uint8Array that was saved in store(), and do not create a new Ydoc, as doing so would lead to a new history (duplicated content)."
Transport options evaluated
Vercel native WebSocket — https://vercel.com/changelog/websocket-support-is-now-in-public-beta — public beta since 2026-06-22, runs on Fluid compute with Node (ws / Socket.IO work; standard upgrade triggers a function invocation that stays pinned to the connection). Billing is Active-CPU, so idle connections are cheap. But: connections are pinned to one instance for its max duration, future connections are not guaranteed to land on the same instance, and there is no built-in cross-instance broadcast. A Yjs room needs all peers of a document on one authority — so Vercel WS alone is not a sufficient Yjs hub today. Vercel's own KB still points collaborative apps at Ably / Liveblocks / PartyKit / Pusher / Supabase. (Needs verification before Phase 2: whether the beta has since gained a documented affinity/broadcast primitive.)
Hocuspocus — https://github.com/ueberdosis/hocuspocus — npm @hocuspocus/server@4.4.0. Long-lived Node WebSocket server for Yjs. Auth via the onAuthenticate({ documentName, token, requestHeaders, ... }) hook: validate a token (e.g. a Clerk JWT), throw to reject, return a context object consumed by later hooks (beforeHandleMessage, onStoreDocument); connection.readOnly supports read-only grants. Deployable next to the existing Railway Postgres.
y-sweet — https://github.com/jamsocket/y-sweet — Yjs document store with S3-compatible persistence + auth; self-host or Jamsocket. (Note: issue #203 in that repo reports high Cloudflare Worker runtime cost for the Worker deployment — evaluate before adopting.)
Liveblocks / TipTap Collab — fully managed Yjs backends with CodeMirror guides. Fastest path, but adds a vendor and a per-seat cost for what is currently a one-user app.
y-websocket@3.0.0 — https://github.com/yjs/y-websocket — the reference provider/server; fine for local dev and for a Railway-hosted node.
Algorithm background for the A/B/C comparison
OT vs CRDT: OT needs a central, reliable server and keeps documents lean; CRDTs carry per-character metadata (a naive text CRDT adds ~16–32 bytes/char) but work offline and peer-to-peer. Google Docs uses OT; Figma moved to CRDTs in 2019; Obsidian/Linear/Yjs-based apps are CRDT. Yjs specifically uses YATA with an optimized run-length item representation, so real-world markdown notes do not hit the naive 32×-blowup figure — but the update log does grow monotonically, which is why compaction is mandatory (see Implementation Notes).
Automerge / automerge-repo — https://github.com/automerge/automerge — mature JSON CRDT with a sync-server story; strong for structured docs, but there is no first-party CodeMirror 6 binding as mature as y-codemirror.next, and it brings a WASM payload.
Loro — https://github.com/loro-dev/loro — Rust/WASM CRDT with excellent memory + load performance and a rich text type. Promising, but the CM6 ecosystem, provider ecosystem, and production track record are thinner than Yjs's. (Needs verification: current state of an official Loro CodeMirror 6 binding.)
Proposed API / Schema
DB migration (append to ensureSchema() in lib/db.ts, same idempotent style)
-- Append-only CRDT update log. Never UPDATEd, only INSERTed and (post-compaction) DELETEd.CREATETABLEIF NOT EXISTS note_doc_updates (
seq BIGSERIALPRIMARY KEY,
note_id TEXTNOT NULLREFERENCES notes(id) ONUPDATE CASCADE ON DELETE CASCADE,
user_id TEXTNOT NULL,
update_bin BYTEANOT NULL,
created_at TIMESTAMPTZNOT NULL DEFAULT NOW()
);
CREATEINDEXIF NOT EXISTS note_doc_updates_note_seq_idx
ON note_doc_updates (note_id, seq);
-- Compacted state. One row per CRDT-backed note.CREATETABLEIF NOT EXISTS note_doc_snapshots (
note_id TEXTPRIMARY KEYREFERENCES notes(id) ONUPDATE CASCADE ON DELETE CASCADE,
user_id TEXTNOT NULL,
state_bin BYTEANOT NULL, -- Y.encodeStateAsUpdate(ydoc)
state_vector BYTEANOT NULL, -- Y.encodeStateVector(ydoc)
through_seq BIGINTNOT NULL DEFAULT 0, -- highest note_doc_updates.seq folded in
updated_at TIMESTAMPTZNOT NULL DEFAULT NOW()
);
Notes on the shape:
ON DELETE CASCADE mirrors note_revisions, so permanent delete and archive purge clean up automatically.
update_bin / state_bin avoid the reserved-ish word update as a column name.
through_seq is what makes "snapshot + tail" reads cheap and compaction safe.
No new user-facing columns on notes.notes.body keeps its meaning; it just gains a new writer.
GET /api/notes/[id]/doc
Returns the full document state, used on first open when the client has no local state.
update = base64 of Y.encodeStateAsUpdate(ydoc) where ydoc = snapshot + all updates with seq > through_seq.
seq = highest note_doc_updates.seq folded into update (or through_seq when the tail is empty). The client passes it back as a since cursor.
created: true when this call performed first-time seeding from notes.body (see Flow step 2).
404 when resolveCanonicalNoteId(userId, id) returns null — same not-found semantics and no existence leak as the current routes.
POST /api/notes/[id]/doc/sync — the workhorse (one round trip = push + pull)
Request:
{
"update": "AQHT4a...base64, optional — omit for a pull-only poll",
"state_vector": "AQEB...base64, optional — omit to receive nothing back",
"since": 4217
}
Response 200:
{
"seq": 4231,
"update": "AQKz...base64 of the diff the client is missing, or null",
"body": "# Note title\n\nprojected plaintext…",
"updated_at": "2026-07-31T09:14:02.511Z"
}
Semantics:
If update is present → append one row to note_doc_updates, then recompute the projection (see Flow).
If state_vector is present → reply with Y.encodeStateAsUpdate(serverDoc, sv); null when the client is already current. since is an optimization: when since === current head seq and no local update was sent, the server may skip loading the doc entirely and return { seq, update: null }.
body + updated_at are returned so the client can refresh the sidebar row without a second GET /api/notes.
This single endpoint is deliberately syncStep1 + update in, syncStep2 out — the y-protocols shape, over HTTP.
GET /api/notes/[id]/doc/stream (Phase 1.5, optional)
text/event-stream SSE emitting data: {"seq":4231,"update":"<base64>"} per new update. Cuts perceived cross-device latency without a WebSocket. Needs verification: Vercel Fluid compute max function duration for a long-lived SSE response and whether Postgres LISTEN/NOTIFY through the existing pgPool is workable there, or whether a short-lived (~50s) SSE with client reconnect is the pragmatic shape.
PUT /api/notes/[id] — unchanged wire contract, narrowed meaning
Still requires expected_updated_at; still returns 409 { error, note }.
New rule: when a note_doc_snapshots row exists for the note (i.e. it is CRDT-backed) and the request carries a body, respond 409 with a machine-readable reason so a stale legacy client cannot LWW over the CRDT:
|{/** Binary Yjs update for a note, mirrored to peer tabs instantly. */
type: "doc-update";
sourceId: string;
id: string;
update: Uint8Array;// structured-clone handles this natively}
The existing draft message stays for the legacy path and is not sent for CRDT-backed notes.
Validation rules
update / state_vector: base64; reject payloads over 1 MiB decoded with 413. A single keystroke update is tens of bytes; anything larger is a bug or an attack.
Every route: requireUserId() first, then resolveCanonicalNoteId(userId, id) — never trust the path id. Aliases must resolve, matching getNote.
note_doc_updates.user_id is set server-side from Clerk, never from the payload.
A malformed update (Y.applyUpdate throws) → 400 { "error": "Invalid update" }, and the row is not appended. Never persist an update the server could not apply.
Backward compatibility: clients without the flag keep using PUT with expected_updated_at and are unaffected until their notes are seeded.
Implementation Notes
Recommended architecture — and why, versus the alternatives
Replaces timestamp tokens with a monotonic generation BIGINT, removing the FLOOR(EXTRACT(EPOCH ...)) ms-truncation hazard. Cheap.
Does not merge. Every conflict is still someone's typing thrown away. #57 already proved that adding guards converts one loss mode into another. Two devices editing one note remain mutually exclusive. This is polish on the wrong abstraction.
(B) Field-level / 3-way text merge (diff3, diff-match-patch) server-side
Genuinely merges non-overlapping edits, no client rewrite, no new deps on the editor.
3-way merge needs a common ancestor the server does not keep (only the previous body, coalesced at 60s). Merge results are heuristic — silent mis-merges in markdown lists/tables are worse than a visible conflict. Overlapping edits still need a policy. And it produces no operation stream, so realtime and offline stay out of reach. It is strictly a worse CRDT.
(C) Yjs CRDT ✅
Concurrent edits merge by construction; conflicts stop existing as a category; realtime, offline, presence, and multi-user all become incremental additions on the same substrate; yCollab preserves caret/scroll/IME natively, retiring the applyExternalValue heuristics on that path.
Real cost: a binary document model beside the plaintext one, monotonic log growth needing compaction, yjs on both client and server, and undo/redo must move to Y.UndoManager. All bounded and addressed below.
The decisive point for this product: AgentNote already pays the complexity cost of a CRDT (six discard predicates, three generation refs, a per-peer sequence map, a conflict UX) while getting none of the benefit. Switching substrate deletes more code than it adds.
Two choices keep the blast radius small:
notes.body becomes a derived projection, not a second source of truth. Publish, revisions, previews, titles, archive, and the public route keep reading the exact column they read today. Nothing downstream of the editor changes.
Phase 1 ships over the existing HTTP + poll transport. No WebSocket server, no vendor, no new deploy target. The correctness win (no truncation) lands first and independently; latency is a separate, later, purely-transport change.
Phased rollout
Phase 1 — Stop the data loss (the MVP; ship this alone if nothing else lands).
Yjs doc model, y-codemirror.next binding, note_doc_updates + note_doc_snapshots, GET /doc + POST /doc/sync, poll reuses the existing 1.5s cadence, BroadcastChannel carries binary updates, notes.body projection, compaction cron. Behind NEXT_PUBLIC_AGENTNOTE_CRDT. Outcome: truncation is structurally impossible; two tabs and two devices both merge.
Phase 1.5 — Local-first.y-indexeddb per note. Offline edits queue and flush on reconnect. Reload mid-edit loses nothing.
Phase 2 — True realtime transport. Swap the poll for a WebSocket provider. Recommended primary: Hocuspocus on Railway — a long-lived Node process beside the Postgres this project already runs there, with onAuthenticate verifying a Clerk JWT and @hocuspocus/extension-databasefetch/store pointed at the same tables. Alternative if a Cloudflare-shaped deploy is preferred: y-partyserver on Durable Objects. Only the provider changes — the document model, schema, and editor binding are already correct. Note Vercel's native WS beta cannot host the room today (no cross-instance broadcast).
Phase 3 — Optional multi-user. Add a note_members(note_id, user_id, role) table, relax the room ACL from notes.user_id = auth.userId to membership, and enable yCollab's awareness (remote cursors, yRemoteSelections) which is already wired in Phase 1 with a single local client. No rewrite: the room key is already note_id and isolation is already enforced per request.
Likely files to modify
package.json — add yjs@^13.6.31, y-codemirror.next@^0.3.5, y-protocols@^1.0.7, lib0@^0.2.117; Phase 1.5 adds y-indexeddb@^9.0.12. Pin yjs to a single version — two copies of Yjs in one bundle silently break sync; add a pnpm.overrides entry if the lockfile ever dedupes badly.
lib/db.ts — the two CREATE TABLE IF NOT EXISTS blocks above, inside the existing ensureSchema() IIFE, before migrateLegacyNoteIds(pool).
components/codemirror-editor.tsx — accept an optional ytext?: Y.Text + awareness?: Awareness. When present: seed EditorState.create({ doc: ytext.toString() }), append yCollab(ytext, awareness, { undoManager }), and drop history() / historyKeymap from the extension list (see Edge Cases). When absent: today's value/applyExternalValue path, byte-for-byte unchanged.
components/agentnote-app.tsx — the largest change. For CRDT-backed notes, retire persist()/broadcastDraft()/applyRemoteDraft()/applyRemoteNote()-for-body in favour of a doc-session hook. Keep all of it for the legacy path. saveState becomes a much simpler "synced" | "syncing" | "offline" on the CRDT path — no dirty, no error-with-conflict, no beforeunload prompt for the body.
lib/tab-sync.ts — add the doc-update variant to SyncMessage.
lib/notes.ts — add getNoteDocState, appendNoteDocUpdate, compactNoteDoc, projectNoteBodyFromDoc; add the crdt_managed_body guard to updateNote.
app/api/notes/[id]/route.ts — return the crdt_managed_body 409 when a snapshot row exists and a body was sent.
vercel.json — add { "path": "/api/cron/compact-note-docs", "schedule": "30 9 * * *" } alongside the two existing 09:00-ish crons.
README.md — document the flag, the new tables, and the recovery story.
.env.example — NEXT_PUBLIC_AGENTNOTE_CRDT=0.
New files
lib/crdt/note-doc.ts — pure, testable helpers: NOTE_TEXT_KEY = "body", seedDocFromPlaintext(body): Uint8Array, mergeUpdatesToState(updates): { state, stateVector }, docBodyFromState(state): string, shouldCompact({ updateCount, byteSize }). No pg, no fetch here — this is the unit-test surface.
lib/crdt/use-note-doc.ts — client hook owning one Y.Doc per active note: creates the doc, GET /doc on open, subscribes doc.on('update') → POST + BroadcastChannel, poll loop, applies inbound updates, exposes { ytext, awareness, syncState }. Must tag origins (Y.applyUpdate(doc, u, "remote")) so remote updates are not echoed back.
lib/crdt/sync-transport.ts — the HTTP transport behind a small interface (pull, push, subscribe?) so Phase 2 swaps in a WebSocket provider without touching the hook.
app/api/notes/[id]/doc/route.ts — GET full state.
app/api/notes/[id]/doc/sync/route.ts — POST push+pull.
app/api/cron/compact-note-docs/route.ts — mirror app/api/cron/purge-note-revisions/route.ts exactly, including the Bearer CRON_SECRET check (that path is public in proxy.ts, so the in-route check is the only gate).
Open a note.use-note-doc creates new Y.Doc(), calls GET /api/notes/:id/doc, Y.applyUpdate(doc, base64ToBytes(update), "remote"), records seq, hands ytext to CM6 via yCollab.
First-time seeding (server, exactly once per note). If no note_doc_snapshots row exists: build a doc, ydoc.getText("body").insert(0, note.body), encode, and INSERT ... ON CONFLICT (note_id) DO NOTHING, then re-read the row and return the winner's state. Do the insert only on the server. Seeding on the client — or on two servers without the ON CONFLICT guard — is the single most common Yjs bug and produces duplicated document content (the Hocuspocus docs call this out explicitly).
Local edit. CM6 dispatch → yCollab mutates ytext → doc.on('update') fires with origin === null (local). The handler (a) posts { type: "doc-update", update } on the BroadcastChannel immediately — peer tabs converge in ~1 frame — and (b) enqueues the update for the network. Debounce network pushes with the existing 400 ms autosave feel, merging queued updates with Y.mergeUpdates() into one POST.
Push.POST /api/notes/:id/doc/sync with { update, state_vector, since }. Server: requireUserId() → resolveCanonicalNoteId() → pg_advisory_xact_lock(hashtext($note_id)) → validate by applying to the loaded doc → INSERT INTO note_doc_updates → recompute body = ydoc.getText("body").toString() → UPDATE notes SET body = $1, title = <deriveTitle(body)>, updated_at = NOW() → reply with the diff the client is missing plus the fresh body/updated_at.
The advisory lock serializes the projection write only. The log append itself is commutative and needs no lock for correctness; the lock exists so two concurrent serverless invocations cannot write projections computed from different tail sets and leave notes.body behind the log.
Keep recordPreviousBodyRevision() on the projection write (still coalesced to 60 s) — note_revisions stays useful as a human-readable time machine even though CRDT recovery no longer depends on it.
Pull. The existing 1.5 s visible-tab poll calls the same endpoint with no update. Remote updates are applied with origin "remote", which yCollab renders without disturbing the local selection or scroll — no applyExternalValue heuristics, no IME special-case.
Peer tab receives doc-update.Y.applyUpdate(doc, update, "broadcast"). Because updates are idempotent and commutative, a tab that already has it via the network is unaffected. The draftSeq / isDraftBaseCurrent machinery is unnecessary here — out-of-order and duplicate delivery are both correct inputs to a CRDT.
Compaction (cron, daily). For each note with COUNT(note_doc_updates) > 200orSUM(octet_length(update_bin)) > 256 KiB: inside one transaction, take the advisory lock, load snapshot + tail, Y.encodeStateAsUpdate(doc) (a full doc load, which also drops deleted content — Y.mergeUpdates alone would not), UPSERT note_doc_snapshots with the new through_seq, then DELETE FROM note_doc_updates WHERE note_id = $1 AND seq <= $through_seq. Thresholds mirror y-postgresql's default of 200 transactions before auto-merge.
// Two docs from the same seed; each edits a different region while offline.consta=newY.Doc(),b=newY.Doc()Y.applyUpdate(a,seed);Y.applyUpdate(b,seed)a.getText('body').insert(0,'PREFIX ')// tab A prependsb.getText('body').insert(b.getText('body').length,'\nAPPENDED')// tab B appendsY.applyUpdate(a,Y.encodeStateAsUpdate(b))Y.applyUpdate(b,Y.encodeStateAsUpdate(a))expect(a.getText('body').toString()).toBe(b.getText('body').toString())expect(a.getText('body').toString()).toContain('PREFIX')expect(a.getText('body').toString()).toContain('APPENDED')// Neither side's text was discarded. Under the current PUT path, one is lost.
Out-of-order and duplicate update delivery both converge (asserts we can delete shouldAcceptDraftSeq).
note-doc.test.ts — seedDocFromPlaintext round-trips exactly, including CRLF, emoji/surrogate pairs (the apply-external.ts surrogate hazard), and the ASCII-arrow substitution output from lib/arrows.ts.
note-doc-store.test.ts — compaction preserves docBodyFromState byte-for-byte and monotonically reduces stored bytes; through_seq never regresses; deleting compacted rows loses nothing.
Seeding idempotency: two concurrent seed attempts produce exactly one snapshot row and no duplicated body text.
Route tests: PUT with a body on a CRDT-backed note → 409 crdt_managed_body; oversized update → 413; garbage update → 400and no row appended; another user's note id → 404.
Keep every existing test in lib/remote-apply-guard.test.ts and lib/save-failure.test.ts green — the legacy path is still live behind the flag.
Manual smoke: pnpm test, pnpm lint, pnpm build.
Edge Cases And Risks
Undo/redo double-history.yCollab's undoManager and @codemirror/commands' history() both bind ⌘Z. On CRDT-backed notes, remove history()/historyKeymap and use yUndoManagerKeymap + new Y.UndoManager(ytext). Y.UndoManager is per-client by design (you undo your edits, not a peer's) — that is the desired behavior, but it is a behavior change the operator will notice.
Two copies of Yjs. The classic silent failure: client and server, or two bundles, resolving different yjs instances → updates apply but nothing converges. Pin one version; verify with pnpm why yjs.
Seeding twice = duplicated content. Covered by server-only seeding + ON CONFLICT DO NOTHING + re-read. Explicitly warned about in the Hocuspocus docs. Must have a test.
Log growth. Updates are append-only and Y.mergeUpdates does not GC deleted content. Without the compaction cron a heavily-edited note's log grows without bound. Compaction must do a full doc load, not just mergeUpdates. Also add an octet_length guard so one runaway note cannot blow up the row size.
Projection lag.notes.body is eventually consistent with the log. If a /p/... read races a sync POST it may serve a body one update stale. Acceptable (today's poll is 1.5 s stale anyway) — but the projection write must never be skipped on the "no diff to return" path.
Serverless cold Y.Doc cost. Every /doc/sync POST loads snapshot + tail and re-encodes. With aggressive compaction this is a few KB and sub-millisecond, but it is real per-request CPU on Fluid pricing. Mitigation: the since === head fast path that skips the doc load entirely for no-op polls. Needs verification: measure p95 for a ~100 KB note before enabling the flag for daily use.
resolveCanonicalNoteId and aliases.note_doc_* rows key on the canonical id. The id-migration path in lib/db.ts::migrateLegacyNoteIds rewrites notes.id — the ON UPDATE CASCADE on both new FKs handles it, but verify against a note that still has an alias row.
Archive / permanent delete / purge.ON DELETE CASCADE drops doc rows with the note. Restore-from-archive must not resurrect a stale snapshot over a newer one — it cannot, since archive does not touch doc rows, but add a test.
Publish path./p/{handle}/{id} reads notes.body via getPublicNote() with no auth. Unchanged — but confirm the projection write fires before published_at is set on a first publish.
Tenant isolation. Every doc route must resolve the id through userId. A room key of note_id alone would let a leaked id read another user's CRDT. Phase 2's onAuthenticate must verify the Clerk JWT and re-check ownership per documentName — a valid token for user A must not open user B's room.
Payload abuse. Cap decoded updates at 1 MiB and reject unparseable ones before insert; otherwise the log is an unauthenticated-shaped write amplifier for an authenticated user.
IME / composition.yCollab handles remote application through CM6 transactions, so the view.composing bail-out in apply-external.ts is not needed on this path. Still smoke-test Korean IME explicitly — this is the operator's primary input method and the historical source of editor bugs here.
Image widgets, arrow substitution, list continuation. These are CM6 extensions operating on the doc; they compose with yCollab (which is also just an extension). Regression-test all three, especially lib/editor/paste-images.ts and arrowPasteFilter(), which dispatch programmatic changes.
Flag flip-back. Once a note is seeded, the legacy PUT body path returns crdt_managed_body. Turning the flag off strands those notes read-only for body edits. Either keep a documented un-seed script (snapshot → notes.body, delete doc rows) or accept the flag as one-way and say so in the README.
Vercel WebSocket beta is a trap for Phase 2. It looks like the obvious answer and is not: pinned instances with no cross-instance broadcast cannot host a Yjs room. Re-verify before committing to it.
Non-Goals
No multi-user collaboration UI in v1. No avatars, no name badges, no follow-mode, no comments. Awareness is wired but carries a single local client.
No remote cursor rendering in Phase 1.yRemoteSelections stays off until Phase 3.
No WebSocket server in Phase 1. Reuse HTTP + the existing poll. Phase 2 is a separate PR and a separate deploy decision.
No managed vendor (Liveblocks / TipTap Collab / Ably) unless Phase 2 self-hosting is explicitly rejected.
pnpm dev with NEXT_PUBLIC_AGENTNOTE_CRDT=1. Open the same note in two tabs. Type in both simultaneously in different paragraphs. Expect: both texts present in both tabs within ~1 frame; no error indicator; no confirm dialog.
Two devices (laptop + phone via the deployed preview, same Clerk user). Edit both at once. Expect convergence after one poll cycle, and notes.body in Postgres matching what both screens show.
Offline: DevTools → Offline, type, go back online. Expect the buffered updates to flush and merge (Phase 1 queues in memory; Phase 1.5 survives reload).
Mid-edit reload with unsaved text — Phase 1.5 acceptance.
Publish the note, open /p/{handle}/{id} in a private window, confirm the body matches after a sync round.
Archive → restore → permanent delete; confirm note_doc_* rows cascade (SELECT COUNT(*) FROM note_doc_updates WHERE note_id = '<id>' → 0). Read-only verification on a scratch note only.
Trigger /api/cron/compact-note-docs with the CRON_SECRET bearer on a scratch note with 300+ updates; verify byte reduction and identical body.
Set NEXT_PUBLIC_AGENTNOTE_CRDT=0; confirm legacy notes still save via PUT and every remote-apply-guard test stays green.
Korean IME: type a long Hangul composition in tab A while tab B pushes updates. Expect no composition break and no character loss.
Suggested PR Scope
L overall — split into four PRs, in order. PR 1 alone must fix the data loss.
PR 1 (M) — CRDT core, behind the flag. Deps, ensureSchema() tables, lib/crdt/*, both doc routes, use-note-doc, yCollab in codemirror-editor.tsx, doc-update on the BroadcastChannel, crdt_managed_body guard, convergence tests. Ships the fix.
PR 3 (S) — Offline.y-indexeddb, sync-state UI, reconnect flush.
PR 4 (M, optional/later) — Realtime transport. Hocuspocus on Railway (or y-partyserver on Durable Objects) behind the sync-transport.ts interface, Clerk JWT onAuthenticate with per-documentName ownership re-check. Requires a deploy decision — do not start without one.
Do not delete the legacy path or lib/remote-apply-guard.ts in any of these. A separate cleanup issue should retire them one release after the flag defaults to on.
Suggested next agent: $worktree-task → $generate-pr for PR 1, scoped to Phase 1 only.
Summary
Replace AgentNote's whole-document last-write-wins note body sync with a Yjs CRDT document model bound to CodeMirror 6 via
y-codemirror.next, persisted in the existing Postgres as an append-only update log plus periodic snapshots, and synced over plain HTTP route handlers first (no new infrastructure), with a WebSocket transport as a drop-in Phase 2.notes.bodystays in the schema as a server-derived plaintext projection of the CRDT, so publish//p/...,note_revisions, title derivation, archive, and the sidebar preview keep working with zero changes.Goal: make silent body truncation structurally impossible rather than defended against by an ever-growing set of guards (#51, #53, #57), and put true concurrent editing (same user across tabs/devices now, multiple users later) on the table without a rewrite.
Why This Matters
Body-loss incidents keep recurring on this repo despite three rounds of hardening:
upsertwithforceBodyclobbered an actively-edited buffer.beforeunloadguard.note_revisionsadded as a recovery net (explicitly not a fix).uvk-cumd-omo) — a stale tab still won an LWWPUTafter the Data loss: cross-tab upsert with forceBody overwrites an actively-edited note body #51/fix: stop forceBody upsert from overwriting dirty note bodies #56 dirty guards; fixed by adding optimistic concurrency (expected_updated_at+ 409) and a separatebaseUpdatedAtRefgeneration token.Every one of those fixes is correct, and the current code on
mainis genuinely careful. But they all share one root cause: the unit of synchronization is the entire note body string. With a whole-document write, "who wins" is a policy decision, and every policy has a losing side.lib/remote-apply-guard.tsnow holds six separate predicates (canApplyRemoteBody,isRemoteNoteNewer,shouldMarkSavedAfterPersist,canAdvanceBaseWithoutAdopting,isDraftBaseCurrent,shouldAcceptDraftSeq) whose only job is to decide which whole body to discard. Under OCC the loss mode changed shape but did not disappear — it became "409 → user must choose", i.e. one side's typing is still thrown away.A CRDT changes the unit of synchronization to operations on a shared text. Concurrent inserts/deletes from two tabs both survive and merge deterministically. There is no "stale writer", no generation token to get wrong, and no discard prompt. This is the same model Obsidian Sync, Linear, and Figma converged on, and the reason Yjs exists.
Secondary benefits that matter for this specific product:
y-indexeddb) — today an offline edit is a pendingPUTthat will 409.agentnotepremise ("sits next to the agent tab") implies the same note is open in several places at once. That is exactly the workload LWW is worst at.Conversation Context
Condensed from the design conversation that produced this issue:
pg) hosted on Railway, Clerk for auth (agentnoteClerk app, GitHub OAuth). Any transport choice must respect that Vercel functions are not a durable WebSocket hub (see Source Of Truth).Current Behavior
All paths below are on
origin/mainas of commit247aa89("fix: optimistic concurrency + buffer guards to stop stale-tab truncation (#57) (#58)"). A local checkout may be dozens of commits behind — read fromorigin/main.Data model (
lib/db.ts→ensureSchema())notes.updated_atdoubles as the optimistic-concurrency token.Write path —
PUT /api/notes/[id](app/api/notes/[id]/route.ts→lib/notes.ts::updateNote)Request body:
{ "title": "string", "body": "string", "expected_updated_at": "2026-07-30T12:00:00.000Z" }expected_updated_atis required; a missing token is a hard400("fail closed: old clients without a concurrency token must not LWW-write").FLOOR(EXTRACT(EPOCH FROM updated_at) * 1000)::bigint—FLOOR, not a rounding::bigintcast, because clients send ms truncated byDate#toISOString()and half-up rounding would false-409 roughly half of all notes.WITH prev AS (SELECT ...), updated AS (UPDATE ... FROM prev WHERE ... AND FLOOR(...)= $5)) so a concurrent save cannot interleave between the read of the previous body and the overwrite. TheFLOORgate is repeated on the UPDATE row because underREAD COMMITTEDthe EvalPlanQual recheck would otherwise let two same-token writers both win after the CTE snapshot.409 { "error": "Conflict", "note": <server note> }.note_revisions(best-effort, never fails the save, coalesced to one row per 60s).This is still a whole-document overwrite. OCC narrows the window; it does not merge.
Client state machine (
components/agentnote-app.tsx, ~1384 lines)Constants:
POLL_MS1500pullFromServer()interval whiledocument.visibilityState === "visible"DRAFT_BROADCAST_MS32400(inlinesetTimeout)PUTSAVE_RETRY_BACKOFF_MS[1000, 2000, 4000, 8000]lib/save-failure.tsRefs that constitute the concurrency protocol:
bodyRefState— live editor buffer, written in the same turn assetBody(setBodyNow) so poll/BroadcastChannel callbacks never see a one-frame-stale value (regression from Chrome: editor viewport/caret jumps to top — full-document CM6 replacement collapses scroll anchor and selection to offset 0 #47 T4).lastAckedBodyRef— body from the last successful server ack or clean open.baseUpdatedAtRef— the generation the buffer was built on, deliberately not the newest list row.persist()sends this asexpected_updated_at. It advances only when the buffer adopts server state.saveSeqRef/isCurrentSaveAttempt()— drops stale persist results so an older body can never land after a newer save.persistInFlightRef—flushPendingSave()awaits it, because a second same-tokenPUTraces into a self-409.draftSeqRef+lastDraftSeqByPeer— per-peer monotonic draft sequence for out-of-order ignore.Save states:
"saved" | "saving" | "dirty" | "error";hasUnsavedWork()treatsdirty/saving/erroralike.Cross-tab sync (
lib/tab-sync.ts)BroadcastChannel("agentnote.sync." + userId)— per-user channel name, same browser/origin only. Message union:A
draftcarries the entire body string. Receivers gate it throughisDraftBaseCurrent()(missing base fails closed) andshouldAcceptDraftSeq().Apply guards (
lib/remote-apply-guard.ts)canApplyRemoteBodydirty/saving/errorall refuse, andforceBodyis explicitly ignored (void opts?.forceBody). Additionally refuses whenlocalBody !== lastAckedBodyeven insavedstate.isRemoteNoteNewer>onupdated_at— equal timestamps keep local, so client-clock drafts cannot win a tie.shouldMarkSavedAfterPersistcanAdvanceBaseWithoutAdoptingbaseUpdatedAtRef— otherwise a stale buffer gets a valid ticket (the exact #57 bug). Only body-neutral bumps (publish/unpublish/restore) may advance it.isDraftBaseCurrentundefinedbase fails closed.shouldAcceptDraftSeqnoteAfterConflictKeepLocalBuffershouldAutoRetrySave()deliberately returnsfalseforconflict: "Conflict must not auto-retry — rebasing + PUT would recreate LWW loss." The user is left inerrorstate with a manual Retry.Editor (
components/codemirror-editor.tsx+lib/editor/apply-external.ts)@codemirror/lang-markdown, custom Enter list continuation, Tab list indent, strikethrough, arrow substitution, inline image widgets,indentedLineWrapping(),scrollPastEnd().value: stringprop. Parent remounts it withkey={noteId}.applyExternalValue(view, next), a ZedBuffer::apply_diffanalogue:minimalChange()computes one common-prefix/suffix-trimmed replace range, snapping off UTF-16 surrogate-pair interiors;false(skip) when the local selection intersects the remote hunk (sel.from <= to && sel.to >= from);falsewhileview.composing(IME);scrollIntoView: falseand no explicit selection soChangeSetmapping preserves caret + scroll.onExternalReconcilesyncs React from CM without marking dirty (reconcileBodyFromEditorsetsskipNextSave.current = true).Tests on
mainlib/remote-apply-guard.test.ts,lib/save-failure.test.ts,lib/notes-revisions.test.ts,lib/editor/apply-external.test.ts,lib/editor/apply-external.dom.test.ts,lib/editor/arrow-input.test.ts,lib/editor/image-widgets.test.ts. Runner:vitest(pnpm test), jsdom available.What is explicitly NOT collaborative today
draftandupsertcarry final strings, so intent (insert "x" at offset 40) is unrecoverable.PUT; on reconnect its token is stale → 409 → manual choice.saveErrorKind === "conflict"requires a human to pick a side.BroadcastChannelbetween devices — only the 1.5s poll, andapplyRemoteNoterefuses to adopt whenever the local tab is dirty, so two devices editing the same note diverge until one gives up its work.Desired Behavior
After this issue:
Y.Text. Concurrent edits from any number of tabs/devices converge to a single deterministic document. No edit is ever discarded to resolve a conflict.notes.bodyremains a correct plaintext mirror of the CRDT at all times (eventually consistent within one sync round), so/p/{handle}/{id},note_revisions, sidebar previews, and derived titles are unchanged.value: stringcomponent for CRDT-backed notes; CM6 is driven byyCollab, which preserves caret/scroll/IME by construction — makingapplyExternalValue's heuristics unnecessary on that path.Source Of Truth
Internal repo/source — read these first
components/agentnote-app.tsxpersist(),pullFromServer(),applyRemoteNote(),applyRemoteDraft(),broadcastDraft(),flushPendingSave(),ensureSafeToLeaveActive(),POLL_MS, autosave400. The whole body sync state machine lives here.components/codemirror-editor.tsxeditorExtensions(), thevalue→applyExternalValueeffect,applyingExternalguard,key={noteId}remount contract.lib/editor/apply-external.tsminimalChange()+ selection/IME skip semantics being replaced on the CRDT path.lib/remote-apply-guard.tslib/save-failure.tsSAVE_RETRY_BACKOFF_MS,classifySaveHttpStatus,shouldAutoRetrySave(conflict must not auto-retry),isCurrentSaveAttempt.lib/tab-sync.tsopenSyncChannel(),syncChannelName(userId), theSyncMessageunion to extend.lib/notes.tsupdateNote()CTE with theFLOOR(EXTRACT(EPOCH ...))OCC gate;recordPreviousBodyRevision();resolveCanonicalNoteId()(aliases!).lib/db.tsensureSchema()— idempotent, runs at query time behindglobal.__agentnoteSchemaReady. New tables go here, same style.app/api/notes/[id]/route.tsPUThandler,expected_updated_at400/409 contract.app/api/notes/route.tsGETlist /POSTcreate.lib/require-user.tsrequireUserId()→ `{ userId }proxy.tsclerkMiddleware; public matcher list (/login(.*),/p(.*),/api/version,/api/cron(.*)). New/api/notes/**routes are protected by default.lib/types.tsNote/PublicNoteshapes returned to the client.vercel.jsonPrior issues (context, not duplicates)
upsert+forceBodyclobbered an edited body.note_revisionsrecovery trail. Explicitly a safety net, not a fix.beforeunloadguard.uvk-cumd-omo) — stale-tab LWW truncation after the Data loss: cross-tab upsert with forceBody overwrites an actively-edited note body #51/fix: stop forceBody upsert from overwriting dirty note bodies #56 dirty guards. Introducedexpected_updated_atOCC + 409 +baseUpdatedAtRef. OCC already exists onmain; this issue does not re-invent it.yCollabremoves that failure mode on the CRDT path.External docs/source
Yjs core
yjs@13.6.31(verified 2026-07-31).Y.applyUpdate(doc, update: Uint8Array, origin?: any)Y.encodeStateAsUpdate(doc, encodedTargetStateVector?: Uint8Array): Uint8ArrayY.encodeStateVector(doc): Uint8ArrayY.encodeStateVectorFromUpdate(update: Uint8Array): Uint8Array— state vector without loading a Y.DocY.diffUpdate(update: Uint8Array, stateVector: Uint8Array): Uint8Array— diff on binary, no Y.DocY.mergeUpdates(updates: Uint8Array[]): Uint8Array— compaction; dedupes, improves compressiondoc.on('update', (update: Uint8Array, origin: any, doc: Y.Doc) => void)mergeUpdatesdoes not garbage-collect deleted content; only a full Y.Doc load + re-encode shrinks a document meaningfully.y-protocols@1.0.7SyncStep1(id0) = sender's state vector;SyncStep2(id1) = the missing updates;Update(id2) = an incremental update.(state, clock, timestamp); an entry not refreshed for 30s MUST be removed locally.decoding.readVarUint()→sync.readSyncMessage()/awareness.applyAwarenessUpdate(), withlib0/encoding+lib0/decodingprimitives (lib0@0.2.117).{ sv, update }and returning{ update }is SyncStep1+Update in, SyncStep2 out.CodeMirror 6 binding — critical, this app is CM6
y-codemirror.next— https://github.com/yjs/y-codemirror.next — npmy-codemirror.next@0.3.5(verified 2026-07-31).yCollab,ySync,ySyncFacet,YSyncConfig,yRemoteSelections,yUndoManagerKeymap,yCollabConfig.mainbranch is the unstable@y/codemirrorline targeting Yjs v14 (@y/y). Stay on the publishedy-codemirror.next+yjs@13.xfor this issue. The README example on GitHub showsimport * as Y from '@y/y'— that is the v14 branch; usefrom 'yjs'.yCollabinstalls its own undo/redo. It must replace, not stack on,@codemirror/commands'history()/historyKeymapfor CRDT-backed notes, or ⌘Z will fight itself. See Edge Cases.Persistence reference implementations (read these, they solve exactly this problem)
y-postgresql— https://github.com/MaxNoetzold/y-postgresql — npmy-postgresql. API:PostgresqlPersistence.build(connectionOptions, persistenceOptions), thengetYDoc(docName),storeUpdate(docName, update),getStateVector(docName),getDiff(docName, stateVector),clearDocument(docName),destroy(). Stores individual updates and merges them into one document after a threshold — default 200 transactions. Implements they-websocketpersistence interface{ bindState, writeState }:bindStatesubscribes todoc.on('update')and stores incrementally;writeStatepersists when the last client disconnects. This is the closest existing analogue to what this issue builds — use it as a design reference even if not adopted as a dependency.y-indexeddb— https://github.com/yjs/y-indexeddb — npmy-indexeddb@9.0.12. Offline/local persistence; replicates state to every peer that has visited the document, so if the server ever loses data a client can sync it back. Combine with a network provider (docs: https://docs.yjs.dev/getting-started/allowing-offline-editing).@hocuspocus/extension-database@4.4.0.fetch({ documentName }) => Promise<Uint8Array | null>andstore({ documentName, state }) => Promise<void>. Documented pitfall, applies to us too: "Make sure to return the same Uint8Array that was saved in store(), and do not create a new Ydoc, as doing so would lead to a new history (duplicated content)."Transport options evaluated
ws/ Socket.IO work; standard upgrade triggers a function invocation that stays pinned to the connection). Billing is Active-CPU, so idle connections are cheap. But: connections are pinned to one instance for its max duration, future connections are not guaranteed to land on the same instance, and there is no built-in cross-instance broadcast. A Yjs room needs all peers of a document on one authority — so Vercel WS alone is not a sufficient Yjs hub today. Vercel's own KB still points collaborative apps at Ably / Liveblocks / PartyKit / Pusher / Supabase. (Needs verification before Phase 2: whether the beta has since gained a documented affinity/broadcast primitive.)@hocuspocus/server@4.4.0. Long-lived Node WebSocket server for Yjs. Auth via theonAuthenticate({ documentName, token, requestHeaders, ... })hook: validate a token (e.g. a Clerk JWT), throw to reject, return a context object consumed by later hooks (beforeHandleMessage,onStoreDocument);connection.readOnlysupports read-only grants. Deployable next to the existing Railway Postgres.y-partyserver— https://github.com/cloudflare/partykit/tree/main/packages/y-partyserver — npmy-partyserver@2.2.0. Yjs on Cloudflare Durable Objects;YServeriswithYjs(Server). A Durable Object gives exactly the single-authority-per-document property a Yjs room needs.y-durableobjects— https://github.com/napolab/y-durableobjects — alternative DO implementation, no Node dependency.y-sweet— https://github.com/jamsocket/y-sweet — Yjs document store with S3-compatible persistence + auth; self-host or Jamsocket. (Note: issue #203 in that repo reports high Cloudflare Worker runtime cost for the Worker deployment — evaluate before adopting.)y-websocket@3.0.0— https://github.com/yjs/y-websocket — the reference provider/server; fine for local dev and for a Railway-hosted node.Algorithm background for the A/B/C comparison
automerge-repo— https://github.com/automerge/automerge — mature JSON CRDT with a sync-server story; strong for structured docs, but there is no first-party CodeMirror 6 binding as mature asy-codemirror.next, and it brings a WASM payload.Proposed API / Schema
DB migration (append to
ensureSchema()inlib/db.ts, same idempotent style)Notes on the shape:
ON DELETE CASCADEmirrorsnote_revisions, so permanent delete and archive purge clean up automatically.update_bin/state_binavoid the reserved-ish wordupdateas a column name.through_seqis what makes "snapshot + tail" reads cheap and compaction safe.notes.notes.bodykeeps its meaning; it just gains a new writer.GET /api/notes/[id]/docReturns the full document state, used on first open when the client has no local state.
Response
200:{ "note_id": "abc-defg-hij", "update": "AQHT4a...base64 Uint8Array...", "seq": 4217, "created": false }update= base64 ofY.encodeStateAsUpdate(ydoc)whereydoc= snapshot + all updates withseq > through_seq.seq= highestnote_doc_updates.seqfolded intoupdate(orthrough_seqwhen the tail is empty). The client passes it back as asincecursor.created: truewhen this call performed first-time seeding fromnotes.body(see Flow step 2).404whenresolveCanonicalNoteId(userId, id)returns null — same not-found semantics and no existence leak as the current routes.POST /api/notes/[id]/doc/sync— the workhorse (one round trip = push + pull)Request:
{ "update": "AQHT4a...base64, optional — omit for a pull-only poll", "state_vector": "AQEB...base64, optional — omit to receive nothing back", "since": 4217 }Response
200:{ "seq": 4231, "update": "AQKz...base64 of the diff the client is missing, or null", "body": "# Note title\n\nprojected plaintext…", "updated_at": "2026-07-31T09:14:02.511Z" }Semantics:
updateis present → append one row tonote_doc_updates, then recompute the projection (see Flow).state_vectoris present → reply withY.encodeStateAsUpdate(serverDoc, sv);nullwhen the client is already current.sinceis an optimization: whensince === current head seqand no local update was sent, the server may skip loading the doc entirely and return{ seq, update: null }.body+updated_atare returned so the client can refresh the sidebar row without a secondGET /api/notes.This single endpoint is deliberately
syncStep1 + updatein,syncStep2out — the y-protocols shape, over HTTP.GET /api/notes/[id]/doc/stream(Phase 1.5, optional)text/event-streamSSE emittingdata: {"seq":4231,"update":"<base64>"}per new update. Cuts perceived cross-device latency without a WebSocket. Needs verification: Vercel Fluid compute max function duration for a long-lived SSE response and whether PostgresLISTEN/NOTIFYthrough the existingpgPoolis workable there, or whether a short-lived (~50s) SSE with client reconnect is the pragmatic shape.PUT /api/notes/[id]— unchanged wire contract, narrowed meaningexpected_updated_at; still returns409 { error, note }.note_doc_snapshotsrow exists for the note (i.e. it is CRDT-backed) and the request carries abody, respond409with a machine-readable reason so a stale legacy client cannot LWW over the CRDT:{ "error": "Conflict", "reason": "crdt_managed_body", "note": { "...": "..." } }Extended
BroadcastChannelmessage (lib/tab-sync.ts)The existing
draftmessage stays for the legacy path and is not sent for CRDT-backed notes.Validation rules
update/state_vector: base64; reject payloads over 1 MiB decoded with413. A single keystroke update is tens of bytes; anything larger is a bug or an attack.requireUserId()first, thenresolveCanonicalNoteId(userId, id)— never trust the path id. Aliases must resolve, matchinggetNote.note_doc_updates.user_idis set server-side from Clerk, never from the payload.Y.applyUpdatethrows) →400 { "error": "Invalid update" }, and the row is not appended. Never persist an update the server could not apply.PUTwithexpected_updated_atand are unaffected until their notes are seeded.Implementation Notes
Recommended architecture — and why, versus the alternatives
Chosen: (C) Yjs CRDT +
y-codemirror.next+ Postgres update log/snapshot, HTTP-first transport.generation BIGINT, removing theFLOOR(EXTRACT(EPOCH ...))ms-truncation hazard. Cheap.diff-match-patch) server-sideyCollabpreserves caret/scroll/IME natively, retiring theapplyExternalValueheuristics on that path.yjson both client and server, and undo/redo must move toY.UndoManager. All bounded and addressed below.The decisive point for this product: AgentNote already pays the complexity cost of a CRDT (six discard predicates, three generation refs, a per-peer sequence map, a conflict UX) while getting none of the benefit. Switching substrate deletes more code than it adds.
Two choices keep the blast radius small:
notes.bodybecomes a derived projection, not a second source of truth. Publish, revisions, previews, titles, archive, and the public route keep reading the exact column they read today. Nothing downstream of the editor changes.Phased rollout
Phase 1 — Stop the data loss (the MVP; ship this alone if nothing else lands).
Yjs doc model,
y-codemirror.nextbinding,note_doc_updates+note_doc_snapshots,GET /doc+POST /doc/sync, poll reuses the existing 1.5s cadence,BroadcastChannelcarries binary updates,notes.bodyprojection, compaction cron. BehindNEXT_PUBLIC_AGENTNOTE_CRDT. Outcome: truncation is structurally impossible; two tabs and two devices both merge.Phase 1.5 — Local-first.
y-indexeddbper note. Offline edits queue and flush on reconnect. Reload mid-edit loses nothing.Phase 2 — True realtime transport. Swap the poll for a WebSocket provider. Recommended primary: Hocuspocus on Railway — a long-lived Node process beside the Postgres this project already runs there, with
onAuthenticateverifying a Clerk JWT and@hocuspocus/extension-databasefetch/storepointed at the same tables. Alternative if a Cloudflare-shaped deploy is preferred:y-partyserveron Durable Objects. Only the provider changes — the document model, schema, and editor binding are already correct. Note Vercel's native WS beta cannot host the room today (no cross-instance broadcast).Phase 3 — Optional multi-user. Add a
note_members(note_id, user_id, role)table, relax the room ACL fromnotes.user_id = auth.userIdto membership, and enableyCollab's awareness (remote cursors,yRemoteSelections) which is already wired in Phase 1 with a single local client. No rewrite: the room key is alreadynote_idand isolation is already enforced per request.Likely files to modify
package.json— addyjs@^13.6.31,y-codemirror.next@^0.3.5,y-protocols@^1.0.7,lib0@^0.2.117; Phase 1.5 addsy-indexeddb@^9.0.12. Pinyjsto a single version — two copies of Yjs in one bundle silently break sync; add apnpm.overridesentry if the lockfile ever dedupes badly.lib/db.ts— the twoCREATE TABLE IF NOT EXISTSblocks above, inside the existingensureSchema()IIFE, beforemigrateLegacyNoteIds(pool).components/codemirror-editor.tsx— accept an optionalytext?: Y.Text+awareness?: Awareness. When present: seedEditorState.create({ doc: ytext.toString() }), appendyCollab(ytext, awareness, { undoManager }), and drophistory()/historyKeymapfrom the extension list (see Edge Cases). When absent: today'svalue/applyExternalValuepath, byte-for-byte unchanged.components/agentnote-app.tsx— the largest change. For CRDT-backed notes, retirepersist()/broadcastDraft()/applyRemoteDraft()/applyRemoteNote()-for-body in favour of a doc-session hook. Keep all of it for the legacy path.saveStatebecomes a much simpler"synced" | "syncing" | "offline"on the CRDT path — nodirty, noerror-with-conflict, nobeforeunloadprompt for the body.lib/tab-sync.ts— add thedoc-updatevariant toSyncMessage.lib/notes.ts— addgetNoteDocState,appendNoteDocUpdate,compactNoteDoc,projectNoteBodyFromDoc; add thecrdt_managed_bodyguard toupdateNote.app/api/notes/[id]/route.ts— return thecrdt_managed_body409 when a snapshot row exists and abodywas sent.vercel.json— add{ "path": "/api/cron/compact-note-docs", "schedule": "30 9 * * *" }alongside the two existing 09:00-ish crons.README.md— document the flag, the new tables, and the recovery story..env.example—NEXT_PUBLIC_AGENTNOTE_CRDT=0.New files
lib/crdt/note-doc.ts— pure, testable helpers:NOTE_TEXT_KEY = "body",seedDocFromPlaintext(body): Uint8Array,mergeUpdatesToState(updates): { state, stateVector },docBodyFromState(state): string,shouldCompact({ updateCount, byteSize }). Nopg, nofetchhere — this is the unit-test surface.lib/crdt/note-doc-store.ts— server-side Postgres I/O: load snapshot + tail, apply, append, compact, write projection. Usesquery()fromlib/db.ts.lib/crdt/use-note-doc.ts— client hook owning oneY.Docper active note: creates the doc,GET /docon open, subscribesdoc.on('update')→ POST +BroadcastChannel, poll loop, applies inbound updates, exposes{ ytext, awareness, syncState }. Must tag origins (Y.applyUpdate(doc, u, "remote")) so remote updates are not echoed back.lib/crdt/sync-transport.ts— the HTTP transport behind a small interface (pull,push,subscribe?) so Phase 2 swaps in a WebSocket provider without touching the hook.app/api/notes/[id]/doc/route.ts—GETfull state.app/api/notes/[id]/doc/sync/route.ts—POSTpush+pull.app/api/cron/compact-note-docs/route.ts— mirrorapp/api/cron/purge-note-revisions/route.tsexactly, including theBearer CRON_SECRETcheck (that path is public inproxy.ts, so the in-route check is the only gate).lib/crdt/note-doc.test.ts,lib/crdt/note-doc-store.test.ts,lib/crdt/convergence.test.ts.Flow
use-note-doccreatesnew Y.Doc(), callsGET /api/notes/:id/doc,Y.applyUpdate(doc, base64ToBytes(update), "remote"), recordsseq, handsytextto CM6 viayCollab.note_doc_snapshotsrow exists: build a doc,ydoc.getText("body").insert(0, note.body), encode, andINSERT ... ON CONFLICT (note_id) DO NOTHING, then re-read the row and return the winner's state. Do the insert only on the server. Seeding on the client — or on two servers without theON CONFLICTguard — is the single most common Yjs bug and produces duplicated document content (the Hocuspocus docs call this out explicitly).yCollabmutatesytext→doc.on('update')fires withorigin === null(local). The handler (a) posts{ type: "doc-update", update }on the BroadcastChannel immediately — peer tabs converge in ~1 frame — and (b) enqueues the update for the network. Debounce network pushes with the existing 400 ms autosave feel, merging queued updates withY.mergeUpdates()into one POST.POST /api/notes/:id/doc/syncwith{ update, state_vector, since }. Server:requireUserId()→resolveCanonicalNoteId()→pg_advisory_xact_lock(hashtext($note_id))→ validate by applying to the loaded doc →INSERT INTO note_doc_updates→ recomputebody = ydoc.getText("body").toString()→UPDATE notes SET body = $1, title = <deriveTitle(body)>, updated_at = NOW()→ reply with the diff the client is missing plus the freshbody/updated_at.notes.bodybehind the log.recordPreviousBodyRevision()on the projection write (still coalesced to 60 s) —note_revisionsstays useful as a human-readable time machine even though CRDT recovery no longer depends on it.update. Remote updates are applied with origin"remote", whichyCollabrenders without disturbing the local selection or scroll — noapplyExternalValueheuristics, no IME special-case.doc-update.Y.applyUpdate(doc, update, "broadcast"). Because updates are idempotent and commutative, a tab that already has it via the network is unaffected. ThedraftSeq/isDraftBaseCurrentmachinery is unnecessary here — out-of-order and duplicate delivery are both correct inputs to a CRDT.COUNT(note_doc_updates) > 200orSUM(octet_length(update_bin)) > 256 KiB: inside one transaction, take the advisory lock, load snapshot + tail,Y.encodeStateAsUpdate(doc)(a full doc load, which also drops deleted content —Y.mergeUpdatesalone would not),UPSERT note_doc_snapshotswith the newthrough_seq, thenDELETE FROM note_doc_updates WHERE note_id = $1 AND seq <= $through_seq. Thresholds mirrory-postgresql's default of 200 transactions before auto-merge.Tests
lib/crdt/convergence.test.ts— the load-bearing test. This is the Data loss: stale-tab LWW PUT still truncates note bodies after #51/#56 dirty guards (RCA 2026-07-30 uvk-cumd-omo) #57 regression, restated:shouldAcceptDraftSeq).note-doc.test.ts—seedDocFromPlaintextround-trips exactly, including CRLF, emoji/surrogate pairs (theapply-external.tssurrogate hazard), and the ASCII-arrow substitution output fromlib/arrows.ts.note-doc-store.test.ts— compaction preservesdocBodyFromStatebyte-for-byte and monotonically reduces stored bytes;through_seqnever regresses; deleting compacted rows loses nothing.PUTwith abodyon a CRDT-backed note →409 crdt_managed_body; oversized update →413; garbage update →400and no row appended; another user's note id →404.lib/remote-apply-guard.test.tsandlib/save-failure.test.tsgreen — the legacy path is still live behind the flag.pnpm test,pnpm lint,pnpm build.Edge Cases And Risks
yCollab'sundoManagerand@codemirror/commands'history()both bind ⌘Z. On CRDT-backed notes, removehistory()/historyKeymapand useyUndoManagerKeymap+new Y.UndoManager(ytext).Y.UndoManageris per-client by design (you undo your edits, not a peer's) — that is the desired behavior, but it is a behavior change the operator will notice.yjsinstances → updates apply but nothing converges. Pin one version; verify withpnpm why yjs.ON CONFLICT DO NOTHING+ re-read. Explicitly warned about in the Hocuspocus docs. Must have a test.Y.mergeUpdatesdoes not GC deleted content. Without the compaction cron a heavily-edited note's log grows without bound. Compaction must do a full doc load, not justmergeUpdates. Also add anoctet_lengthguard so one runaway note cannot blow up the row size.notes.bodyis eventually consistent with the log. If a/p/...read races a sync POST it may serve a body one update stale. Acceptable (today's poll is 1.5 s stale anyway) — but the projection write must never be skipped on the "no diff to return" path./doc/syncPOST loads snapshot + tail and re-encodes. With aggressive compaction this is a few KB and sub-millisecond, but it is real per-request CPU on Fluid pricing. Mitigation: thesince === headfast path that skips the doc load entirely for no-op polls. Needs verification: measure p95 for a ~100 KB note before enabling the flag for daily use.resolveCanonicalNoteIdand aliases.note_doc_*rows key on the canonical id. The id-migration path inlib/db.ts::migrateLegacyNoteIdsrewritesnotes.id— theON UPDATE CASCADEon both new FKs handles it, but verify against a note that still has an alias row.ON DELETE CASCADEdrops doc rows with the note. Restore-from-archive must not resurrect a stale snapshot over a newer one — it cannot, since archive does not touch doc rows, but add a test./p/{handle}/{id}readsnotes.bodyviagetPublicNote()with no auth. Unchanged — but confirm the projection write fires beforepublished_atis set on a first publish.userId. A room key ofnote_idalone would let a leaked id read another user's CRDT. Phase 2'sonAuthenticatemust verify the Clerk JWT and re-check ownership perdocumentName— a valid token for user A must not open user B's room.yCollabhandles remote application through CM6 transactions, so theview.composingbail-out inapply-external.tsis not needed on this path. Still smoke-test Korean IME explicitly — this is the operator's primary input method and the historical source of editor bugs here.yCollab(which is also just an extension). Regression-test all three, especiallylib/editor/paste-images.tsandarrowPasteFilter(), which dispatch programmatic changes.PUTbody path returnscrdt_managed_body. Turning the flag off strands those notes read-only for body edits. Either keep a documented un-seed script (snapshot →notes.body, delete doc rows) or accept the flag as one-way and say so in the README.Non-Goals
yRemoteSelectionsstays off until Phase 3.note_revisions(Add note body revision history so a bad overwrite is recoverable #52) or thelib/remote-apply-guard.tspredicates while the legacy path exists./p/..., archive, themes, or any editor keybinding behavior.@y/y. Stay on the stableyjs@13.x+y-codemirror.next@0.3.xline.Acceptance Criteria
note_doc_updatesandnote_doc_snapshotsexist viaensureSchema(), are idempotent, and cascade on note delete.GET /api/notes/:id/docreturns full state and seeds fromnotes.bodyexactly once per note, with no duplicated content under concurrent first opens.POST /api/notes/:id/doc/syncappends, validates, projects tonotes.body, and returns the caller's missing diff.notes.bodymatchesdocBodyFromState(snapshot + tail)after every sync (verified by test and by SQL spot-check)./p/{handle}/{id}renders the current body with no code change to the public route.note_revisionsstill records prior bodies on projection writes, coalesced at 60 s.404, no existence leak).docBodyFromState.NEXT_PUBLIC_AGENTNOTE_CRDT=0restores the exact current behavior; all existing tests pass in both modes.pnpm test,pnpm lint,pnpm buildclean.QA Plan
pnpm install && pnpm test && pnpm lint && pnpm build.pnpm devwithNEXT_PUBLIC_AGENTNOTE_CRDT=1. Open the same note in two tabs. Type in both simultaneously in different paragraphs. Expect: both texts present in both tabs within ~1 frame; no error indicator; no confirm dialog.maintoday, one side is truncated.notes.bodyin Postgres matching what both screens show./p/{handle}/{id}in a private window, confirm the body matches after a sync round.note_doc_*rows cascade (SELECT COUNT(*) FROM note_doc_updates WHERE note_id = '<id>'→ 0). Read-only verification on a scratch note only./api/cron/compact-note-docswith theCRON_SECRETbearer on a scratch note with 300+ updates; verify byte reduction and identical body.NEXT_PUBLIC_AGENTNOTE_CRDT=0; confirm legacy notes still save viaPUTand everyremote-apply-guardtest stays green.Suggested PR Scope
L overall — split into four PRs, in order. PR 1 alone must fix the data loss.
ensureSchema()tables,lib/crdt/*, both doc routes,use-note-doc,yCollabincodemirror-editor.tsx,doc-updateon the BroadcastChannel,crdt_managed_bodyguard, convergence tests. Ships the fix.vercel.jsonentry, size guards, README.y-indexeddb, sync-state UI, reconnect flush.y-partyserveron Durable Objects) behind thesync-transport.tsinterface, Clerk JWTonAuthenticatewith per-documentNameownership re-check. Requires a deploy decision — do not start without one.Do not delete the legacy path or
lib/remote-apply-guard.tsin any of these. A separate cleanup issue should retire them one release after the flag defaults to on.Suggested next agent:
$worktree-task→$generate-prfor PR 1, scoped to Phase 1 only.