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();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.
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:
| Mode | Who receives a change | Use for |
|---|---|---|
| rule | Subscribers the rule evaluates to true for that row | Per-user / per-team data (the common case) |
| rls | Subscribers whose Postgres SELECT RLS policies admit the row | Postgres tables that already have RLS (reuse it) |
| open | Everyone — no checks | Intentionally public, non-sensitive data |
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 (JWTsub);nullwhen anonymous.$auth.email,$auth.role— standard identity.$auth.team_id,$auth.org, … — any custom JWT claim;$auth.claims.a.bfor nested.$now— current time (RFC3339), for time-window rules.- Anything else is a literal. Numbers compare numerically;
true/false/nullare JSON literals. Escape a literal leading$as\$.
Operators:
| Operator | Meaning |
|---|---|
| eq, neq | equal / not equal (type-aware) |
| gt, gte, lt, lte | ordered compare — number, string, or RFC3339 time |
| in, nin | value is / isn't in a list |
| contains | substring (text field) or element (array field) |
| starts_with, ends_with | string prefix / suffix |
| regex | RE2 pattern match on a string field |
| between | in an inclusive [lo, hi] range |
| exists, missing | field path present / absent (no value) |
| is_null, not_null | value is / isn't null (no value) |
| is_true, is_false | boolean-field shortcuts (no value) |
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" }
]
}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),
);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_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 });
});