Skip to content

Realtime database

Realtime database streams insert, update, and delete changes from selected schema tables to WebSocket clients. Layeron creates the outbox table, Durable Object hub, worker bindings, and runtime metadata from the db({ realtime }) declaration. The Database runtime records outbox rows after successful writes that go through the Layeron Database API.

Realtime database uses the schema primary key by default. Add key when the table uses a different identity column for event payloads.

Terminal window
import { backend } from "@layeron/core"
import { db, table, text } from "@layeron/modules"
const app = backend()
const database = db({
name: "main",
schema: {
posts: table({
id: text().primaryKey(),
tenantId: text().notNull().index(),
title: text().notNull(),
}),
},
realtime: {
tables: {
posts: {
events: ["insert", "update", "delete"],
filters: ["tenantId"],
requiredFilters: ["tenantId"],
},
},
},
})
app.use(database)

Use true for the default table settings:

Terminal window
db({
name: "main",
schema: {
posts: table({
id: text().primaryKey(),
title: text().notNull(),
}),
},
realtime: {
tables: {
posts: true,
},
},
})

Realtime tables are private by default. Private tables require a short-lived subscription grant before a client can open the WebSocket. Use access: "public" only for tables whose row changes are safe for every client to receive.

Issue a grant from a normal route after the route has checked the current user or tenant:

Terminal window
app.post("/realtime/posts/token", async (request) => {
const auth = await requireAuth(request)
return await database.realtime.authorize({
table: "posts",
filters: {
tenantId: auth.tenantId,
},
events: ["insert", "update"],
ttlSeconds: 300,
actor: {
kind: "user",
id: auth.userId,
tenantId: auth.tenantId,
},
})
})

The result includes a WebSocket URL, the JWT grant, and the protocols used by the browser WebSocket constructor:

Terminal window
{
"url": "wss://api.example.com/__layeron/realtime",
"protocols": [
"layeron.realtime.v1",
"layeron.jwt.<token>"
],
"expiresAt": "2026-05-31T00:05:00.000Z"
}

Connect with the returned URL and protocols:

Terminal window
const grant = await fetch("/realtime/posts/token", {
method: "POST",
}).then((response) => response.json())
const socket = new WebSocket(grant.url, grant.protocols)
socket.addEventListener("message", (event) => {
const change = JSON.parse(event.data)
console.log(change)
})

The grant binds the database namespace, database name, table, filters, events, actor metadata, and expiration time. A client cannot change the table or filters during the WebSocket handshake.

Public tables can connect with query parameters because they do not require a grant:

Terminal window
const socket = new WebSocket(
"wss://api.example.com/__layeron/realtime"
+ "?namespace=default"
+ "&database=main"
+ "&table=announcements",
)

Use this form only with access: "public":

Terminal window
db({
name: "main",
schema: {
announcements: table({
id: text().primaryKey(),
title: text().notNull(),
}),
},
realtime: {
tables: {
announcements: {
access: "public",
},
},
},
})
ParameterRequiredDescription
namespaceOptionalDatabase namespace. Defaults to default.
databaseRequiredDatabase module name.
tableRequiredSubscribed table name.
filtersOptionalJSON object of equality filters using columns declared in realtime.tables[table].filters. Required filters still apply.

Each message has this shape:

Terminal window
{
"type": "database.change",
"seq": 1,
"namespace": "default",
"database": "main",
"table": "posts",
"event": "insert",
"key": { "id": "post_1" },
"old": null,
"new": {
"id": "post_1",
"tenantId": "tenant_1",
"title": "Hello"
},
"shard": "DB_SHARD_0000",
"requestId": "req_123",
"createdAt": "2026-05-25T00:00:00.000Z"
}

old contains the row before an update or delete. new contains the row after an insert or update.

Layeron adds a migration named layeron_002_realtime after the schema migration. It creates:

  • layeron_db_realtime_outbox
  • an index for pending outbox rows

The runtime publishes pending outbox rows after successful database writes, then marks each row with published_at.