Commit 790d93a
authored
feat(tables): live collaboration — cell-selection presence + live mutation propagation (#5957)
* feat(tables): live cell-selection presence — protocol + server + client hook
The realtime spine for Google-Sheets-style table presence (mode A, socket):
- @sim/realtime-protocol/table-presence: centralized wire protocol (events +
TableCellSelection {anchor, focus, editing} + payloads) so server emits and
client subscriptions can't drift.
- ROOM_TYPES.TABLE + resolveTableWorkspace registered in ROOM_WORKSPACE_RESOLVERS
(tableId -> workspace via userTableDefinitions, honoring archivedAt); roomName /
presenceEventName / disconnect cleanup / authorizeRoom all derive automatically.
- apps/realtime/src/handlers/tables.ts: join/leave (mirrors workspace-files) + a
table-cell-selection relay (mirrors the workflow selection channel), broadcasting
via roomName(room) since table rooms are namespaced. UserPresence gains a cell
field threaded through the memory + Redis managers (Lua ARGV[7], null clears).
- Extracted the duplicated resolveAvatarUrl into handlers/avatar.ts.
- use-table-room.ts client hook: joins over the shared socket, tracks the roster
(avatars) + patches per-socket cell deltas, exposes a throttled emitCellSelection.
Grid UI (avatars + selection overlay) lands next; concurrent cell-value edits
(last-write-wins via the durable log) are the follow-up PR.
* feat(tables): render live cell-selection presence in the grid
Wires the table presence room into the grid UI:
- Page (table.tsx): useTableRoom (gated off in embedded/mothership mode) —
renders <PresenceAvatars> in the header and passes remoteSelections +
emitCellSelection down to the grid.
- Grid emits its local selection: an effect resolves the index-based
anchor/focus to stable (rowId, columnId) via refs and broadcasts it (with an
editing flag for the active cell) through the throttled emitter.
- RemoteSelectionOverlay: draws each remote viewer's selection in their color
(getUserColor), a darker fill while editing, and name-on-hover — measured from
live cell rects in the content wrapper's space (scrolls with the grid),
hidden when rows are virtualized off-window, pointer-events-none so it never
blocks cell clicks (hover via pointer hit-test).
* test(tables): cover the table presence handler
Mirrors workspace-files.test.ts: join auth/unavailable/denied/success, plus the
cell-selection relay (asserts it persists via updateUserActivity and broadcasts
on the namespaced roomName, not the bare id) and leave.
* feat(tables): propagate manual cell edits live (last-write-wins)
A manual row edit now appends a lightweight 'edit' event to the durable table
stream; collaborators refetch the row (via the existing debounced rows-invalidate
the job events use) so the winning value shows live. The event carries no value —
peers refetch in their own wire format, so there's no auth-specific value
translation on the wire, and last-write-wins falls out of the DB's committed order
(the Google-Sheets model). Edits that also trigger a dispatch already emit
dispatch/cell events; the debounce coalesces the two.
* refactor(tables): apply /simplify findings
- Drop the dead 'add unknown peer' upsert branch in use-table-room (Socket.IO
ordering guarantees a peer is in the roster before their selection delta).
- TableCellSelectionBroadcast = TablePresenceUser & { cell } (was a copy-paste).
- Make TableGrid's presence props required + drop the unused empty-default/guard
(only table.tsx mounts it, always passing both).
- Drop the unused rowId from the 'edit' event (the handler invalidates all rows).
- Overlay: subscribe scroll/resize/pointer listeners once per scroll element and
cache the wrapper origin, so incoming deltas re-measure without re-subscribing
and the pointer hit-test never forces a per-move layout read.
- Server: cache the immutable socket session so a selection delta no longer reads
it from Redis every time.
* refactor(tables): apply /cleanup findings
- Fix the remote-selection name label contrast: text-white is unreadable on the
light-pastel user colors (same bug the Files caret fixed) → fixed dark #1a1a1a.
- Re-measure via useLayoutEffect so a moving peer selection updates before paint
(no one-frame position lag).
- Drop 'mothership' from a comment (constitution copy rule).
Six cleanup passes ran (effect, memo/callback, state, react-query, emcn, comment);
the rest confirmed clean — all state/memos/callbacks/effects are load-bearing,
presence correctly lives in useState (socket-pushed), and the edit→rows-invalidate
granularity is right.
* feat(tables): propagate every table mutation live (edit + schema signals)
Comprehensive live collaboration for all user table mutations, via two value-less
durable signals + named helpers (signalTableRowsChanged / signalTableSchemaChanged):
- edit (rows refetch): single + batch row create, cell/row update, batch update,
delete by id/filter, and upsert.
- schema (definition + rows refetch): column add/update/delete, workflow-group
add/update/delete, table rename, and CSV import (which can add columns).
- Client handles 'schema' by invalidating the table detail (exact) + rows.
Execution paths (column run, cancel-runs) and async jobs (delete/import-async,
job-cancel) already propagate via cell/dispatch/job events — verified applyJob
refetches on terminal. No reorder routes exist. Table archive (route DELETE) is a
deliberate follow-up: it needs a table-deleted redirect event, not a refetch signal
(which would 404).
* refactor(tables): apply comprehensive /cleanup audit findings
Holistic + react-query + comment audits over the whole PR:
- Security/crash fix: a remote peer's rowId flowed unescaped into the overlay's
querySelector — a hostile id ('x"]') threw SyntaxError inside a useLayoutEffect,
crashing every other viewer's page. CSS.escape it, and validate + whitelist the
untrusted cell payload server-side (shape + 200-char id bound) before it is
stored/rebroadcast.
- Simplify the CELL_SELECTION relay: the delta attached userId/userName/avatarUrl
that the client discarded (identity comes from the roster). Drop them + the
getUserSession lookup/cache entirely — the delta is now { socketId, cell }.
- React Query: schema handler also invalidates lists() (parity with the local
column-mutation set); document that the mutating client self-refetches by design.
- Comment tightenings; biome fixed a stale import order in workspace-files.ts.
* fix(tables): broadcast single-cell selections (focus falls back to anchor)
Cursor High: a normal cell click leaves selectionFocus null (the grid treats it as
a one-cell selection via focus ?? anchor), but the presence emit required BOTH anchor
and focus to resolve — so the most common selection never broadcast and clicking even
cleared a prior remote outline. Mirror the grid's focus ?? anchor semantics.
* fix(tables): reviewer + regression + per-LOC audit findings
Cursor review round (5 findings) + regression audit + per-LOC audit:
- Presence roster snapshot now KEEPS the cell we already hold for a known socket, so
a join/leave broadcast can't revert a fresher CELL_SELECTION delta.
- Reset the selection throttle on table switch (was unmount-only), so a pending
selection for table A can't flush into table B's room after a switch.
- Metadata writes (column widths, display) use a new lightweight 'metadata' signal
that refetches only the definition — a resize no longer forces peers to refetch rows.
- Overlay re-measures on row add/remove/reorder via a tbody childList MutationObserver
(a live refetch moves cells without a scroll/resize).
- Document the actor self-refetch create caveat (scrolled multi-page insert) accurately.
- isCellRef narrows to a partial instead of casting to the full type then re-checking;
drop a redundant mount measure() (the layout effect covers it); text-[11px]→text-xs.
* fix(tables): drop ineffective metadata propagation + re-measure overlay on column resize
Cursor round on b8f28b0:
- Remove the 'metadata' signal entirely. The grid seeds columnWidths/pinnedColumns
from metadata ONCE (metadataSeededRef) and deliberately never re-applies them (to
avoid clobbering a local in-progress resize), so refetching the definition on a peer
never surfaced their width/pin change — an ineffective path. Width/pin live-sync needs
reconciliation that doesn't clobber a local resize; that's a deliberate follow-up, not
a no-op refetch. Structural changes still propagate via 'schema'.
- Overlay now also observes the content layer with the ResizeObserver, so a column
resize (which grows the content, not the scroll container) re-measures remote outlines.
- Presence-merge comment now states both sides of the trade-off.
* fix(tables): re-broadcast local selection on (re)join
Cursor Medium: a selection made before the room join completes (or held across a
reconnect) was dropped server-side and never re-sent, so peers didn't see it until
the local user moved it again. Track the current selection in a ref (set on every
emit, cleared on table switch) and re-emit it from handleJoinSuccess once the room is
joined.
* fix(tables): re-broadcast selection when a peer's row change shifts it
End-to-end lifecycle audit (Low-Med): the selection emit resolved the stable
(rowId, columnId) only on selection/editing change, not when a live edit/schema
refetch inserted/deleted/reordered rows. The index-based local selection then sat
on a different logical row than the rowId peers held, so your outline showed on the
old row until you moved. Re-run the emit on rows/displayColumns change and dedup an
unchanged result (also drops the redundant null-on-open emit) so the broadcast stays
consistent with the local highlight.
* fix(tables): schema invalidates run-state/enrichment + guard stale join
Cursor round on cdc8796 (2 Medium):
- schema handler used detail exact:true, so it skipped the activeDispatches +
enrichmentDetails sibling queries the local invalidateTableSchema refreshes via a
prefix match. After a peer deletes/restructures a workflow group, peers could keep a
stale running badge or enrichment panel. Now invalidates both siblings too (rows stay
on the debounce).
- Guard against a stale join stealing the room: a fast table A->B switch could let A's
async authorize finish after B, leave B, and strand the socket in A. Added a
per-socket monotonic join generation checked after authorize (mirrors the file-doc
relay's guard) + a test.
* feat(tables): live column width/pin/order sync
Collaborators now see each other's column resizes, pins, and reorders live —
the last piece of Google-Sheets-style layout parity.
- New lightweight `metadata` durable event kind (distinct from `schema`): only the
table definition carries UI metadata, so peers refetch the definition alone — no
rows/run-state refetch. The metadata PUT route now signals it.
- The grid reconciles server metadata against its in-progress gesture: the column
being actively resized keeps its live local width, and an in-flight column drag
blocks a reorder apply — so a peer's change never reverts the local action. Each
field is reference-guarded (React Query structural sharing keeps unchanged
sub-objects stable), so an unrelated peer change doesn't re-apply the others.
* fix(tables): escalate to schema signal when a reorder scrubs group deps
Independent audit of the metadata-sync commit found a stale-run-state hole: a
columnOrder PUT that moves a column left of a workflow group's leftmost column
makes updateTableMetadata scrub that group's dependencies and write a new schema —
a real structural change. But the route only fired the lightweight 'metadata'
signal (detail-only refetch), so peers' and the actor's activeDispatches /
enrichmentDetails queries stayed stale (a lingering running badge / enrichment
panel) — exactly what the 'schema' handler exists to prevent.
updateTableMetadata now reports whether it scrubbed the schema; the route emits
signalTableSchemaChanged in that case and the light signalTableMetadataChanged
otherwise. Width/pin/plain-reorder stay on the cheap detail-only path.1 parent 45cb10b commit 790d93a
28 files changed
Lines changed: 1292 additions & 43 deletions
File tree
- apps
- realtime/src
- handlers
- rooms
- sim
- app
- api/table/[tableId]
- columns
- groups
- import
- metadata
- rows
- [rowId]
- upsert
- workspace/[workspaceId]/tables/[tableId]
- components/table-grid
- hooks
- lib/table
- packages
- platform-authz/src
- realtime-protocol
- src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
| 6 | + | |
6 | 7 | | |
7 | 8 | | |
8 | 9 | | |
| |||
17 | 18 | | |
18 | 19 | | |
19 | 20 | | |
| 21 | + | |
20 | 22 | | |
21 | 23 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
0 commit comments