35 — Real-time Sync & Live Collaboration (envisioning)
Envisioning doc — needs confirmation. Governed by
00-ROADMAP.md. Created 2026-06-19 (owner-raised). Owner: "the app needs to catch events to refresh the data that shows" — if two users are on the same track version and one posts a comment, it should appear at publish, with no manual refresh or navigate-away-and-back. Applies to comments, tracks, projects, artists, rosters, status changes — everything. Finding: the transport is already built and unused — this is a wiring + client-merge effort, not a from-scratch system.
What triggered this
Collaboration is the product (doc 31), but every surface today is fetch-on-appear + pull-to-refresh. In a live session (reviewer + producer on the same version), new comments / approvals / uploads are invisible until someone manually refreshes. The owner wants the catalog to feel live.
Finding — the WebSocket layer exists (verified 2026-06-19)
ctrl-audio-back/src/modules/websocket/ already has a working gateway — it's just never called:
EventsGateway(events.gateway.ts): Socket.IO gateway, JWT-authenticated on connect (handleConnectionresolvesuserId/emailfrom the token), auto-joins a personal roomuser:${userId}, and exposes room join/leave for the hierarchy:join:track/join:project/join:artist(+leave:*). Emit helpers already exist:emitToUser/emitToTrack/emitToProject/emitToArtist(server.to(room).emit(event, data)).WebsocketModuleis@Global()and exportsEventsGateway— any service can inject it with zero module plumbing.- It is wired nowhere. No service/controller injects the gateway or calls
emitTo*, so no domain mutation ever broadcasts. (The only "realtime" today isGET /notification/countSSE, which is a 1 s interval poll, not event-driven — unrelated.)
So the rooms map 1:1 to the hierarchy the owner listed, and emit is one method call away. The gap is (a) emit on mutation server-side and (b) subscribe + merge client-side.
The model — emit on mutation → room → client merges
- Server: emit on every meaningful mutation. Inject
EventsGatewayinto the domain services and, after a successful write, emit a typed event to the relevant room(s). The cascade already implies the rooms (a comment belongs to a track →emitToTrack; a track status change →emitToTrack+emitToProjectfor list badges; an artist/roster edit →emitToArtist). - Client: join the room for the visible screen, merge events. On opening a track the app emits
join:track; the view-model handles inbound events by merging into local state (insert/update/remove) instead of refetching — comments appear instantly, status pills flip live, new versions surface.leave:*on disappear. - Catch-up on (re)connect. Sockets drop (backgrounding, network). On connect / reconnect the client does one normal fetch to reconcile, then trusts the live stream — events are a fast path, the REST read stays the source of truth (no event sourcing, no missed-message replay to build).
Event taxonomy (initial)
| Domain | Event(s) | Room(s) | Client effect |
|---|---|---|---|
| Comment | comment:created · :updated · :resolved · :deleted | track:${id} | merge into the thread / Wave markers live |
| Review/approval | track:reviewStatusChanged · releaseStatusChanged | track:, project: | flip status pills + list badges |
| Versions | file:uploaded (new version) | track: | new VersionCard appears |
| Track | track:updated · :created · :deleted | project: | tracklist updates |
| Project / Artist / Roster | project:updated · artist:updated (incl. roster) | artist:, user: | grids / grouping refresh |
| Sharing / Teams | collaborator:added/removed · team:shared | user: (the affected member) | Shared/With-me feed updates |
Invariants & gotchas (must resolve before building)
- ✅ Gate room joins by access — DONE (2026-06-19, Claude Code). Previously any authenticated socket could
join:track/join:project/join:artistfor any id and receive its events. NowEventsGateway.canAccessgates eachjoin:*(owner · collaborator — denormalized down the hierarchy ·sharedWith), mirroring the REST gate; denied joins return{ success: false, error: 'forbidden' }. Latent today (nothing emits yet) but closed before any emit wiring so live events can never become a leak channel. Verified on real data (owner allowed; stranger/bad-id/no-user denied). SeeCHANGELOG.md. - Privacy parity with REST. Event payloads must respect the same shaping as reads — e.g. owner-private
tags(doc-26 /StripPrivateTagsInterceptor) shouldn't ride along in a broadcast to non-owners. Prefer thin events (ids + change type) and let the client refetch the node, or run payloads through the same stripping. Decide per event. - Echo/optimistic dedupe. The author already inserted their own comment optimistically; ignore the echo of your own event (tag events with an actor/client id).
- Scale. Single Container App instance is fine now; multi-instance Socket.IO needs a Redis adapter so rooms span instances — a later scale lever (doc 14), not a launch blocker.
Backend vs iOS map
| Piece | Today | Work | Owner |
|---|---|---|---|
| Socket gateway + rooms + JWT auth | ✅ built, @Global | none | — |
Permission-gated join:* | ✅ done 2026-06-19 — canAccess gate per handler | shipped (mirrors REST gate) | — |
| Emit on mutation | ✅ all done 2026-06-19/20 — track comments + review/release · hierarchy CRUD (track/project/artist incl. roster) · sharing (collaborator:added/removed, team:shared/unshared → member user: rooms) | — | backend |
| Thin-event vs full-payload + privacy | ✅ thin chosen — { ids…, actorId }, client refetches the node | — | backend |
| Socket client + (re)connect + catch-up | ✅ done 2026-06-19 (SocketIOManager, phase 3) | — | iOS |
| Join/leave per screen + merge | ✅ browse done 2026-06-20 (phase 5) — Player + Artists/Detail/Tracklist VMs; Shared feed pending (consumes phase-4b sharing events) | the Shared/With-me VM joins its user: room + merges | iOS |
How this sequences
Progress: phase 1 (
join:*access gate) ✅ 2026-06-19 · phase 2 (emit on track comments + review/ release status) ✅ 2026-06-19 · phase 3 (iOS Socket.IO client — connect / join-per-screen / merge / catch-up) ✅ 2026-06-19 · phase 4a (emit hierarchy CRUD:track:created/updated/deleted→project:room,project:created/updated/deleted→artist:+project:rooms,artist:updated/deleted(incl. roster) →artist:+owneruser:rooms) ✅ 2026-06-19 (boot-verified;nest build+jest14/14 green). Emitted from the controllers — the uniform choke point where the actor (@GetUser) + the result both exist (services have multiple internal create paths and mostly lack the user). · phase 5 (iOS browse view-models go live — Artists/Detail/Tracklist join their rooms + merge) ✅ 2026-06-20 · phase 4b (emit sharing events:collaborator:addedon link-join,collaborator:removedon remove-from-space/project/track,team:shared/team:unsharedfan-out to each member'suser:room) ✅ 2026-06-20 (boot-verified;nest build+jest14/14). These emit from the services (collaboration / team) — that's where the affected-member fan-out is computed, unlike the controller-emitted hierarchy CRUD. Thin payloads{ ids…, actorId }; best-effort; one event per user-action (recursive cascades suppressed). Backend emit is now complete. Only remaining piece = iOS: the Shared / With-me feed VM joins itsuser:room and merges the phase-4b events (it already has theSocketIOManagerplumbing from phase 5) so inbound/outbound shares update without pull-to-refresh.
Envision now, build as its own phase after the current Portals/notes + image work — it's high-value (makes collaboration feel live) and mostly reuse, but it touches many services (emit points) and every browse view-model (merge), so it deserves a dedicated slice rather than piecemeal. Backend-contract-first: (1) gate join:*, (2) define the event names + payload shape, (3) emit from services; then iOS connects, subscribes, merges. Start with the highest-value room — track: comments + review status (the owner's exact scenario) — and extend outward to project/artist/roster once the pattern is proven.
Decisions — to confirm (not now; flagged)
- Thin events vs full payloads — recommend thin (
{type, id, parentIds, actorId}) + client refetch for correctness/privacy, upgrading hot paths (comments) to full payloads if the refetch feels slow. - Transport — keep Socket.IO (already built) vs SSE. Recommend Socket.IO (bidirectional join/leave
- rooms already implemented).
- Scope of first slice —
track:(comments + review/version) only, or all rooms at once? Recommend track-first. - Presence ("who's here / typing") — defer; a natural follow-on once rooms carry membership.
Until built, surfaces stay fetch-on-appear + pull-to-refresh; nothing here blocks other phases.
Cross-references
- The transport this reuses:
ctrl-audio-back/src/modules/websocket/events.gateway.ts(rooms +emitTo*) - Notifications (SSE/APNs, the other signal path):
notificationmodule +13-MOBILE-CONSIDERATIONS.md - What gets merged live (comments/notes):
33-NOTES-ANNOTATIONS.md - Multi-instance scale (Redis adapter):
14-SCALABILITY.md - Collaboration vision:
31-PRODUCT-VISION-360.md