Files
xarmian 18087463ce feat(collab): op-log cursor protocol — force-refresh + watermark advance (TASK-1319) (#472)
* feat(collab): op-log cursor protocol — force-refresh + watermark advance (TASK-1319)

Closes both holes left by TASK-1309:

  1. Long-disconnected tab + external-write race. A reconnecting client
     announces its highest applied item_yjs_updates.id via `?since=<id>`.
     If that id is below MIN(id) for the item, rows it expected to
     replay have been pruned and the server sends a `force_refresh`
     control frame and closes the conn. Client recreates the Y.Doc
     and lazy-seeds from items.content. Without this, Tab A's stale
     state would silently overwrite an external CLI/MCP write on the
     next 5s flush.

  2. Browser-only-edited items never GC'd. Browser collab-snapshot
     PATCHes now carry an op_log_cursor body field. The store advances
     items.content_flushed_op_log_id only when the cursor matches the
     current MAX(op-log.id) — proving the markdown captures every
     persisted op. SQL CASE clause re-evaluates MAX at COMMIT time so
     a peer op landing between client-side cursor capture and the
     UPDATE leaves the watermark untouched (no over-advancement).

Combined cursor mechanism:

  - Server attaches op_log_cursor JSON control frames after replay,
    after every successful AppendYjsUpdate (originator), and to every
    peer's binary fan-out (so all peers stay in lockstep without a
    round trip).
  - Client persists per-tab in sessionStorage (NOT localStorage —
    avoids cross-tab cursor leakage that would force-refresh stable
    sessions).
  - Server's MIN(id) check + force_refresh fires only when a non-zero
    `since` is below MIN; `since=0` is treated as a fresh client.

New store methods: MinOpLogID, MaxOpLogID. New ItemUpdate field:
OpLogCursor *int64. New control message types: op_log_cursor,
force_refresh. New OpEvent.OpLogID for cursor piggyback. Existing
collab tests updated to drain TextMessage cursor frames.

Tests cover: initial cursor frame after replay (populated + empty
op-log), force_refresh fires when since<MIN, delta replay when
since>=MIN, cursor broadcast to originator + peers on append, and
watermark advancement gated on cursor==MAX.

Parent: PLAN-1248. Builds on TASK-1309.

* fix(collab): skip stale-Ydoc flush on force_refresh teardown per Codex review (round 1)

A force_refresh tear-down means the local Y.Doc cursor is below the
server's MIN(item_yjs_updates.id) — its derived markdown is stale.
Without this guard the collab $effect cleanup runs flushCollabNow
on the way out and silently PATCHes that stale markdown back to
items.content, overwriting the canonical content the fresh provider
is supposed to lazy-seed from. Per Codex round 1 [P1] of TASK-1319.

* fix(collab): force_refresh on empty op-log + cancel pending flush per Codex review (round 2)

Two P1 fixes:

1. Manager.Join now force_refreshes when since>0 and the op-log is
   empty (hasMin==false), not just when since<MIN. After
   PruneAndApply wipes the entire op-log, MIN is undefined; the
   original predicate would have admitted the stale tab and let its
   on-open Y.encodeStateAsUpdate write resurrect the pre-prune
   document.

2. The +page.svelte onForceRefresh handler now also clears
   collabFlushTimer. Without this a 5s timer that armed before the
   force_refresh frame arrived can still fire AFTER the cleanup
   ran, PATCHing stale Y.Doc-derived markdown to items.content.

New test: TestRoomManagerForceRefreshOnEmptyOpLogWithSince covers
the empty-op-log branch.

Per Codex round 2 [P1] of TASK-1319.

* fix(collab): include forceRefreshNonce in Editor key so it remounts on force_refresh per Codex review (round 3)

The collab $effect cleanup runs on forceRefreshNonce bump, but the
<Editor> {#key} was `${item.id}:true` — itemID doesn't change, so
the keyed Editor wasn't unmounting. The Tiptap Collaboration
extension only binds in onMount, so the editor stayed wired to the
stale (destroyed) Y.Doc while a fresh provider+doc were set up
in parallel. Edits would either be unsynced or eventually flush
stale markdown again.

Adding forceRefreshNonce to the key forces the Editor to remount
in lockstep with the doc swap. Per Codex round 3 [P1] of TASK-1319.

* fix(collab): refetch item.content before lazy-seed on force_refresh per Codex review (round 4)

After force_refresh the collab $effect rebuilds the Y.Doc and the
lazy-seed (TASK-1261) seeds it from item.content. But item.content
was the cached page-state copy — possibly stale relative to the
server (the WS force_refresh can beat the SSE/visibility refresh
that would otherwise update it). Lazy-seeding stale content into
a fresh op-log re-introduces exactly the staleness force_refresh
was supposed to clear: the next 5s flush PATCHes that stale view
back to canonical items.content.

onForceRefresh now does an api.items.get() before bumping the
nonce so the rebuild's lazy seed reads server-fresh content. A
failed fetch falls through to the bump anyway (an editor on
possibly-stale content is still better than a broken editor).

Per Codex round 4 [P1] of TASK-1319.

* fix(collab): suppress cursor during replay + move force_refresh check before getOrCreate per Codex review (round 5)

Two more findings:

1. [P1] writeLoop sends op_log_cursor frames for live ops broadcast
   during the replay window. A client disconnecting after one of
   those cursors lands but BEFORE the rest of replay completes
   would persist a cursor pointing past unreplayed rows. On
   reconnect with since=that-cursor, server replays nothing — the
   client's Y.Doc would be missing causally-required ops.

   Fix: per-roomConn replayDone atomic.Bool. writeLoop suppresses
   cursor frames while it's false. runConn flips it after the
   post-replay initial cursor is on the wire. Live binary frames
   continue to flow during replay (Yjs CRDT commutativity); only
   the cursor metadata is gated.

2. [P2] Force-refresh path leaked an empty room. getOrCreate
   inserted into m.rooms before the force_refresh bail-out left
   an orphan entry that PruneSweep would later treat as 'active'
   and skip indefinitely.

   Fix: schema-rebuild + force_refresh checks now run BEFORE
   getOrCreate. Both are store-only mutations and the per-item
   lock is held throughout, so concurrency is unchanged.

New test: TestRoomManagerCursorSuppressedDuringReplay regression-
guards the cursor-suppression behaviour.

Per Codex round 5 [P1+P2] of TASK-1319.

* fix(collab): tighten initial cursor + sync-destroy provider on force_refresh per Codex review (round 6)

Two more P1 fixes:

1. runConn's empty-replay fallback used MaxOpLogID() to anchor
   the initial cursor. A live op landing between replayTo
   returning and the cursor write would be reflected in MAX
   but its binary frame might not have flowed through this
   conn's writeLoop yet — the cursor would advertise an id
   the client hasn't received. Initial cursor is now strictly
   max(highestReplayed, since); MaxOpLogID is removed from
   the opLogStore interface.

2. Provider.handleControlMessage's force_refresh branch now
   calls this.destroy() SYNCHRONOUSLY before invoking the
   onForceRefresh callback. Previously the consumer's recovery
   path (async items.get refetch) would race the provider's
   own onClose-triggered reconnect, which would re-open with
   since=0 and push Y.encodeStateAsUpdate of the stale Y.Doc
   — recreating the corruption force_refresh was meant to
   prevent. destroy() sets destroyed=true so scheduleReconnect
   short-circuits.

Per Codex round 6 [P1] of TASK-1319.

* fix(collab): block flush scheduling during force_refresh recovery per Codex review (round 7)

Previously, after onForceRefresh fires:
  1. Provider is destroyed synchronously.
  2. Async items.get refetch is in flight.
  3. forceRefreshNonce bumps after refetch resolves.
  4. $effect cleanup runs, then rebuild.

But during steps 2-3 the editor component is still mounted with
the stale Y.Doc, and a local edit fires handleContentUpdate which
calls scheduleCollabFlush. clearTimeout earlier in onForceRefresh
only canceled the timer at THAT moment; a new edit during the
refetch window arms a fresh timer that fires before cleanup. That
PATCHes stale Y.Doc-derived markdown back to canonical content,
recreating the corruption force_refresh was meant to prevent.

Fix: forceRefreshInFlight flag set in onForceRefresh, blocks
scheduleCollabFlush, resets after the fresh provider is wired
(end of $effect run). Per Codex round 7 [P1].

* fix(collab): gate runCollabFlush itself on force_refresh in-flight per Codex review (round 8)

scheduleCollabFlush blocked the 5s timer path, but direct callers
of flushCollabNow / runCollabFlush (beforeunload handler,
rich-to-raw toggle) bypassed the guard. A page reload or raw
toggle DURING the force_refresh recovery window still PATCHed
stale Y.Doc-derived markdown to canonical items.content.

Pulling the guard into runCollabFlush covers every caller in one
spot and returns 'deduped' so the result-shape contract holds.

Per Codex round 8 [P1] of TASK-1319.

* fix(collab): distinct 'skipped' result for force_refresh path; raw-toggle aborts per Codex review (round 9)

runCollabFlush returning 'deduped' on the force_refresh-blocked
path was indistinguishable from a legitimate same-content dedupe.
The rich→raw toggle treats 'deduped' as 'server already has this
markdown' and seeds rawSeedMarkdown from it — letting the user's
next raw edit overwrite canonical items.content with content
derived from the stale Y.Doc.

Add a distinct 'skipped' result for the force_refresh path. Raw
toggle aborts on it (with a 'try again in a moment' toast); other
callers fall through unchanged because no other call site
behaviorally depends on 'deduped' vs 'skipped'.

Per Codex round 9 [P1] of TASK-1319.

* fix(collab): server-side gate + post-await client guard against stale collab-snapshot per Codex review (round 10)

A force_refresh frame can arrive WHILE a collab-snapshot PATCH is
already mid-flight to the server. The client-side
forceRefreshInFlight check at PATCH-start can't catch this race;
the request lands at the server with stale Y.Doc-derived markdown.

Two-pronged fix:

1. Server: handler now checks op_log_cursor against MIN(op-log.id)
   for collab-snapshot PATCHes and returns 409 Conflict when
   cursor < MIN. Such cursors prove the flushing tab's Y.Doc was
   built on rows that have been pruned (PruneAndApply, schema
   rebuild, dormant GC). The markdown is, by construction, stale.

2. Client: post-await check on forceRefreshInFlight returns
   'skipped' instead of 'flushed' so saveStatus / lastFlushedContent
   don't seed from a known-stale base even if the server happened
   to accept the PATCH (e.g. MIN advanced after handler validation).

New tests: TestCollabSnapshotRejectsCursorBelowMin (gate fires),
TestCollabSnapshotAcceptsCursorAtOrAboveMin (negative path).

Also de-leak an unused slice in the round-5 cursor-suppression test
so staticcheck stays clean.

Per Codex round 10 [P1] of TASK-1319.

* fix(collab): reject collab-snapshot when cursor>0 and op-log empty per Codex review (round 11)

The HTTP-layer gate I added in round 10 mirrored only PART of the
WS-upgrade force_refresh predicate. Round 5 had already taught us
that 'op-log entirely pruned' is a separate stale path from
'cursor below MIN' (PruneAndApply, schema rebuild, dormant GC all
leave hasMin=false), and the WS check now uses
`since > 0 && (!hasMin || since < minID)`. The HTTP gate had
only the second clause.

Mirror the WS predicate at the handler so a stale collab-snapshot
PATCH against an empty op-log gets a 409 too. New regression:
TestCollabSnapshotRejectsCursorOnEmptyOpLog.

Per Codex round 11 [P1] of TASK-1319.

* fix(collab): reject collab-snapshot cursor=0 on non-empty op-log per Codex review (round 12)

Round-11 gate accepted cursor=0 unconditionally. But a stateful tab
whose previous session disconnected BEFORE receiving the
post-replay cursor frame (network blip during the writeMu burst
between replay binaries and the cursor) ends up with sessionStorage
cursor=0 + a non-empty Y.Doc populated by prior replay binaries.
On reconnect with since=0 the server treats it as fresh, replays
nothing if the op-log was meanwhile pruned, and the client's
on-open Y.encodeStateAsUpdate resurrects pre-prune ops. The next
flush carries cursor=0 + stale-derived markdown.

The gate now refuses any incompatible cursor:
  - cursor>0 + empty op-log (prior rule)
  - cursor<MIN + non-empty op-log (prior rule, now naturally
    catches cursor=0 too because 0 < any positive MIN)

The WS replay path is unchanged — full replay from since=0 is
the recovery for clients that genuinely lost their cursor; the
corruption manifested through the flush PATCH which we now gate.

New test: TestCollabSnapshotRejectsCursorZeroOnNonEmptyOpLog.

Per Codex round 12 [P1] of TASK-1319.

* fix(collab): close cursor=0 client/server gaps + lock validation+write atomically per Codex review (round 13)

Four P1 issues addressed:

1. Client always sends op_log_cursor (including 0) so the server
   gate sees the field. Previously cursor=0 was omitted, which
   silently bypassed the server's stale-snapshot rejection.

2. Provider construction now resets sessionStorage cursor to 0
   when the Y.Doc is empty. The Y.Doc isn't persisted across
   page reload, so a stored cursor=N + fresh empty Y.Doc would
   announce since=N to the server and miss rows 1..N from
   replay (server only replays id > N).

3. onOpen skips Y.encodeStateAsUpdate when lastOpLogID === 0.
   A populated Y.Doc + cursor=0 is the network-blip-during-cursor-
   write failure mode; pushing that state can resurrect ops the
   server has pruned. Server replay + lazy-seed handle recovery
   without our push.

4. Server gate now runs INSIDE the per-item collab setup lock
   (new RoomManager.UnderItemLock helper) so a concurrent prune
   (PruneAndApply, schema rebuild, dormant GC) cannot land
   between the MIN check and the items.content write. Without
   this, a tight race let stale snapshots overwrite canonical
   content the prune just installed.

Per Codex round 13 [P1] of TASK-1319.

* fix(collab): gate handleDocUpdate on cursorAnchored to close stale-Ydoc edit path per Codex review (round 14)

Round 13 fix skipped on-open send for lastOpLogID===0, but local
edits via handleDocUpdate still propagated. A populated Y.Doc +
no-cursor-yet client could type, the edit would land in the
op-log with id N, server would send originator cursor=N, and
the next 5s flush would carry an 'anchored' cursor that passed
the server's MIN check — overwriting items.content with stale-
Y.Doc-derived markdown.

Add a cursorAnchored boolean. Set on first op_log_cursor frame
receipt (including cursor=0 against an empty op-log — that's a
legitimate 'server has nothing' signal). handleDocUpdate refuses
to send before this. Local edits buffer in the editor; once the
cursor arrives (or force_refresh rebuilds the provider), the
existing reconnect/edit paths catch them up.

Per Codex round 14 [P1] of TASK-1319.

* fix(collab): buffer + flush pre-anchor local updates per Codex review (round 15)

Round 14 silently dropped local Yjs updates fired before the
first op_log_cursor frame anchored the session. Yjs updates are
incremental: a dropped keystroke leaves later ops referencing
structs no peer can resolve, breaking convergence.

Buffer pre-anchor updates in a Uint8Array[] (capped at 1000 to
prevent unbounded growth in pathological 'anchor never arrives'
scenarios — overflow triggers force_refresh-style recovery).
On the first cursor frame, flush the buffer in order so the
server gets every causally-required struct before any post-
anchor updates land.

Per Codex round 15 [P1] of TASK-1319.

* fix(collab): destroy provider before force_refresh on pre-anchor buffer overflow per Codex review (round 16)

Round 15 overflow path called onForceRefresh but didn't destroy
the provider synchronously. A late op_log_cursor arriving before
the page-level rebuild (the recovery callback is async — refetches
items.content) would flip cursorAnchored=true, the partially-
populated buffer would flush, but the DROPPED prefix (the
overflowed entries) would leave server-side ops causally
incomplete — exactly the bug the buffer was supposed to prevent.

destroy() sets destroyed=true, removes message listener,
short-circuits scheduleReconnect, closes the socket. Late cursor
frames can no longer anchor a doomed provider.

Per Codex round 16 [P2] of TASK-1319.

* fix(collab): refuse rebuild on refetch fail + broaden on-open gate to cursorAnchored per Codex review (round 17)

Two findings:

[P1] force_refresh recovery bumped forceRefreshNonce in finally
even when the item.content refetch failed. The rebuild then
lazy-seeded from the cached (possibly-stale) item.content, and
the next flush would PATCH that stale view back to the server.
Move the bump into .then() so a failed refetch surfaces a
'please reload' toast and leaves the editor effectively
read-only (forceRefreshInFlight stays true, blocking flushes).

[P2] Send-on-open gate was lastOpLogID > 0, which silently
dropped local edits made during a brief offline window after a
legitimate 'cursor=0' anchor (empty op-log session). Switch to
cursorAnchored — the boolean specifically distinguishes
'unanchored' (stale Y.Doc + no server confirmation) from
'anchored at cursor=0' (legitimate empty op-log).

Per Codex round 17 [P1+P2] of TASK-1319.

* fix(collab): force_refresh on cursor=0 against non-empty Y.Doc per Codex review (round 18)

cursor=0 means the server's op-log is currently empty. A
non-empty Y.Doc at first-cursor receipt implies the ops came
from an earlier connection within this provider's life that
never reached its post-replay cursor frame, followed by a
server-side prune (PruneAndApply, schema rebuild, dormant GC)
during our disconnect. Anchoring at cursor=0 in that state
would mark a stale Y.Doc as authoritative; the next on-open
state push or flush would resurrect pre-prune state and
overwrite canonical items.content.

Detect the configuration via Y.encodeStateVector length and
invoke the same force_refresh-style recovery the explicit
server frame triggers: destroy provider, clear sessionStorage,
fire onForceRefresh so the page rebuilds from items.content.

Per Codex round 18 [P1] of TASK-1319.

* fix(collab): gate cursor=0 force_refresh on remoteSyncApplied per Codex review (round 19)

Round 18 force_refreshed the provider whenever cursor=0 arrived
against a non-empty Y.Doc. But local pre-anchor edits (user typed
before the initial cursor=0 of a legitimate empty-op-log session
arrived) ALSO populate Y.Doc — yet those edits live in
preAnchorUpdates and were supposed to flush on anchor. The
predicate spuriously triggered force_refresh, dropping the
buffered local edits.

Track remoteSyncApplied (set when readSyncMessage applies
anything to Y.Doc — replay binary or live peer op). Only force_
refresh on cursor=0 when remoteSyncApplied is true: that's the
true 'remote replay landed but server now reports empty op-log
=> mid-session prune' signature.

Per Codex round 19 [P1] of TASK-1319.

* fix(collab): repair brace mis-merge in wsProvider cursor=0 guard

The round-19 patch overlapped the round-18 inner block, producing
an extra brace + over-indented body. Collapsing into a single
clean block restores parseability without changing semantics
beyond what round 19 already documented.

* fix(collab): gate syncStep2 reply on cursorAnchored per Codex review (round 20)

readSyncMessage writes an inline syncStep2 reply when it receives
a peer's syncStep1. That reply embeds our current Y.Doc state.
If a peer's syncStep1 arrives before our first op_log_cursor
(pre-anchor window), the reply path bypasses handleDocUpdate's
cursorAnchored gate and lets potentially-stale Y.Doc state reach
the server before the cursor=0 + remoteSyncApplied force_refresh
recovery has a chance to fire.

Suppress the reply while unanchored. Peer state propagation
still works: the buffered preAnchorUpdates flush on anchor, and
the lazy-seed rebuild after a force_refresh seeds canonical
content from items.content.

Per Codex round 20 [P1] of TASK-1319.

* fix(collab): fold mid-replay live op ids into post-replay cursor + remoteSyncApplied only on apply per Codex review (round 21)

Two more findings:

[P1 server] writeLoop suppresses cursor frames during replay to
prevent the client persisting a cursor past unreplayed rows.
But binary frames for those live ops still go through
(commutativity), so the client APPLIES them to its Y.Doc. The
post-replay initial cursor only covered max(highestReplayed,
since), leaving the cursor below the highest applied op. On
empty-replay sessions this trips the client's
'cursor=0 + remoteSyncApplied' force_refresh path and discards
buffered pre-anchor edits.

Track maxLiveOpLogIDDuringReplay on the roomConn (atomic
compare-and-swap) and fold it into the post-replay cursor.

[P1 client] remoteSyncApplied was set on every MESSAGE_SYNC,
including syncStep1 (which only carries a state vector — it
doesn't apply state). A peer's syncStep1 arriving pre-anchor
would falsely flag remote-sync-applied and trip the cursor=0
force_refresh on legitimate empty-op-log sessions. Set the
flag only after readSyncMessage returns, and only for
syncStep2 / update subtypes.

Per Codex round 21 [P1] of TASK-1319.

* fix(collab): widen writeMu critical section + drop omitempty on op_log_id per Codex review (round 22)

Two more P1s:

[P1 server] writeLoop's mid-replay record-max happened OUTSIDE
writeMu, so runConn's post-replay read could race the record:
runConn loads → writeLoop's atomic store of higher value →
runConn sends cursor below the live id. Move the entire
per-event sequence (binary write + replayDone observation +
record-or-send) inside writeMu, and have runConn acquire
writeMu around its read+cursor-write+replayDone-flip. The lock
serializes the two paths cleanly: writeLoop events that ran
first have already recorded; events that arrive after replayDone
flips emit their own cursor frames.

[P1 protocol] OpLogID had `omitempty` JSON tag — a legitimate
cursor=0 (empty op-log session) serialized as
`{"type":"op_log_cursor"}` with no op_log_id field. The
client's strict-type check then rejected it as malformed,
leaving the session unanchored and local edits buffered
forever. Drop omitempty so 0 is wire-visible. Other control
types (applier_request/ack) carry an extra op_log_id:0 in
their JSON, which their client dispatches ignore.

Per Codex round 22 [P1] of TASK-1319.

* fix(collab): route originator cursor through writeLoop FIFO per Codex review (round 23)

readLoop sent the originator's op_log_cursor directly via
sendOpLogCursor right after AppendYjsUpdate, bypassing the bus/
writeLoop ordering. With a peer op already queued in rc.bus, the
sequence on the wire could be:
  1. originator cursor=N (newer local op)
  2. peer binary (older op)
  3. peer cursor=M < N (rejected by client's max-take logic)

Client persists cursor=N. If the client then disconnects before
applying the peer binary, reconnect with since=N replays nothing
(server has nothing > N) and the older peer op is lost forever
to this client's Y.Doc.

Fix: writeLoop now processes self events too — skipping the
binary echo (the originator already has Y.Doc state) but routing
the cursor frame through the same FIFO bus channel as peer ops.
The originator's cursor=N now arrives strictly AFTER all
older-id peer events on the same channel.

Per Codex round 23 [P1] of TASK-1319.
2026-05-09 21:45:46 -04:00

120 lines
5.3 KiB
Go

// Package collab provides the server-side substrate for real-time
// collaborative editing of item content via Yjs (PLAN-1248).
//
// The package centers on a "dumb relay" model: the server never
// understands the Y.Doc structure or applies CRDT operations on its
// own — it only persists raw binary updates (in the op-log added by
// TASK-1252) and broadcasts them to peers connected to the same item.
// All CRDT logic lives in the browser via @tiptap/y-tiptap.
//
// Wiring:
//
// WebSocket handler (TASK-1254) ─┐
// │ Publish(OpEvent)
// ▼
// ┌──────┐
// │ OpBus│ ← in-process MemoryOpBus
// └──────┘ (RedisOpBus is a future
// ▲ drop-in for multi-replica
// │ deploys, deferred IDEA)
// Subscribe(itemID)
// │
// WebSocket handler (other peer) ┘
//
// OpBus only handles the broadcast leg. Persistence (op-log append,
// snapshot rebuild) lives in the room manager (TASK-1255) so the bus
// stays focused on fan-out.
package collab
// OpEvent is a single message broadcast across an item's collab room.
//
// Fields:
// - ItemID target item; the bus filters subscribers by this.
// - ClientID Yjs client id of the originating peer. The dumb relay
// does not interpret it, but the designated-applier
// election (TASK-1257) uses it to break ties when
// multiple peers could supply a markdown snapshot.
// - Type coarse classifier — "sync" carries Y.Doc updates that
// must be persisted to the op-log; "awareness" carries
// cursor/presence ephemera that must NOT be persisted.
// - Data raw y-protocol message; opaque to the server.
// - Timestamp UnixMilli when the message entered the bus. Set
// automatically on Publish if zero.
type OpEvent struct {
ItemID string
ClientID uint64
Type string
Data []byte
Timestamp int64
// OpLogID is the persisted item_yjs_updates.id assigned by
// AppendYjsUpdate when this event was a sync frame. Zero for
// awareness frames (never persisted) and for the rare sync
// frame whose append failed (logged at error; broadcast still
// fires so the live mesh stays consistent). Used by writeLoop
// to piggyback an op_log_cursor control frame after the binary
// frame so peers track their applied-cursor without a round
// trip. Per TASK-1319.
OpLogID int64
}
// OpEvent.Type values. Kept narrow on purpose — the server should not
// need to know about more than the handful of categories that change
// its own behavior (persist vs broadcast-only). New y-protocol message
// types should map to one of these unless the server actively needs to
// distinguish them.
const (
// OpTypeSync carries a Y.Doc binary update. Persisted to the
// op-log (TASK-1252) on broadcast so reconnecting peers can
// replay since their last cursor.
OpTypeSync = "sync"
// OpTypeAwareness carries cursor / selection / presence info
// (CollaborationCursor extension, TASK-1264). Broadcast only —
// NEVER persisted, since presence is meaningless after the
// originating client disconnects.
OpTypeAwareness = "awareness"
)
// OpBus is the cross-instance pub/sub interface for collab broadcasts.
// MemoryOpBus is the production implementation for single-instance
// deployments (every shipping target today: self-hosted single Go
// binary, pad-cloud single replica). A future RedisOpBus would
// implement the same interface for multi-replica fanout — that's
// scoped as a separate IDEA filed at PLAN-1248 close, since the
// dumb-relay design intentionally keeps Redis off the self-host
// dependency surface.
//
// Channels returned by Subscribe MUST be drained promptly. Slow
// subscribers — peers whose channel buffer fills before the consumer
// reads — have new events dropped (with a warning log) rather than
// blocking the publisher. This mirrors internal/events.MemoryBus and
// keeps the broadcast loop responsive even when one socket is
// momentarily backed up by the OS write buffer.
type OpBus interface {
// Subscribe registers a subscriber for the given item and returns a
// buffered channel of OpEvents. Caller is responsible for calling
// Unsubscribe (typically in a defer) so the channel is closed and
// the slot reclaimed.
Subscribe(itemID string) chan OpEvent
// Unsubscribe removes a subscriber and closes its channel. Safe to
// call with an already-removed channel — no-op in that case.
Unsubscribe(ch chan OpEvent)
// Publish broadcasts an event to every subscriber whose itemID
// matches event.ItemID. Non-blocking: full subscriber channels
// drop the event with a warning. event.Timestamp is set to
// time.Now().UnixMilli() if zero.
Publish(event OpEvent)
// SubscriberCount returns the number of active subscribers for the
// given item. Useful for room-manager TTL accounting (TASK-1255 —
// when the count hits 0, the room enters its 60s grace window).
SubscriberCount(itemID string) int
// Close shuts down the bus and closes every active subscriber
// channel. After Close, Subscribe / Publish are undefined; callers
// must not invoke them.
Close()
}