Changefeeds (WAL)
Subscribe directly to table-level commit streams.
await db.wal.subscribe(...)Table-level changefeeds are especially useful for:
- triggering workflow runs
- driving cache invalidation
- feeding audit, fan-out, or projection pipelines
db.wal.subscribe(...) is driven directly from the underlying database's commit stream:
- FlashQL's Write Ahead Log (WAL)
- PostgreSQL's Write Ahead Log (WAL)
- MySQL/MariaDB's Binary Logging (Binlog)
The Minimal Form
const unsubscribe = await db.wal.subscribe((commit) => {
console.log(commit);
});This subscribes to all matching commits the database produces.
Filtering by Selector
Most real use cases want to narrow the scope to specific table names.
const unsubscribe = await db.wal.subscribe(
{ public: ['users', 'orders'] },
(commit) => {
console.log(commit.entries);
}
);Common Selector Forms
'*'
{ public: ['users'] }
{ public: ['users', 'orders'] }
[{ namespace: 'public', name: 'users' }]The selector is normalized internally into a namespace-to-table mapping.
Enabling Realtime Capabilities
LinkedQL’s realtime capabilities (live queries and WAL subscriptions) depend on the support mode of the underlying database. For FlashQL and the Edge runtime client, this is automatic. But for the mainstream database family, this works behind a configuration.
See the Guides section for setup details by runtime.
What Commit Objects Look Like
A commit contains one or more entries describing row-level changes.
{
txId: 234214,
entries: [...],
}txIdis the ID of the transactionentriesis an array of one or more change descriptors
insert Descriptor
{
op: 'insert',
relation: { namespace: 'public', name: 'users', keyColumns: ['id'] },
new: { id: 1, name: 'Ada' }
}update Descriptor
{
op: 'update',
relation: { namespace: 'public', name: 'users', keyColumns: ['id'] },
old: { id: 1, name: 'Ada' },
new: { id: 1, name: 'Ada Lovelace' }
}delete Descriptor
{
op: 'delete',
relation: { namespace: 'public', name: 'users', keyColumns: ['id'] },
old: { id: 1, name: 'Ada Lovelace' }
}Variations
While the above is the standard shape, the following attributes may vary depending on the underlying database system or configuration:
FlashQL
descriptor.old: always present
PostgreSQL
descriptor.old: present when the database'sREPLICA IDENTITYisFULL, otherwise, you get:descriptor.key: present when the database'sREPLICA IDENTITYisDEFAULT
MySQL/MariaDB
Coming soon
Example
const commits = [];
const unsubscribe = await db.wal.subscribe(
{ public: ['users'] },
(commit) => commits.push(commit)
);
await db.query(`
INSERT INTO public.users (id, name) VALUES (1, 'Ada');
UPDATE public.users SET name = 'Ada Lovelace' WHERE id = 1;
`);
await db.query(`DELETE FROM public.users WHERE id = 1`);
await unsubscribe();What you get:
- two commit events, not three
- the first containing two entries:
insertandupdate - the second containing one:
delete
Stable Subscription Slots
Subscriptions can be given a stable id:
const unsubscribe = await db.wal.subscribe(
{ public: ['users'] },
(commit) => console.log(commit),
{ id: 'users_slot' }
);That id is more than a label. It gives the subscription a durable slot identity, and LinkedQL binds that subscription to the same slot each time it is recreated with the same id.
With that slot identity, the runtime:
- resumes from the same logical slot
- catches up on commits that were missed while the subscriber was away
- continues emitting to the subscriber from that state
- avoids treating every reconnect as a brand-new subscription
That matters when changefeeds back application caches, replicas, sync workers, or long-lived UI sessions that must continue from a known point rather than restarting blindly from "now."
Example
const commits = [];
const unsubscribe = await db.wal.subscribe(
{ public: ['users'] },
(commit) => commits.push(commit),
{ id: 'users_slot' }
);
await db.query(`
BEGIN;
INSERT INTO public.users (id, name) VALUES (1, 'Ada');
UPDATE public.users SET name = 'Ada Lovelace' WHERE id = 1;
COMMIT;
`);
await unsubscribe();
await db.query(`DELETE FROM public.users WHERE id = 1`);What happens:
- you get one commit event containing two entries:
insertandupdate - you called
unsubscribe()and don't get the second commit
const unsubscribe = await db.wal.subscribe(
{ public: ['users'] },
(commit) => commits.push(commit),
{ id: 'users_slot' }
);What happens now:
- you re-subscribed to the same subscription slot
- you get the one commit event you missed:
delete
Dropping Slots
To drop the slot itself, pass { forget: true } to the unsubscribe() call:
await unsubscribe({ forget: true });Visibility and Security
db.wal.subscribe() exposes a powerful information stream to the host application and its users. It lets you subscribe directly to the commit stream of the database – the Write Ahead Log (WAL) of a PostgreSQL or FlashQL database, for example. That also means it needs a security boundary that ideally controls what's visible and to whom.
That boundary is defined through the resolveCommitVisibility(entries, sub) hook.
const db = new PGClient({
resolveCommitVisibility: async (entries, sub) => {
return entries;
},
});It lets you intercept a subscriber's event payload and filter for visibility.
The resolution pipeline is:
- a database commit matches a subscription and is selected for dispatch
- LinkedQL passes the commit's
entriesthroughresolveCommitVisibility(entries, sub) - the hook returns the visible subset of the input array (or
nullto defer to default behavior) - LinkedQL emits the filtered commit
That hook answers the policy question:
"Which of the rows described in this commit are visible to this subscriber?"
That precision matters because one commit can describe multiple rows and they may not all share the same visibility rule.
Transaction-Scoped WAL Subscriptions
Transactions are a common way to enforce visibility rules in most database systems, like PostgreSQL. You are able to attach policies that apply in the lifetime of a specific transaction. LinkedQL natively supports this pattern.
When you run db.wal.subscribe() within a policy-bound transaction, your resolveCommitVisibility() handler executes within that transaction’s context.
In PostgreSQL, this pairs naturally with Row-Level Security (RLS).
That could look like:
const tx = await db.begin();
await db.query(
`SELECT set_config('claims.user_id', 'user_abc_123', true)`,
{ tx }
);
const unsubscribe = await db.wal.subscribe(
{ public: ['posts'] },
(commit) => console.log(commit),
{ tx }
);Here, the subscription is issued with { tx } – i.e. issued within a transaction context. LinkedQL passes that same transaction to resolveCommitVisibility() as sub.tx.
resolveCommitVisibility(entries, sub) {
console.log(sub.tx); // The original transaction
}The hook is able to look up each row in entries within that transaction – the exact same security context in which the subscription was created – to determine visibility.
So in a policy-driven system, the emitted changefeed stops being merely "what changed in the database" and becomes "what this subscriber is allowed to observe changing."
A Practical Pattern
Suppose a commit contains multiple post changes, but the policy restricts users to specific rows, that subset of the commit is what subscribers should see. This is shown below:
BEGIN;
INSERT INTO posts (id, title) VALUES (1, 'Visible');
INSERT INTO posts (id, title) VALUES (2, 'Hidden');
COMMIT;const db = new PGClient({
resolveCommitVisibility: async (entries, sub) => {
const visibleEntries = [];
// Each entry is resolved by querying the database within the subscriber’s transaction context
for (const entry of entries) {
const id = entry.old?.id ?? entry.new?.id;
const result = await db.query(
`SELECT id
FROM public.posts
WHERE id = $1`,
{ values: [id], tx: sub.tx }
);
if (entry.op === 'delete') {
if (!result.rows.length) {
visibleEntries.push(entry);
}
continue;
}
if (result.rows.length) {
visibleEntries.push(entry);
}
}
return visibleEntries;
},
});For each entry:
insertandupdateare visible only if the lookup confirms that they actually exist for the given subscriberdeleteis visible only if the lookup confirms that the row actually disappeared for the given subscriber
Outside a Transaction
When subscriptions are not transaction-scoped, a resolveCommitVisibility() hook may still be useful.
Although its sub.tx would be null, you can still:
- apply application-level filtering to dispatch payloads
- suppress certain commit shapes entirely
const db = new PGClient({
resolveCommitVisibility: async (entries, sub) => {
console.log(sub.tx);
console.log(sub.liveQueryOriginated);
if (sub.liveQueryOriginated) {
// This is a subscription made by the live query engine
// returning null lets the Live Query engine apply its default visibility behavior
return null;
}
// Filter by relation name
return entries.filter((entry) => entry.relation.name !== 'audit_log');
},
});In this case:
- commits involving
audit_logaren't exposed todb.wal.subscribe() - subscriptions originating from the Live Query engine are left to the Live Query engine's default visibility behavior
What the Hook Receives
The function signature is:
resolveCommitVisibility(entries, sub)Where:
entriesis a list of descriptors for the rows touched by the commit that intersect with the subscriptionsubis a small subscription descriptor containing contextjs{ tx, liveQueryOriginated, }Here,
sub.liveQueryOriginatedis a boolean flag that tells if the subscription originated from the Live Query engine, as may be the case when theresolveCommitVisibility()handler is paired withcentralizeCommitVisibility: true. (See Live Queries' Visibility and Security section for that.)
On receiving the above, the hook must return one of:
- an array of entries: the visible subset of the input array for the given subscriber
null: which means: "take the default action"
For subscriptions that aren't liveQueryOriginated, returning null is treated as an empty array. In other words, the entire commit is invisible to the subscriber.
For subscriptions that are liveQueryOriginated, returning null tells the engine to go ahead with its default visibility behavior. Returning null is the default behavior unless explicit filtering is required.
Important Rule
If you issue transaction-bound subscriptions – db.wal.subscribe(..., { tx }) – you must also provide resolveCommitVisibility().
Without it, LinkedQL has no reliable way to answer the question:
"What's visible in the context of this transaction?"
An error is thrown in that case.
Additional Reading
| If you want to learn about... | Go to... |
|---|---|
| the related live query model | Live Queries |
the db.wal.subscribe() API in detail | API: db.wal.subscribe() |
