Skip to main content

Editor Live Sync

The editor live sync system keeps widget and overlay editors in real-time sync across all connected sessions. When a mutation happens (upload, update, delete), every open editor tab sees the change immediately - without polling.

The system is built on top of the existing WebSocket + Redis pub/sub infrastructure. No additional services are required.

Architecture​

Mutation (REST or GraphQL handler)
│
▼
Redis pub/sub publish
"lumio:sounds:{account_id}" ←─ channel helper in lo-cache
│
▼
Event Relay Worker (apps/api/src/workers/event_relay.rs)
PSUBSCRIBE "lumio:sounds:*" → forwards to WsServer
│
▼
WebSocket channel "sounds:{account_id}"
(gated by gate.rs: ChannelGate::AccountScoped + feature:sounds)
│
▼
useEditorPresence hook (apps/web/src/hooks/use-editor-presence.ts)
extraChannels: ["sounds:{accountId}"]
onEvent: (channel, eventType, data) => { ... }
│
▼
Version counter incremented
│
▼
Child component re-fetches data

Key files​

FileRole
crates/lo-cache/src/pubsub.rsChannel name helpers (channels::sounds, channels::events, …)
apps/api/src/workers/event_relay.rsRedis PSUBSCRIBE → WsServer relay; CHANNEL_MAPPINGS table
crates/lo-websocket/src/gate.rschannel_gate_for and channel_feature_for; controls who may subscribe
apps/web/src/hooks/use-editor-presence.tsFrontend WebSocket hook; extraChannels + onEvent API

Data Flow in Detail​

1. Backend publishes an event​

After any mutation, the REST and GraphQL handlers call publish_json with the structured payload:

let channel = lo_cache::pubsub::channels::sounds(&account_id.to_string());
let _ = state.pubsub.publish_json(&channel, &serde_json::json!({
"event_type": "sound:list:updated",
"data": { "action": "created", "sound_id": row.id }
})).await;

The publish must happen in both the REST handler (routes/sounds.rs) and the GraphQL handler (graphql/sounds.rs). This is required by the GraphQL/REST parity rule - a mutation on either protocol must produce identical side-effects.

2. Event Relay Worker forwards to WebSocket​

The relay worker runs a PSUBSCRIBE loop over all entries in CHANNEL_MAPPINGS:

const CHANNEL_MAPPINGS: &[(&str, &str)] = &[
("lumio:sounds:*", "sounds:"),
("lumio:events:*", "events:"),
// ...
];

When a message arrives on lumio:sounds:{account_id}, the worker maps it to the WsServer channel sounds:{account_id} and wraps it in the standard WebSocket envelope:

{
"type": "event",
"channel": "sounds:{account_id}",
"event_type": "sound:list:updated",
"data": { "action": "created", "sound_id": "..." }
}

The event_type and data fields are extracted from the Redis payload before wrapping. This is the same envelope format used by overlay channels.

3. Channel gate controls who can subscribe​

channel_gate_for in gate.rs determines the authentication requirement for each channel type:

"sounds" => ChannelGate::AccountScoped,

AccountScoped - not Authenticated - because Widget and Overlay tokens carry an account but no account-level RBAC, and must still reach only their own account's sound events.

channel_feature_for adds an optional feature-flag check:

"sounds" => Some("feature:sounds"),

Subscriptions are rejected if the gate fails or the feature flag is disabled. An unregistered channel type falls through to ChannelGate::Unknown and is always rejected - a new channel that is not added to both functions simply does not work.

4. Frontend subscribes via useEditorPresence​

The widget editor subscribes to extra channels alongside the presence channel:

const [soundsVersion, setSoundsVersion] = useState(0);

const handleEditorEvent = useCallback(
(_channel: string, eventType: string) => {
if (eventType === "sound:list:updated") {
setSoundsVersion((v) => v + 1);
}
},
[]
);

const presence = useEditorPresence({
resourceChannel: `widget:${initial.id}`,
extraChannels: [`sounds:${accountId}`],
onEvent: handleEditorEvent,
wsUrl,
wsToken,
});

The onEvent callback receives every event from every extra channel. Filter on eventType to handle only the events your component cares about.

5. Child components react to version changes​

Components that display the synced data receive version as a prop and re-fetch when it changes:

useEffect(() => {
fetchSounds(0, search, false);
}, [version]);

Using a version counter rather than updating state directly from the event avoids stale-data and race-condition issues: the refetch always loads fresh data from the server.

useEditorPresence API​

interface UseEditorPresenceOptions {
resourceChannel: string; // Primary presence channel (e.g. "widget:{id}")
extraChannels?: string[]; // Additional channels to subscribe for data sync
onEvent?: EditorEventHandler; // Called for every event on extra channels
wsUrl?: string;
wsToken?: string;
enabled?: boolean;
}

export type EditorEventHandler = (
channel: string,
eventType: string,
data: unknown
) => void;

interface UseEditorPresenceResult {
connections: PresenceConnection[];
isConnected: boolean;
}

Implementation notes:

  • extraChannels and onEvent are excluded from the useEffect dependency array on purpose. Adding them would cause WebSocket reconnects every render cycle when callbacks are recreated. Wrap onEvent in useCallback with a stable dependency list.
  • The hook maintains a single WebSocket connection shared between presence tracking and data channels. No extra connections are created for extraChannels.

Event naming convention​

PatternMeaning
{resource}:list:updatedThe collection changed (item created, updated, or deleted)
{resource}:{action}A specific action on a single resource

The data field carries action ("created" | "updated" | "deleted") and the affected resource ID. Examples:

  • sound:list:updated - sound library changed; subscribers should refresh the list
  • sound:play - a sound was triggered for playback
  • sound:stop - playback was stopped

Channel gate types​

When adding a new channel, choose the appropriate gate in gate.rs:

GateWhen to use
PublicNo authentication. Only for publicly readable data (e.g. login assignment codes)
PublicWhenFeature("system:flag")Public while the named system flag is on; rejects with FEATURE_DISABLED when off. The channel id is not parsed as an account UUID
AuthenticatedAny authenticated identity (not Anonymous). Note this does not check the account in the channel name - do not use it for account-scoped data
AccountScopedCaller must be able to access the account id encoded in the channel name, but needs no specific permission. This is the right gate for account-scoped channels that Widget/Overlay tokens must reach, since those carry an account but no account-level RBAC
Permission("resource:read")Account access plus the named account permission. Use for sensitive data visible only to team members with the right role
UserPermission("resource:read")The named permission in the caller's user-scoped permissions; the channel id is not an account UUID
WidgetTokenOnly widget tokens whose widget_id matches the channel
OverlayTokenOnly overlay tokens whose overlay_id matches the resolved overlay
ExtensionOwnerOnly the developer who owns the extension in the channel name
UnknownThe fallthrough - an unregistered channel type is always rejected

Authenticated is a common mis-pick for account-scoped data: any authenticated identity could subscribe to another account's channel. sounds was moved to AccountScoped for exactly that reason.

Pair the gate with a channel_feature_for entry if the channel is behind a plan feature flag. Keep that mapping conservative - gating a Free-tier channel by mistake bricks the dashboard for Free users; when in doubt return None and rely on the per-resource feature checks in the REST/GraphQL handlers.

Step-by-step: adding a new sync channel​

Follow these steps to add real-time sync for a new resource type.

Step 1 - Add a channel helper​

In crates/lo-cache/src/pubsub.rs, add a helper to the channels module:

/// My resource events: `lumio:my-resource:{account_id}`
pub fn my_resource(account_id: &str) -> String {
format!("lumio:my-resource:{}", account_id)
}

Step 2 - Register the relay mapping​

In apps/api/src/workers/event_relay.rs, add an entry to CHANNEL_MAPPINGS:

const CHANNEL_MAPPINGS: &[(&str, &str)] = &[
// existing entries ...
("lumio:my-resource:*", "my-resource:"),
];

If the payload format is { "event_type": "...", "data": { ... } }, add a matching branch in run_relay_loop to extract event_type and data and re-wrap in the standard envelope. See the lumio:sounds:* branch as the reference implementation.

Step 3 - Add a channel gate​

In crates/lo-websocket/src/gate.rs, add the channel type to both match arms:

// channel_gate_for
"my-resource" => ChannelGate::Authenticated, // or Permission("my-resource:read")

// channel_feature_for
"my-resource" => Some("feature:my_resource"), // omit if no feature flag

Step 4 - Broadcast from your handlers​

In your REST handler (apps/api/src/routes/my_resource.rs) and GraphQL handler (apps/api/src/graphql/my_resource.rs), publish after every mutation:

let channel = lo_cache::pubsub::channels::my_resource(&account_id.to_string());
let _ = state.pubsub.publish_json(&channel, &serde_json::json!({
"event_type": "my-resource:list:updated",
"data": { "action": "created", "id": row.id }
})).await;

Do this in both REST and GraphQL handlers. Skipping one creates a parity gap where mutations via one protocol are not reflected in open editors.

Step 5 - Subscribe in the editor hook​

In the editor component (widget-editor.tsx or overlay-editor.tsx), add the extra channel subscription:

const [myResourceVersion, setMyResourceVersion] = useState(0);

const handleEditorEvent = useCallback(
(_channel: string, eventType: string) => {
if (eventType === "my-resource:list:updated") {
setMyResourceVersion((v) => v + 1);
}
},
[]
);

const presence = useEditorPresence({
resourceChannel: `widget:${initial.id}`,
extraChannels: [`my-resource:${accountId}`],
onEvent: handleEditorEvent,
wsUrl,
wsToken,
});

Step 6 - Pass version to child components​

Pass the version counter down to any component that displays the synced data:

<MyResourceContent
accountId={accountId}
version={myResourceVersion}
/>

Step 7 - Refetch on version change​

In the child component, watch version with a useEffect:

useEffect(() => {
fetchMyResources(0, search, false);
}, [version]);

What NOT to use for sync​

The extra field in WindowState (managed by getWindowExtra / setWindowExtra in shared/ui/src/floating-window.tsx) is stored in localStorage. It is designed for persisting UI preferences across page reloads (e.g. volume level, filter state, column widths) - not for propagating data changes between users or tabs.

For anything that must be reflected on another user's screen, use the pub/sub + WebSocket channel pattern described above.

All registered channels​

The full set of Redis patterns registered in CHANNEL_MAPPINGS:

Redis patternWsServer channel prefixGateFeature flag
lumio:events:*events:Permission("events:read")-
lumio:spotify:*spotify:Permission("spotify:read")-
lumio:chat:*chat:Permission("chat:read")feature:multichat
lumio:automations:*automations:Permission("automations:read")feature:automation
lumio:overlay:*overlay:OverlayToken-
lumio:widget:*widget:WidgetTokenfeature:widgets
lumio:ext-storage:*ext-storage:ExtensionStorage (storage read rule of the install)feature:extensions
lumio:sounds:*sounds:AccountScopedfeature:sounds
lumio:history:*history:Permission("history:read")feature:stream_history

gate.rs knows more channel types than the relay maps - these are subscribable but are broadcast to from inside the API process rather than relayed from a Redis pattern:

Channel typeGateFeature flag
presencePresenceScoped (signed-in members of the owning account; a widget token only for its own widget)-
login-assignmentsPublic-
ideasPublicWhenFeature("system:ideas_hub_public")system:ideas_hub
ext-logsExtensionOwnerfeature:extension_development
ext-install-logsInstallLogs (bot-modules:read or automations:read by install category)feature:bot_module_extensions