mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
1dbe04399a
* fix(store): optimistic concurrency for collection settings writes (BUG-2265) Collection-level settings (e.g. quick_actions) were written by reconstructing the whole settings JSON from a caller's local Collection snapshot, and UpdateCollection replaced the column with no concurrency check. Two ItemDetails in the same collection (full-page pane host master + pane) hold independent snapshots and clobbered each other. Mirror the item optimistic-concurrency pattern (IDEA-1480): add CollectionUpdate.ExpectedUpdatedAt; when set, UpdateCollection re-reads updated_at atomically under the workspace write lock (SQLite BEGIN IMMEDIATE / Postgres advisory xact lock) and returns CollectionUpdateConflictError on a mismatch. Empty token keeps the legacy last-write-wins path unchanged for CLI/MCP/API callers. No DB migration — reuses collections.updated_at. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * feat(server): collection.updated broadcast + 409 conflict mapping (BUG-2265) - handleUpdateCollection boundary-validates expected_updated_at (400 on a malformed token) and maps store.CollectionUpdateConflictError to the shared update_conflict envelope (HTTP 409) — byte-identical wire shape to the item path, via the extracted writeUpdateConflictEnvelope helper. - Add the collection_updated EventBus type and publish it after a successful update so sibling ItemDetails / collection pages refresh their independent Collection snapshot proactively, shrinking the 409 window. Routed by Collection (slug) through the existing SSE visibility filter. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): 409-aware collection settings writes + sibling refresh (BUG-2265) - CollectionUpdate carries expected_updated_at; add isUpdateConflictError. - QuickActionsMenu sends the token and, on a 409, refetches the collection, re-appends the new action onto the FRESH settings, and retries once — no silent loss, no user-visible error. - EditCollectionModal captures the token at open-time (edge-gated seed so a concurrent broadcast can't wipe in-progress edits) and shows a non-destructive "changed elsewhere, reload" message on 409 rather than auto-merging a full-form edit. - Subscribe to collection_updated over SSE: ItemDetail and the collection page refresh their own Collection snapshot (gen/slug-fenced against the persistent pane host's no-remount switch), so siblings converge before the next save. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(store): harden collection optimistic concurrency + web fetch ordering (BUG-2265, Codex round 1) Address Codex review findings: - [P1] same-second clobber: now() is one-second precision, so two guarded writes in the same second kept an identical token. The accepted write now advances updated_at strictly past the token (only when now() hasn't already moved on), making a stale-token replay deterministically conflict. Add a same-second regression test. - [P1] tokenless-writer race on Postgres: the advisory lock only serialized writers that also took it. Replace it with a `FOR UPDATE` row lock on the in-tx re-read (Postgres) — SQLite's BEGIN IMMEDIATE already serializes every writer — so a concurrent tokenless UpdateCollection can't slip between the re-read and the UPDATE. - [P2] rename broadcast: only publish collection_updated when the slug is unchanged. A rename's old-slug event would make siblings refetch a dead slug (404) and a new-slug event can't reach old-slug visibility snapshots; renames are handled by the existing navigation path. - [P2] out-of-order refreshes: ItemDetail and the collection page now use a dedicated monotonic refresh counter so two rapid collection_updated fetches can't resolve out of order and clobber newer state (loadSeq/loadGeneration only bump on route/item loads). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(store): make collection updated_at strictly monotonic for ALL writes (BUG-2265, Codex round 2) The previous same-second advance ran only on guarded updates, so a tokenless UpdateCollection could write the current second over a forced expected+1s, regressing the concurrency token and letting a stale guarded client clobber newer data (Codex P1). Route every collection update through one small transaction that re-reads updated_at (FOR UPDATE on Postgres; SQLite BEGIN IMMEDIATE covers it) and derives the new timestamp atomically: strictly advance past the row's current value when now() hasn't already moved on. This makes updated_at a reliable concurrency token for guarded AND tokenless writers. Add a tokenless-monotonic regression test. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix: close remaining collection-concurrency gaps (BUG-2265, Codex round 3) - [P1] Board column reordering (handleGroupReorder) rebuilt the full schema from a stale local snapshot and wrote it with no token — a lost-update path identical to the bug being fixed. Now sends expected_updated_at and, on 409, refetches, re-applies the reorder onto the fresh schema, and retries once. - [P2] The workspace settings page seeded EditCollectionModal from a page-load-time collections list, so a change that predated editing produced a false 409. It now refreshes the list on collection_updated (seq-guarded). - [P2] collection.updated is now delivered to item-grant-only SSE subscribers for collections they can see — it's itemless but leak-free (only the slug), so guests' ItemDetail schema/settings snapshots converge too. Filter test extended. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): switch-safety + conflict-merge fixes for collection writes (BUG-2265, Codex round 4) - Board column reorder now ABORTS on a 409 (with a "reorder again" toast) instead of replaying a stale option order onto the fresh field, which would silently drop a concurrent option add/remove/rename. Reordering is cosmetic; never worth clobbering a real schema edit. Also captures ws/slug/base before the await and fences the write against a route switch. - QuickActionsMenu captures workspace + collection identity BEFORE the first await, so a mid-save navigation can't make the 409 refetch/retry target the wrong collection (no guaranteed remount). - Settings-page SSE refresh captures the workspace and drops the result if the workspace changed while fetching, so a slow refresh for workspace A can't overwrite workspace B's freshly loaded collection list. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(store): sub-second collection updated_at token, no future drift (BUG-2265, confirming pass #6) The same-second monotonic advance manufactured whole-second FUTURE updated_at values; sustained >1 write/sec on one collection drifted arbitrarily ahead of wall-clock. collections.updated_at is TEXT on both dialects and never compared lexically (only via time.Equal + display), so switch the update write to sub-second nowNano(): same-second collisions become near-impossible, so the token advances naturally. Keep a strict-monotonicity guard but step by a single NANOSECOND on the (now near-impossible) coarse-clock/step-back collision, so any drift is bounded to nanoseconds. Dual-dialect; covered by make test-pg. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(server): sanitize + always-broadcast collection event (BUG-2265, confirming pass #2,#3) - #2 (P1): collection_updated is delivered to item-grant guests, but the event carried ActorName/Source, leaking the owner's identity + edit source. Strip them — publishCollectionEvent now emits workspace + slug (+ new_slug) only. - #3 (P2): always broadcast (including on rename), routed by the OLD slug and carrying the NEW slug via a new Event.NewSlug field, so remote tabs on the old slug can re-target instead of silently 404ing on their next action. Tests: assert no actor/source leak on a settings update; assert a rename routes by old slug + carries new_slug. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): decisive switch-safety + rename handling for collection writes (BUG-2265, confirming pass #1,#3,#4,#5) - #1 (P1): EditCollectionModal captures the target collection id/slug/name/ws + updated_at when the form is SEEDED, and handleSave/handleArchive now operate on that captured identity (not the live props). The seed effect re-seeds when the collection IDENTITY changes (not on a same-id broadcast refresh), so a reused route can't leave A's form saving/deleting to B. - #3 (P2): on a rename event the collection route navigates to the new slug (preserving the pane query) and ItemDetail refetches by new_slug; the SSE event type carries new_slug. - #4 (P2): the reorder-conflict path refetches the collection (reseeds the token) before prompting, so a missed SSE event doesn't make every retry 409 forever. - #5 (P2): QuickActionsMenu only invokes oncollectionupdated when the live workspace/slug still match the captured identity, so a reused route can't assign an old response to the newly-navigated page. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(server): nano token round-trip, rename visibility, publish-before-migration (BUG-2265, confirming pass 2) Address the round-2 confirming-pass findings (server-only): 1. (P2) The shared update_conflict envelope formatted actual_updated_at with second precision (time.RFC3339), truncating the now sub-second collection token so the client's retry token never matched — a permanent 409 loop. Format with time.RFC3339Nano. Item tokens are zero-nanosecond, so RFC3339Nano emits no fractional part — the item 409 wire shape is byte-identical and the item path still compares via time.Equal. Added a test that the returned token round-trips as a usable retry token. 2. (P2) Rename events are routed by the OLD slug, but a subscriber that revalidated after the rename only has the NEW slug in visibleSlugSet, so the visibility check dropped the event before the new_slug branch. Accept a rename when EITHER the old slug or the (authorized) NewSlug is visible; downstream item-grant gating uses whichever slug is visible. Filter test extended. 3. (P2) The collection_updated event was published only after field migrations succeeded, but UpdateCollection already committed (updated_at advanced). On a migration failure clients got a 500 and no refresh, leaving siblings with stale tokens that 409 blindly. Publish on the commit (before the migration), so siblings always resync regardless of migration outcome. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix: atomic collection update+migration; modal same-id rename retarget (BUG-2265, confirming pass 3) Address the round-3 confirming-pass findings; defer cross-tab rename RE-NAVIGATION to BUG-2272 (placeholder) per coordinator. 1. (P1) Migration atomicity. UpdateCollection committed the schema + concurrency token BEFORE MigrateItemFieldValues ran, so a migration failure returned 500 with the row already changed → the retry was guaranteed to 409 and item values were left inconsistent with the committed schema. Made the two ATOMIC: extracted applyFieldMigrationsTx and run it inside UpdateCollection's own transaction (after taking the workspace seq lock), so a migration failure rolls back the schema AND the token — nothing changes, the retry works. The handler now passes migrations through instead of running them separately, and publishes the event only after the fully-atomic commit. store/tx work → make test-pg run green. 2. (P2) EditCollectionModal same-id rename. The round-1 identity capture ignores same-id prop refreshes (to preserve edits), but a concurrent RENAME changes the slug (not the id), so handleSave/handleArchive PATCHed a dead slug → 404 before the token could 409. On a same-id prop change whose slug changed, the seed effect now retargets the endpoint slug + re-captures the token WITHOUT reseeding the form (in-progress edits preserved). Deferred (BUG-2272, TODO comments added, already broken on main — no regression): - ItemDetail full-page item URL/collSlug not retargeted after a remote rename. - Collection route chained-rename events during SSE replay landing on a dead intermediate slug. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): keep seeded token on same-id rename retarget (BUG-2265) On the EditCollectionModal same-id rename branch, retarget the endpoint (slug/name/ws) only — drop the token re-capture. Re-capturing let a later handleSave succeed against the renamed collection and apply the modal's stale pre-rename full form, silently REVERTING the concurrent rename (the exact stale-snapshot clobber BUG-2265 prevents). Keeping the seeded token means a concurrent rename correctly yields a 409 → the non-destructive "collection changed, reload" message. Slug-retarget without token-recapture gives both: no 404 (right URL) and no clobber (409 fires). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix: lock-order deadlock + unified collection-snapshot fences (BUG-2265, confirming pass 4) 1. (P1) DEADLOCK regression. UpdateCollection's atomic migration path took the collection-row FOR UPDATE lock and THEN the workspace seq lock, but item creation takes them in the reverse order (workspace advisory lock first, then the collection-row FK lock on INSERT) — a concurrent item-create + schema-migration ABBA-deadlocks on Postgres. Fix: acquire the workspace seq lock BEFORE the collection-row FOR UPDATE (matching item-create's order). Every store path that locks both now takes them workspace-seq → collection-row (tryCreateItem, UpdateItem, MigrateItemFieldValues, UpdateCollection). Added a concurrency regression test (item-create racing schema-migration); make test-pg green. 2+3. (P2) Cross-generation fence gap. The SSE collection refresh and route/item loads used SEPARATE counters, so a stale in-flight load could complete after a fresh SSE refresh and revert the collection + its concurrency token. Unified to a SINGLE monotonic collection-snapshot generation in BOTH the collection route and ItemDetail — every collection-snapshot write (loadCollection/loadData, the SSE refresh, reorder, and the quick-action/edit-modal callbacks) bumps it on start and gates its assignment on "still latest". ItemDetail's load keeps a switch-escape so a stale refresh for the OLD collection can't block loading a NEW one. Settings page unified the same way over its collections-list writes. 4. (P2) Settings page fed a stale editingCollection to the edit modal after a remote rename (its prop never changed → the same-id-rename retarget never fired → 404). The unified refresh now re-points editingCollection at the refreshed object for the same id, so the modal's retarget fires. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix: emit item-changes signal on field migration; QuickActions retry-by-id (BUG-2265, confirming pass 5) 1. (P1) A collection update that runs a field migration mutates item `fields` JSON and advances item `seq`, but only collection_updated was published — open item views refreshed collection METADATA and returned without reconciling the migrated items, so clients kept stale field JSON under the new schema and a later full-fields item update could UNDO the migration (a clobber). UpdateCollection now returns the migrated-item count; when > 0 the handler ALSO emits the existing bulk item-mutation signal (items_bulk_updated, Op=migrate) so open views reconcile via /items-changes. Fires only when the migration touched >= 1 item — a pure settings/quick-actions update emits nothing extra. No store SQL/locking change (Go signature + count plumbing only); make test / make test-pg both green. 2. (P2) QuickActionsMenu's 409 retry GET-by-slug 404s if the competing update renamed the collection. Resolve the fresh collection by STABLE id (list + find by id) before re-appending + retrying, mirroring EditCollectionModal's identity approach; the result-propagation guard is now id-based too so a rename doesn't spuriously drop it. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix: uniform sweep of item-grant delivery, rename routing, and 409/404 retries (BUG-2265, confirming pass 6) One pattern-sweep instead of per-site patches. Audited every event this PR publishes and every client retry path, applying three patterns uniformly: PATTERN A (item-grant SSE reconcile) + B (old-slug rename routing): instead of a SEPARATE items_bulk_updated migration event (which carries op/count for items an item-grant subscriber can't see and isn't rename-routed), FOLD a SANITIZED `items_changed` bool onto collection_updated — already item-grant-delivered (round 3) and already old-slug-routed with new_slug (round 2). On it the client triggers a /items-changes deltaSync (server-filtered to the caller's grants) and ItemDetail refetches its open item, so item-grant EDITORS reconcile migrated field JSON — closing the clobber where a stale full-fields update would UNDO the migration. Leak surface: "a collection you can see items in changed [+ renamed + had item changes]" — a bool, no per-item data. Removed the round-5 items_bulk_updated publish. The pre-existing items_bulk_updated (archive/move) is untouched and correctly stays suppressed for item-grant users. PATTERN C (409 AND 404 in retries): a competing RENAME can 404 a slug-targeted write before it can 409, bypassing recovery. Added isNotFoundError / isConflictOrNotFound helpers; every write/retry path now treats BOTH: QuickActions save resolves-by-id and retries on either; board reorder reseeds-by-id and aborts on either; EditCollectionModal save shows the reload prompt and archive resolves-by-id and retries on either. Tests: server asserts collection_updated sets items_changed on migration (not on settings-only) and stays sanitized; the SSE-filter test asserts the migration variant reaches item-grant subscribers for a visible collection; web unit tests assert 404/409 classification and a real component-driven not_found -> resolve- by-id -> retry in QuickActionsMenu. make test / make test-pg / npm run test all green. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix: stable collection-ID identity for collection events + request-based items_changed (BUG-2265, confirming pass 7) 1. (P1) Collection events were identified only by MUTABLE, reusable slugs, and events replay — so a stale rename event's old slug, once re-owned by a DIFFERENT collection, could pass a slug-based match and misroute a client (navigate away / load the wrong schema) or leak the new slug. Fix at the ROOT: carry the STABLE CollectionID on collection_updated (Event.CollectionID) and match by ID everywhere: - Server visibility: sseEventVisibleFor matches collection_updated on a new visibleCollIDSet (built from the same VisibleCollectionIDs), not the slug — so an event for a collection the subscriber can't see by ID is dropped even if its (reused) slug is in visibleSlugSet. Filter test proves the slug-reuse drop. - Clients: ItemDetail and the collection route match `event.collection_id === <their collection>.id`, not slug. Slug(s)/new_slug stay only for the rename-navigation URL. Settings refreshes its whole list (already id-safe). 2. (P1) items_changed was keyed off the affected-ROW count, delivered to item-grant subscribers → a subscriber whose own items were unaffected could infer that HIDDEN items matched the migrated value. Now keyed off whether a field MIGRATION WAS REQUESTED (len(input.Migrations) > 0), independent of row count — leaks nothing about hidden item values. Reverted round-5's UpdateCollection count-return (no longer needed). Test: a migration matching ZERO items still sets items_changed. Deferred with markers: - NOTE(BUG-2273) at ItemDetail's reconcile-skip AND updateField: the web editor's full-fields field write lacks item-level OCC (never adopted IDEA-1480/v0.14), so the migration reconcile is best-effort. - TODO(BUG-2272) at the reorder 404 reseed: it refreshes `collection` but not the route `collSlug` (renavigation, deferred). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix: archive OCC (no destructive wrong-target) + settings load fence (BUG-2265, confirming pass 8) 1. (P1) EditCollectionModal handleArchive resolved the target by stable id but the server DELETE re-resolves by the MUTABLE slug — a rename that re-owned that slug before the delete landed would archive the WRONG collection. Close the TOCTOU with an expected_updated_at OCC on the delete, mirroring the update OCC: DeleteCollection re-reads updated_at under a lock (FOR UPDATE on Postgres) and 409s on mismatch; the handler validates the token + maps the 409; the client sends it as a query param; handleArchive passes the seeded token (and the fresh token on the resolve-by-id retry). A reused slug or a concurrently-changed target now yields a clean 409 → the reload message, never a wrong-collection archive. Server test: stale token 409s (and the collection survives); current token 204s; malformed 400s; no token 204s. 2. (P2) settings load(): the generation was bumped AFTER awaiting setCurrent, so a slow load for workspace A could resume after B's load and clobber B's name/context/collections/members. Capture a dedicated loadGen at load() ENTRY (before any await) and fence EVERY continuation on it; the collections write additionally respects collectionsGen so it can't revert a fresher SSE refresh. Using a dedicated loadGen (not the SSE-shared collectionsGen) means an SSE collections-refresh mid-load doesn't drop the name/members writes. Deferred: TODO(BUG-2272) at the collection route's rename-navigation site — the global collectionStore (sidebar/pickers) isn't refreshed and the workspace layout ignores collection_updated, so the sidebar keeps the dead slug. Layout- level renavigation, deferred. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): dedicated item-snapshot fence + id-based rename comparisons in ItemDetail (BUG-2265, confirming pass 9) One comprehensive ItemDetail async-snapshot fence sweep so this file's item/ collection fencing is uniform and ID-based. 1. (P1) The migration item-refetch and loadData shared loadGeneration, so the refetch could apply migrated fields and then a stale loadData response overwrite them (a later full-fields edit then undoes the migration). Added a DEDICATED itemGen (separate from loadGeneration and collectionGen), bumped at the start of BOTH loadData's item load AND the migration refetch, and gated BOTH `item = ` writes on "still latest itemGen" — neither can stale-overwrite the other. Swept the other PASSIVE item snapshot-refreshes onto itemGen too (SSE item_updated/archived/restored, onSync deleted/incremental/full, the collab refresh) so they're ordered against each other and the migration/load. 2. (P2) A settings update that follows a rename before the rename fetch completes requested the OLD slug and bumped collectionGen, cancelling the valid rename fetch. Fetch slug is now `event.new_slug || event.collection || slug`. 3. (P2) The loadData collection fence-escapes compared the stale load's SLUG vs the freshly-renamed snapshot's slug (they differ on a rename → escape let the stale result overwrite). They now compare stable collection IDs; the SSE refresh's post-fetch identity check is id-based too. Audit (site -> generation -> id?): every PASSIVE snapshot-refresh (loadData item+collection, migration refetch, SSE x3, onSync x3, collab) bumps the correct dedicated gen (item->itemGen, collection->collectionGen) and compares identity by id. The DELIBERATE user/action writes (title/field/tag/assignee/role/content/ link/version/restore saves) keep loadGeneration + item-id switch-safety; their item-snapshot concurrency vs the migration refetch is the deferred item-OCC gap (BUG-2273, best-effort) — reordering them last-started-wins is orthogonal to that. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
402 lines
13 KiB
Go
402 lines
13 KiB
Go
package events
|
|
|
|
import (
|
|
"log/slog"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// Event types
|
|
const (
|
|
DocumentCreated = "document_created"
|
|
DocumentUpdated = "document_updated"
|
|
DocumentArchived = "document_archived"
|
|
DocumentRestored = "document_restored"
|
|
WorkspaceUpdated = "workspace_updated"
|
|
|
|
// Item events (v2)
|
|
ItemCreated = "item_created"
|
|
ItemUpdated = "item_updated"
|
|
ItemArchived = "item_archived"
|
|
ItemRestored = "item_restored"
|
|
|
|
// Collection events. Emitted when a collection's own row changes
|
|
// (settings/schema/name/icon, e.g. a quick-action added). Routed by
|
|
// Collection (slug) through the SSE visibility filter so sibling
|
|
// ItemDetails / collection pages refresh their independent Collection
|
|
// snapshot proactively — shrinking the optimistic-concurrency (409)
|
|
// window (BUG-2265).
|
|
CollectionUpdated = "collection_updated"
|
|
|
|
// Comment events
|
|
CommentCreated = "comment_created"
|
|
CommentUpdated = "comment_updated"
|
|
CommentDeleted = "comment_deleted"
|
|
|
|
// Reaction events
|
|
ReactionAdded = "reaction_added"
|
|
ReactionRemoved = "reaction_removed"
|
|
|
|
// Star events
|
|
ItemStarred = "item_starred"
|
|
ItemUnstarred = "item_unstarred"
|
|
|
|
// Composite events
|
|
ItemUpdatedWithComment = "item_updated_with_comment"
|
|
|
|
// Batch events. Emitted once for a whole bulk mutation (TASK-1668)
|
|
// instead of one ItemUpdated/ItemArchived per row — the lane-header
|
|
// bulk actions (archive/move/tag/untag/set-priority/assign all) can
|
|
// touch a whole filtered lane, so per-item fan-out would flood both
|
|
// SSE subscribers and webhooks.
|
|
ItemsBulkUpdated = "items_bulk_updated"
|
|
)
|
|
|
|
// Default replay buffer settings.
|
|
const (
|
|
DefaultReplayBufferSize = 1024 // max events to retain per workspace
|
|
DefaultReplayMaxAge = 5 * time.Minute // discard events older than this
|
|
)
|
|
|
|
// Event represents a real-time event published when state changes occur.
|
|
type Event struct {
|
|
ID int64 `json:"id"`
|
|
Type string `json:"type"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
DocumentID string `json:"document_id,omitempty"`
|
|
ItemID string `json:"item_id,omitempty"`
|
|
// CollectionID is the STABLE collection identity on a collection_updated
|
|
// event (BUG-2265). Slugs are mutable and reusable and events replay, so a
|
|
// stale rename event's OLD slug could be re-owned by a different collection;
|
|
// clients therefore match these events by CollectionID, not the slug (which
|
|
// stays only for rename-navigation URLs). Empty on non-collection events.
|
|
CollectionID string `json:"collection_id,omitempty"`
|
|
Collection string `json:"collection,omitempty"`
|
|
// NewSlug carries a collection's NEW slug on a collection_updated event
|
|
// that is a rename (BUG-2265). The event is routed by Collection (the OLD
|
|
// slug, which the sibling tabs still address) so old-slug watchers receive
|
|
// it and can re-target to NewSlug. Empty for non-rename updates.
|
|
NewSlug string `json:"new_slug,omitempty"`
|
|
// ItemsChanged is set on a collection_updated event when a field MIGRATION
|
|
// WAS REQUESTED (a schema change carrying migrations), independent of how
|
|
// many rows actually changed (BUG-2265 Codex round 7). It's a SANITIZED
|
|
// reconcile signal — a bare bool carrying NO per-item data and revealing
|
|
// nothing about hidden item values — so it can be delivered to item-grant
|
|
// subscribers; their client triggers a /items-changes deltaSync
|
|
// (server-filtered to their grants) to pick up the migrated field JSON.
|
|
ItemsChanged bool `json:"items_changed,omitempty"`
|
|
Title string `json:"title,omitempty"`
|
|
DocType string `json:"doc_type,omitempty"`
|
|
Actor string `json:"actor,omitempty"`
|
|
ActorName string `json:"actor_name,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
UserID string `json:"user_id,omitempty"` // For user-scoped events (e.g. star/unstar)
|
|
Timestamp int64 `json:"timestamp"`
|
|
// Seq is the workspace-scoped monotonic mutation cursor of the
|
|
// item the event references (PLAN-1343 / TASK-1352). Populated
|
|
// for item lifecycle events (created / updated / archived /
|
|
// restored) so the local-first read model (TASK-1358) can apply
|
|
// the change in-place when the seq is contiguous with the
|
|
// client's cursor, or trigger a /items-changes backfill when
|
|
// there's a gap. Zero for non-item events (workspace_updated,
|
|
// comment_*, reaction_*) and for legacy publishers that
|
|
// haven't been upgraded.
|
|
Seq int64 `json:"seq,omitempty"`
|
|
// Op / Count describe a batch event (ItemsBulkUpdated, TASK-1668).
|
|
// Op is the verb applied (archive/move/tag/untag/set-priority/
|
|
// assign); Count is the number of items affected in this event's
|
|
// Collection. Zero/empty for single-item events.
|
|
//
|
|
// A batch event is scoped to ONE Collection (the bulk endpoint emits
|
|
// one per affected collection) so the SSE visibility filter routes
|
|
// it like any collection-scoped event. It deliberately carries NO
|
|
// per-item IDs: a batch can't be item-grant-filtered for guests on a
|
|
// broadcast bus, so IDs would leak. Recipients react by running a
|
|
// /items-changes delta, which IS visibility-filtered server-side;
|
|
// Seq holds the max seq across the batch as the reconcile cursor.
|
|
Op string `json:"op,omitempty"`
|
|
Count int `json:"count,omitempty"`
|
|
}
|
|
|
|
// EventBus is the interface for pub/sub event distribution.
|
|
// Implementations include MemoryBus (in-process) and RedisBus (cross-instance).
|
|
type EventBus interface {
|
|
// Subscribe registers a new subscriber for the given workspace.
|
|
// Returns a buffered channel that will receive events for that workspace.
|
|
Subscribe(workspaceID string) chan Event
|
|
|
|
// SubscribeIfAllowed atomically checks the global and per-workspace
|
|
// subscriber limits and, only if both are satisfied, subscribes in the
|
|
// same critical section. Returns (ch, true) on success or (nil, false)
|
|
// when a limit would be exceeded. Pass 0 for either limit to disable it.
|
|
SubscribeIfAllowed(workspaceID string, maxGlobal, maxPerWorkspace int) (chan Event, bool)
|
|
|
|
// Unsubscribe removes a subscriber and closes its channel.
|
|
Unsubscribe(ch chan Event)
|
|
|
|
// Publish sends an event to all subscribers for the event's workspace.
|
|
Publish(event Event)
|
|
|
|
// EventsSince returns events for a workspace with IDs greater than sinceID.
|
|
// Used to replay missed events on SSE reconnect (Last-Event-ID).
|
|
// Returns nil if sinceID is too old and has been evicted from the buffer.
|
|
EventsSince(workspaceID string, sinceID int64) []Event
|
|
|
|
// Close shuts down the event bus and cleans up resources.
|
|
Close()
|
|
|
|
// SubscriberCount returns the number of active local subscribers.
|
|
SubscriberCount() int
|
|
|
|
// WorkspaceSubscriberCount returns the number of active subscribers
|
|
// for a specific workspace.
|
|
WorkspaceSubscriberCount(workspaceID string) int
|
|
}
|
|
|
|
// replayBuffer is a bounded ring buffer of recent events for a single workspace.
|
|
// It supports efficient append and replay-since-ID queries.
|
|
type replayBuffer struct {
|
|
events []Event
|
|
size int // max capacity
|
|
head int // next write position
|
|
count int // current number of events
|
|
}
|
|
|
|
func newReplayBuffer(size int) *replayBuffer {
|
|
return &replayBuffer{
|
|
events: make([]Event, size),
|
|
size: size,
|
|
}
|
|
}
|
|
|
|
// append adds an event to the ring buffer, evicting the oldest if full.
|
|
func (rb *replayBuffer) append(e Event) {
|
|
rb.events[rb.head] = e
|
|
rb.head = (rb.head + 1) % rb.size
|
|
if rb.count < rb.size {
|
|
rb.count++
|
|
}
|
|
}
|
|
|
|
// since returns all buffered events with ID > sinceID, in chronological order.
|
|
// Returns nil if sinceID is older than the oldest buffered event AND the buffer
|
|
// is full (i.e. events have been evicted), meaning we can't guarantee completeness.
|
|
// Returns an empty (non-nil) slice if sinceID is current (no missed events).
|
|
// A sinceID of 0 means "give me everything in the buffer".
|
|
func (rb *replayBuffer) since(sinceID int64) []Event {
|
|
if rb.count == 0 {
|
|
return []Event{}
|
|
}
|
|
|
|
// Find the oldest event in the buffer
|
|
oldest := (rb.head - rb.count + rb.size) % rb.size
|
|
oldestID := rb.events[oldest].ID
|
|
|
|
// Find the newest event in the buffer.
|
|
newest := (rb.head - 1 + rb.size) % rb.size
|
|
newestID := rb.events[newest].ID
|
|
|
|
// If sinceID is beyond the newest event we have, the ID came from a
|
|
// different sequence (e.g., a different instance in a Redis deployment).
|
|
// We can't determine what was missed — signal a gap.
|
|
if sinceID > newestID {
|
|
return nil
|
|
}
|
|
|
|
// If the requested ID is older than our oldest AND the buffer has wrapped
|
|
// (events were evicted), we can't guarantee completeness — signal a gap.
|
|
// But if the buffer hasn't filled up yet, all events are still present.
|
|
if sinceID > 0 && sinceID < oldestID && rb.count == rb.size {
|
|
return nil
|
|
}
|
|
|
|
// Collect events with ID > sinceID
|
|
var result []Event
|
|
for i := 0; i < rb.count; i++ {
|
|
idx := (oldest + i) % rb.size
|
|
if rb.events[idx].ID > sinceID {
|
|
result = append(result, rb.events[idx])
|
|
}
|
|
}
|
|
if result == nil {
|
|
result = []Event{}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// subscriber wraps a channel with its workspace filter.
|
|
type subscriber struct {
|
|
ch chan Event
|
|
workspaceID string
|
|
}
|
|
|
|
// MemoryBus is an in-process pub/sub event bus that fans out events
|
|
// to all subscribers for a given workspace. Suitable for single-instance deployments.
|
|
type MemoryBus struct {
|
|
mu sync.RWMutex
|
|
subscribers map[chan Event]*subscriber
|
|
|
|
// Monotonic sequence counter for event IDs.
|
|
seq atomic.Int64
|
|
|
|
// Per-workspace replay buffers for Last-Event-ID support.
|
|
replayMu sync.RWMutex
|
|
replayBuffers map[string]*replayBuffer
|
|
replaySize int
|
|
replayMaxAge time.Duration
|
|
}
|
|
|
|
// New creates a new in-memory EventBus with default replay buffer settings.
|
|
func New() *MemoryBus {
|
|
return NewWithReplay(DefaultReplayBufferSize, DefaultReplayMaxAge)
|
|
}
|
|
|
|
// NewWithReplay creates a new in-memory EventBus with custom replay settings.
|
|
func NewWithReplay(bufferSize int, maxAge time.Duration) *MemoryBus {
|
|
return &MemoryBus{
|
|
subscribers: make(map[chan Event]*subscriber),
|
|
replayBuffers: make(map[string]*replayBuffer),
|
|
replaySize: bufferSize,
|
|
replayMaxAge: maxAge,
|
|
}
|
|
}
|
|
|
|
// Subscribe registers a new subscriber for the given workspace.
|
|
// Returns a buffered channel that will receive events for that workspace.
|
|
func (b *MemoryBus) Subscribe(workspaceID string) chan Event {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
ch := make(chan Event, 64)
|
|
b.subscribers[ch] = &subscriber{
|
|
ch: ch,
|
|
workspaceID: workspaceID,
|
|
}
|
|
return ch
|
|
}
|
|
|
|
// SubscribeIfAllowed atomically checks limits and subscribes.
|
|
func (b *MemoryBus) SubscribeIfAllowed(workspaceID string, maxGlobal, maxPerWorkspace int) (chan Event, bool) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if maxGlobal > 0 && len(b.subscribers) >= maxGlobal {
|
|
return nil, false
|
|
}
|
|
if maxPerWorkspace > 0 {
|
|
count := 0
|
|
for _, sub := range b.subscribers {
|
|
if sub.workspaceID == workspaceID {
|
|
count++
|
|
}
|
|
}
|
|
if count >= maxPerWorkspace {
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
ch := make(chan Event, 64)
|
|
b.subscribers[ch] = &subscriber{
|
|
ch: ch,
|
|
workspaceID: workspaceID,
|
|
}
|
|
return ch, true
|
|
}
|
|
|
|
// Unsubscribe removes a subscriber and closes its channel.
|
|
func (b *MemoryBus) Unsubscribe(ch chan Event) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
if _, ok := b.subscribers[ch]; ok {
|
|
delete(b.subscribers, ch)
|
|
close(ch)
|
|
}
|
|
}
|
|
|
|
// Publish sends an event to all subscribers for the event's workspace.
|
|
// Non-blocking: if a subscriber's channel is full, the event is dropped
|
|
// and a warning is logged. Events are assigned a monotonic sequence ID
|
|
// and stored in the replay buffer for Last-Event-ID support.
|
|
func (b *MemoryBus) Publish(event Event) {
|
|
if event.Timestamp == 0 {
|
|
event.Timestamp = time.Now().UnixMilli()
|
|
}
|
|
|
|
// Assign a monotonic sequence ID.
|
|
event.ID = b.seq.Add(1)
|
|
|
|
// Store in replay buffer for reconnect replay.
|
|
b.replayMu.Lock()
|
|
rb, ok := b.replayBuffers[event.WorkspaceID]
|
|
if !ok {
|
|
rb = newReplayBuffer(b.replaySize)
|
|
b.replayBuffers[event.WorkspaceID] = rb
|
|
}
|
|
rb.append(event)
|
|
b.replayMu.Unlock()
|
|
|
|
// Fan out to live subscribers.
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
|
|
for _, sub := range b.subscribers {
|
|
if sub.workspaceID != event.WorkspaceID {
|
|
continue
|
|
}
|
|
select {
|
|
case sub.ch <- event:
|
|
default:
|
|
slog.Warn("dropping event for slow subscriber", "type", event.Type, "workspace", event.WorkspaceID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// EventsSince returns buffered events for a workspace with IDs greater than sinceID.
|
|
// Returns nil if sinceID has been evicted from the buffer (gap too large).
|
|
// Returns an empty slice if the caller is fully caught up.
|
|
func (b *MemoryBus) EventsSince(workspaceID string, sinceID int64) []Event {
|
|
b.replayMu.RLock()
|
|
defer b.replayMu.RUnlock()
|
|
|
|
rb, ok := b.replayBuffers[workspaceID]
|
|
if !ok {
|
|
// No events ever published for this workspace.
|
|
return []Event{}
|
|
}
|
|
return rb.since(sinceID)
|
|
}
|
|
|
|
// Close shuts down the event bus by closing all subscriber channels.
|
|
// SSE handler goroutines will see the channel close and exit cleanly.
|
|
func (b *MemoryBus) Close() {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
|
|
for ch := range b.subscribers {
|
|
delete(b.subscribers, ch)
|
|
close(ch)
|
|
}
|
|
}
|
|
|
|
// SubscriberCount returns the number of active subscribers (for testing/debugging).
|
|
func (b *MemoryBus) SubscriberCount() int {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
return len(b.subscribers)
|
|
}
|
|
|
|
// WorkspaceSubscriberCount returns the number of active subscribers for a workspace.
|
|
func (b *MemoryBus) WorkspaceSubscriberCount(workspaceID string) int {
|
|
b.mu.RLock()
|
|
defer b.mu.RUnlock()
|
|
count := 0
|
|
for _, sub := range b.subscribers {
|
|
if sub.workspaceID == workspaceID {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|