SemiLayerDocs

Realtime — Observe

Watch a single record. First yield is the current state; every subsequent yield is an update. When the record is deleted, the iterator ends.

observe is the natural primitive for detail pages, ticket views, and any UI where you want "one row, always fresh."

Signature

// Top-level on BeamClient — not under `stream.` and not per-lens in codegen
client.observe<M = Record<string, unknown>>(
  lens: string,
  recordId: string,
): AsyncIterable<M>

observe is on the root BeamClient, not wrapped per-lens in the generated Beam. Call as beam.observe('lensName', 'rowId').

import { beam } from './beam'

for await (const snapshot of beam.observe<OrdersMetadata>('orders', 'o_4021')) {
  // snapshot.id          → 'o_4021'
  // snapshot.status      → current status; updates on every change
  // snapshot.total_cents → ...
  setOrder(snapshot)
}
// Loop terminates when the record is deleted.

Unlike subscribe, which yields { kind, record } events, observe yields the record directly. Event semantics:

  • First yield — current state of the row (the server does a fetch).
  • Subsequent yields — the full updated record after every change.
  • Delete — iterator ends cleanly with done: true.

React hook

useObserve wraps the iterator for detail pages:

import { useObserve } from '@semilayer/react'

export function OrderDetail({ orderId }: { orderId: string }) {
  const { record, loading, error } = useObserve<OrdersMetadata>(
    'orders',
    orderId,
    { enabled: true },
  )

  if (loading) return <Skeleton />
  if (error)   return <ErrorCard onRetry={() => location.reload()} />
  if (!record) return <DeletedNotice orderId={orderId} />

  return (
    <article>
      <h1>Order {record.id}</h1>
      <div>Status: <StatusPill value={record.status} /></div>
      <div>Total: ${record.total_cents / 100}</div>
      <div>Customer: {record.customer_id}</div>
    </article>
  )
}
  • record is null before the first yield AND after a delete — check loading to disambiguate.
  • Passing enabled: false (or recordId: null) holds the subscription without opening it. Useful for routes that don't know the id yet.
  • The hook re-subscribes on recordId change — navigating from one order to another closes the old op and opens a new one.

The raw WebSocket op

For non-TS clients:

ws.send(JSON.stringify({
  id: 'obs_1',
  op: 'observe',
  params: { recordId: 'o_4021' },
}))

// Frames:
// { id: 'obs_1', type: 'event', kind: 'insert', record: {...} }  — first: current state
// { id: 'obs_1', type: 'event', kind: 'update', record: {...} }  — each change
// { id: 'obs_1', type: 'event', kind: 'delete', record: {...} }  — final change before close
// { id: 'obs_1', type: 'done' }                                  — iterator ends

The client SDK hides the kind field for observe — it just yields the record. If you need the change kind, use subscribe with a narrow filter ({ id: 'o_4021' }) instead.

When to reach for subscribe instead

observe is the right primitive when the UI is truly single-record-focused: a detail page, a ticket view, an order summary. For anything broader — a list where multiple rows might change, a dashboard — subscribe is the cleaner abstraction.

Rule of thumb:

UI shapePrimitive
"This one row, always fresh"observe
"Every row matching a filter, as changes happen"subscribe
"Top N items ranked by scorers, with 'new content' ticks"feed.<name>.subscribe

Access rules

Same gates as subscribe:

grants: {
  stream: {
    enabled: true,
    modes: ['live'],
    maxLiveSubscriptions: 25,
  },
}

observe also honors the lens's search or query grant (whichever is set) for row-level security — the first yield may return empty or error if the caller doesn't have access to that specific row.

Limits

  • One observe per record per connection. Opening a second observe for the same recordId on the same WebSocket reuses the existing op rather than opening a new one.
  • Counts against maxLiveSubscriptions just like subscribe.
  • Record not found on first lookup → iterator throws with a 404 not_found error before any yield. Catch and handle as "this row doesn't exist" — the more common case than a mid-observe delete.

Errors

ErrorMeaning
404 not_found (first yield)Record doesn't exist, never has. Stop observing.
403 forbidden (first yield)Row-level rule denied access to this specific record.
Iterator done mid-streamThe record was deleted. Show a "this record no longer exists" state.
BeamStreamClosedErrorWebSocket dropped. Retry with backoff.

The useObserve hook surfaces the first two as error and the third (delete) as record === null. Retry for the WS drop is handled inside the hook.