ichibaseichibase

Realtime

Subscribe to database changes over a WebSocket, or build presence and broadcast channels. One socket multiplexes every subscription; the SDK reconnects and re-subscribes automatically.

Database changes

Enable realtime for a table/collection in the dashboard, then subscribe. Each subscriber only receives rows your realtime rules allow them to see.

const sub = ichi.realtime.subscribe(
  { kind: 'postgres', table: 'messages', events: ['INSERT', 'UPDATE'] },
  (msg) => {
    console.log(msg.event, msg.record, msg.old);
  },
);

// later
sub.unsubscribe();

Mongo collections work the same way — use kind: 'mongo' with a collection. Note Mongo change events are lowercase (insert / update / delete) vs Postgres' uppercase, and the document arrives as msg.record.

const sub = ichi.realtime.subscribe(
  { kind: 'mongo', collection: 'orders', events: ['insert', 'update'] },
  (msg) => {
    console.log(msg.event, msg.record); // 'insert' | 'update' | 'delete'
  },
);

sub.unsubscribe();
Mongo realtime is delivered by the gateway emitting each write to the realtime engine, so a collection's changes stream just like Postgres rows — same filter grammar and the same per-collection realtime rules apply.

App lifecycle (mobile background / foreground)

On mobile, when your app goes to the background the OS suspends timers and can silently drop an idle socket — leaving a dead connection (and wasted battery) until the user returns.

The Flutter SDK handles this for you — no code required. It watches the app lifecycle and pauses the realtime websocket when the app is backgrounded (paused / hidden), then reconnects and re-subscribes automatically when it comes back to the foreground (resumed). Your existing subscriptions, presence, and channels resume as if nothing happened.

In the browser the JS/TS SDK keeps its single socket across tab backgrounding and reconnects on any drop, so there's nothing to wire up there either.

Security: who receives a change

A realtime stream is broadcast — anyone with your anon key can open a socket and ask to subscribe. The security boundary is the per-table / per-collection rule you set in the dashboard (Realtime page). It runs inside the realtime engine for every changed row and every subscriber, with that subscriber's JWT identity, and decides whether they receive that change. It never touches the database, so it stays fast on the hot path.

Each table or collection picks one of three modes:

ModeWho receives a changeUse for
ruleSubscribers the rule evaluates to true for that rowPer-user / per-team data (the common case)
rlsSubscribers whose Postgres SELECT RLS policies admit the rowPostgres tables that already have RLS (reuse it)
openEveryone — no checksIntentionally public, non-sensitive data
Default-deny by configuration. A table is only listenable after you add it on the Realtime page. rule mode with no conditions means “allow every subscriber” — equivalent to open. So an empty rule is not a safe default: add conditions, choose rls, or leave it off.

The rule grammar

You build rules visually in the dashboard (no JSON by hand), but it helps to know the shape. A rule is a tree. A condition (leaf) tests one field of the changed row; groups combine conditions with and / or, and any node can be inverted with not.

// condition (leaf): compare a field of the row to a value
{ "field": "team_id", "op": "eq", "value": "$auth.team_id" }

// group: all must pass (use "or" for any; "not" to invert)
{ "and": [ <condition>, <condition>, { "not": <condition> } ] }

The left side is always a field of the row (dotted paths reach into nested Mongo documents and arrays: owner.id, items.0.sku). The right side is a literal or a reference to the subscriber:

  • $auth.uid — the user's id (JWT sub); null when anonymous.
  • $auth.email, $auth.role — standard identity.
  • $auth.team_id, $auth.org, … — any custom JWT claim; $auth.claims.a.b for nested.
  • $now — current time (RFC3339), for time-window rules.
  • Anything else is a literal. Numbers compare numerically; true/false/null are JSON literals. Escape a literal leading $ as \$.

Operators:

OperatorMeaning
eq, neqequal / not equal (type-aware)
gt, gte, lt, lteordered compare — number, string, or RFC3339 time
in, ninvalue is / isn't in a list
containssubstring (text field) or element (array field)
starts_with, ends_withstring prefix / suffix
regexRE2 pattern match on a string field
betweenin an inclusive [lo, hi] range
exists, missingfield path present / absent (no value)
is_null, not_nullvalue is / isn't null (no value)
is_true, is_falseboolean-field shortcuts (no value)
Fail-closed. If a field a condition references is missing from the row, value comparisons (eq, gt, in, …) evaluate to false— the subscriber simply doesn't get that row, never an error. The engine never decides policy on its own: configure a rule, or it's open.

Rule examples

Only my own rows. The classic per-user feed:

{ "field": "user_id", "op": "eq", "value": "$auth.uid" }

My team's rows, but not archived ones. A group with a negation:

{
  "and": [
    { "field": "team_id",  "op": "eq",      "value": "$auth.team_id" },
    { "not": { "field": "archived", "op": "is_true" } }
  ]
}

Mine, or anything public. An or group:

{
  "or": [
    { "field": "author_id",  "op": "eq", "value": "$auth.uid" },
    { "field": "visibility", "op": "eq", "value": "public" }
  ]
}

Status in a list + numeric threshold. Lists use in; numbers compare numerically:

{
  "and": [
    { "field": "status",   "op": "in",  "value": ["open", "pending"] },
    { "field": "priority", "op": "gte", "value": 3 }
  ]
}

Mongo nested document.Dotted paths reach into sub-documents and arrays — here, the owner, or any member of the doc's members array:

{
  "or": [
    { "field": "owner.id", "op": "eq",       "value": "$auth.uid" },
    { "field": "members",  "op": "contains", "value": "$auth.uid" }
  ]
}
What the rule can't do.A condition's left side is always a field of the row; you compare row data against the subscriber, not two subscriber claims, and not the subscriber's role against a constant. So “admins see every row” isn't expressible as a per-row rule on its own — for blanket role access prefer rls mode (a Postgres policy keyed on auth.jwt() ->> 'role') or a dedicated broadcast channel for admins.

Filtering a subscription

Pass a filterto receive only the changes you care about — it's a JSON rule: a leaf is { field, op, value } (ops: eq/neq/gt/gte/lt/lte/in/contains/starts_with/regex/between/exists…), combined with and / or / not. Values can reference the subscriber via $auth.uid, $auth.email, or any custom claim ($auth.team_id). The same grammar works for Postgres rows and Mongo documents (dotted paths for nested fields).

// Only messages in room 42, and only mine
ichi.realtime.subscribe(
  {
    kind: 'postgres',
    table: 'messages',
    events: ['INSERT'],
    filter: {
      and: [
        { field: 'room_id', op: 'eq', value: '42' },
        { field: 'author_id', op: 'eq', value: '$auth.uid' },
      ],
    },
  },
  (msg) => console.log(msg.record),
);
The owner's per-table realtime rules (set in the dashboard) are the security boundary — they decide what each subscriber is allowed to see. A client filteronly narrows further within that; it can't widen access.

Broadcast & presence

const room = ichi.realtime.subscribe(
  { kind: 'broadcast', channel: 'room:42', presence: true },
  (msg) => console.log(msg),
);

room.send('chat', { text: 'hi everyone' });   // publish to the channel
room.track({ typing: true });                  // update your presence
Joining a broadcast channel is gated by your _realtime_authorizefunction, and row changes by the per-table realtime rules — both run with the signed-in user's identity when present.

Connection hooks (presence on the server)

Run server code whenever a client opens or closes its realtime websocket. Enable Realtime, then write your hook in the Connection hook editor that appears on the Realtime page — saving it creates the reserved _realtime_connectionEdge Function for you (you don't create or name it yourself). The realtime service then calls it once per connect and once per disconnect. Use it for presence (flip an online flag), session logging, welcome pushes, or cleanup on leave.

It is fire-and-forget: the platform never waits for or reads the response, and a throw or timeout can't block or reject the client's connection. Disconnect is best-effort — a client that vanishes (crash, dead network) may not produce a clean disconnect call.

The function receives this JSON body:

{
  "event":   "connect" | "disconnect",
  "user_id": "…",            // from the JWT, or "" when anonymous
  "email":   "user@x.com",   // when present in the JWT
  "role":    "anon" | "service_role",
  "claims":  { /* full decoded JWT claims */ }
}
// _realtime_connection — keep an "online" flag on the user's row.
import { createPostgrest } from 'jsr:@ichibase/postgrest@^0.3.1';

// createPostgrest() reads ICHIBASE_PROJECT_URL + ICHIBASE_SERVICE_KEY from the
// env; the service key bypasses RLS, so this trusted write isn't blocked.
const pg = createPostgrest();

Deno.serve(async (req) => {
  const { event, user_id } = await req.json();
  if (!user_id) return new Response(null, { status: 204 }); // ignore anon

  await pg.from("profiles")
    .update({ online: event === "connect", last_seen: new Date().toISOString() })
    .eq("id", user_id);

  return new Response(null, { status: 204 });
});
Writes from a connection hook should use the service key— it runs as trusted server code, not as the connecting user, so RLS / table policies won't block the presence update.