Realtime
Subscribing to row changes over private Supabase broadcast channels
Realtime hooks
Every realtime hook returns [latest, clear]: the most recent change seen on its channels, and
a function to acknowledge it. latest is RealtimeData<T>, which pairs the row with an
EnumRealtimeOperation of INSERT, UPDATE, or DELETE. Switching herds resubscribes and
drops any unacknowledged change.
Rows are delivered on private topics, so a subscription only succeeds when the caller holds the
ability that topic requires. The base hook uses the
client abilities mint to skip known-denied
joins when available; realtime.messages RLS remains authoritative. Unauthorized joins default
to stop so Phoenix does not rejoin-spam, then retry once when the Supabase session changes.
Every realtime hook accepts { authErrorRetry: "backoff" } for exponential retries.
Channels
| Hook | Topic | Requires |
|---|---|---|
useScoutRealtimeEvents | {herd_id}-events | media.read |
useScoutRealtimeArtifacts | {herd_id}-artifacts | media.read |
useScoutRealtimeHerdMediaUsage | {herd_id}-herd_media_usage | media.read |
useScoutRealtimeDevices | {herd_id}-devices | devices.read |
useScoutRealtimeTags | {herd_id}-tags | media.read |
useScoutRealtimeSessions | {herd_id}-sessions | sessions.read |
useScoutRealtimePins | {herd_id}-pins | map.read |
useScoutRealtimePlans | {herd_id}-plans | map.read |
useScoutRealtimeParts | {herd_id}-parts | devices.read |
useScoutRealtimeConnectivity | {herd_id}-connectivity | sessions.read |
useScoutRealtimeDocuments | {herd_id}-herd_documents | herd_documents.read |
{herd_id}-device_documents | device_documents.read | |
{herd_id}-user_documents | user_documents.read | |
{herd_id}-operating_context_documents | operating_contexts.read | |
user_{user_id}-documents | being that user | |
product_documents_changes | a view role in any herd | |
useScoutRealtimeUserDocumentStatuses | {herd_id}-user_document_statuses | user_documents.status.read or user_documents.read |
useScoutRealtimeOperatingContexts | {herd_id}-operating_contexts | operating_contexts.read |
useScoutRealtimeMaintenanceRequests | {herd_id}-maintenance_requests | maintenance_requests.read |
useScoutRealtimeContacts | {herd_id}-contacts | contacts.read |
useScoutRealtimeSessionIncidents | {herd_id}-session_incidents | sessions.read |
useScoutRealtimeAnalysisJobs | analysis_jobs_changes | platform superadmin |
useScoutRealtimeAnalysisTasks | analysis_tasks_changes | platform superadmin |
useScoutRealtimeVersionsSoftware | versions_software_changes | authenticated user |
useScoutRealtimeOperatingContexts also emits contact types, point-of-interest
types and instances, operating permissions, compliance resources and types,
operating-context contacts, and herd operating permissions. Use update.table
to narrow the exported OperatingContextRealtimeData or
ComplianceRealtimeData union.
Artifact broadcasts contain only id and device_id; fetch the artifact by ID
when the full row or signed media URLs are needed.
User document status broadcasts contain only the columns exposed by
user_document_statuses. They never contain a file URI, size, or identifier.
Part broadcasts carry the part row only. When a part arrives with a product number the product catalog has not seen, fetch the product and merge it so every consumer keeps sharing one copy:
const product = await get_product_for_product_number(supabase, change.data.product_number);
dispatch(mergeProductCatalog(build_product_catalog([
{ product_number: change.data.product_number, product: product.data },
])));Dropped subscriptions
A channel lost to a transport drop, CLOSED, or TIMED_OUT rejoins on the same
exponential backoff as an auth retry, and returning to a hidden tab or coming
back online rejoins whatever is no longer joined. Pass recoverOnFocus: false to
leave the focus and online listeners off.
Pass enabled: false to any realtime hook to create no channel, auth
subscription, retry timer, or reconnect listener. This lets an application use
the sanitized status topic or polling without repeatedly attempting a
full-document subscription it cannot authorize.
Broadcasts published while a channel was down are gone, so a view that must not
miss a row — a media list waiting on a derivative, for instance — refetches from
onMissedUpdates, which fires once the topic is back:
const [change, clearChange] = useScoutRealtimeArtifacts(supabase, {
onMissedUpdates: () => refetch({ preservePages: true }),
});System role defaults grant contact and operating-context reads to Viewer/Editor/Admin,
maintenance-request reads to Editor/Admin, and document reads to Admin. The domain read
abilities (media.read, devices.read, sessions.read, telemetry.read, map.read) and
chat.binary belong to all three system roles. Topics without a mapping fall back to herds.read. A platform superadmin may join any {herd_id}-* topic. Custom
herd roles only hold the abilities their herd admin granted, so a role created before an ability
existed needs that grant before its topics resolve.
A document arrives on the channel of its owner class, so a herd document and a pilot's licence
never share an audience. useScoutRealtimeDocuments subscribes to all six channels by default;
pass the channels a view actually renders to avoid subscribing to ones the user cannot read.
const [change, clearChange] = useScoutRealtimeDocuments(supabase, ["herd", "device"]);Auth errors
Private-channel CHANNEL_ERROR with Unauthorized/permission text removes the channel by default.
Use backoff when the failure may be transient. Pass backoffMaxAttempts to cap retries.
const [change, clearChange] = useScoutRealtimeParts(
supabase,
{ authErrorRetry: "backoff", backoffMaxAttempts: 5 },
);Usage
Apply the change to local state, then clear it so the next one is distinguishable.
"use client";
import { useEffect, useState } from "react";
import {
EnumRealtimeOperation,
IMaintenanceRequest,
useScoutRealtimeMaintenanceRequests,
useSupabase,
} from "@adventurelabs/scout-core/client";
export function MaintenanceRequests({ initial }: { initial: IMaintenanceRequest[] }) {
const supabase = useSupabase();
const [requests, setRequests] = useState(initial);
const [change, clearChange] = useScoutRealtimeMaintenanceRequests(supabase);
useEffect(() => {
if (!change) return;
setRequests((current) => {
const others = current.filter((request) => request.id !== change.data.id);
return change.operation === EnumRealtimeOperation.DELETE
? others
: [change.data, ...others];
});
clearChange();
}, [change, clearChange]);
return (
<ul>
{requests.map((request) => (
<li key={request.id}>{request.description}</li>
))}
</ul>
);
}An upsert-or-remove reducer like this one handles a row moving between herds: the herd losing it
receives a DELETE and the herd gaining it receives an INSERT.
Custom topics
useScoutRealtimeBroadcast backs every hook above and takes topics directly, for channels this
package does not wrap yet. It applies the same abilities gate and auth-error handling.
const [change, clearChange] = useScoutRealtimeBroadcast<IPart>(
supabase,
[`${herdId}-parts`],
);