Skip to content

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 (handleConnection resolves userId/email from the token), auto-joins a personal room user:${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)).
  • WebsocketModule is @Global() and exports EventsGateway — 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 is GET /notification/count SSE, 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

  1. Server: emit on every meaningful mutation. Inject EventsGateway into 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 + emitToProject for list badges; an artist/roster edit → emitToArtist).
  2. 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.
  3. 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)

DomainEvent(s)Room(s)Client effect
Commentcomment:created · :updated · :resolved · :deletedtrack:${id}merge into the thread / Wave markers live
Review/approvaltrack:reviewStatusChanged · releaseStatusChangedtrack:, project:flip status pills + list badges
Versionsfile:uploaded (new version)track:new VersionCard appears
Tracktrack:updated · :created · :deletedproject:tracklist updates
Project / Artist / Rosterproject:updated · artist:updated (incl. roster)artist:, user:grids / grouping refresh
Sharing / Teamscollaborator:added/removed · team:shareduser: (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:artist for any id and receive its events. Now EventsGateway.canAccess gates each join:* (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). See CHANGELOG.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

PieceTodayWorkOwner
Socket gateway + rooms + JWT auth✅ built, @Globalnone
Permission-gated join:*done 2026-06-19canAccess gate per handlershipped (mirrors REST gate)
Emit on mutationall 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 + privacythin chosen — { ids…, actorId }, client refetches the nodebackend
Socket client + (re)connect + catch-updone 2026-06-19 (SocketIOManager, phase 3)iOS
Join/leave per screen + mergebrowse 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 + mergesiOS

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/deletedproject: room, project:created/updated/deletedartist:+project: rooms, artist:updated/deleted (incl. roster) → artist:+owner user: rooms) ✅ 2026-06-19 (boot-verified; nest build + jest 14/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:added on link-join, collaborator:removed on remove-from-space/project/track, team:shared/team:unshared fan-out to each member's user: room) ✅ 2026-06-20 (boot-verified; nest build + jest 14/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 its user: room and merges the phase-4b events (it already has the SocketIOManager plumbing 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)

  1. 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.
  2. Transport — keep Socket.IO (already built) vs SSE. Recommend Socket.IO (bidirectional join/leave
    • rooms already implemented).
  3. Scope of first slicetrack: (comments + review/version) only, or all rooms at once? Recommend track-first.
  4. 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

Ctrl-Audio Platform Documentation