Live Queries
Turn on reactivity over arbitrary SQL with { live: true }.
const result = await db.query('SELECT * FROM posts', { live: true });You get back a live result set that can directly drive application-level reactivity.
General Idea
Live queries are a first-class concept in LinkedQL.
They happen over the same db.query() API:
const result = await db.query('SELECT * FROM posts', { live: true });In live mode, the query stays open and result.rows becomes a self-updating result set.
It remains a normal JavaScript array:
console.table(result.rows);
console.log(result.rows.length); // 3But it stays in sync with the underlying database truth:
INSERT INTO posts (title) VALUES ('Post A');console.table(result.rows);
console.log(result.rows.length); // 4result.rows will grow, shrink, reorder, and mutate to reflect the latest truth of the query as changes happen underneath it. This holds not only for simple table scans, but also for joins, limits, aggregates, subqueries, and derived tables.
Live mode can be stopped explicitly:
await result.abort();Above, result is a RealtimeResult.
Deep Dive
The mechanics of the engine are covered in the LinkedQL Realtime Engineering Paper.
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.
Live Queries in Practice
Live querie have various real-world use cases. Consider the following.
Analytics Dashboards
A live metric board where aggregates and rollups update as source data changes.
The important behavior is not just "new rows arrive." It is that totals, subtotals, and grouped outputs rebalance in place as the underlying rows change.
SELECT
region,
product,
SUM(sales) AS total_sales,
ROLLUP(region, product)
FROM orders
GROUP BY GROUPING SETS ((region, product), (region), ());Observed effect: totals and subtotals rebalance in place as orders stream in. Charts can update directly from the live view instead of being rebuilt from ad hoc event logic.
Collaborative Workspaces
A shared document list showing current editors, owners, or status indicators.
SELECT
d.id,
d.title,
u.name AS active_editor
FROM documents d
LEFT JOIN users u ON d.active_user = u.id;When a user opens or leaves a document, the corresponding row in the result updates as a row.
Observed effect: the row persists while the joined field changes in place. The view stays continuous.
Live Feeds
A user-facing feed ordered by recency and limited in size.
SELECT *
FROM posts
ORDER BY created_at DESC
LIMIT 20;Here the live result keeps representing "the newest twenty posts", not anymore "the twenty posts that happened to be present at first render."
Observed effect: new posts slide into the top of the view; older ones fall off. Ordering and window limits continue to hold.
Rankings and Leaderboards
Window functions and aggregates often define the visible shape of the result itself.
SELECT
user_id,
SUM(score) AS total_score,
RANK() OVER (ORDER BY SUM(score) DESC) AS rank
FROM scores
GROUP BY user_id;As scores change, ranks move as part of the query result.
Observed effect: scores accumulate in place and ranks shift as the underlying rows change.
Mixed and Derived Sources
Queries that combine subqueries, derived tables, and inline relations behave the same way.
SELECT
p.id,
p.title,
r.reaction_count,
c.comment_count
FROM posts p
JOIN (
SELECT post_id, COUNT(*) AS reaction_count
FROM reactions
GROUP BY post_id
) r ON r.post_id = p.id
LEFT JOIN (
SELECT post_id, COUNT(*) AS comment_count
FROM comments
GROUP BY post_id
) c ON c.post_id = p.id;Observed effect: changes to reactions or comments are reflected in the joined aggregate view itself, not leaked as unrelated table events for the application to reconcile manually.
Live Views in Detail
result.rows is a self-updating array of objects, each row in the array reflecting the current query truth in realtime.
For a query like:
const result = await db.query(
'SELECT id, title FROM posts ORDER BY id',
{ live: true }
);You get an initial result:
[A, B, C, D] // initial resultWhen a matching row is inserted in the database, it appears in the view:
INSERT INTO posts (title) VALUES ('Hello World');[A, B, C, D, E] // E addedWhen a matching row is updated in the database, the corresponding row object in the view updates in place:
UPDATE posts SET title = 'Hello Again' WHERE title = 'Hello World';[A, B, C^, D, E] // C updated in placeWhen a matching row is deleted in the database, the corresponding row leaves the view:
DELETE FROM posts WHERE title = 'Hello Again';[A, B, D, E] // C deletedThat is the simplest live-view model: the query result automatically remains current.
Joins and Join Transitions
Once a query involves joins:
const result = await db.query(
`SELECT
posts.id,
posts.title,
users.name AS author_name
FROM posts
LEFT JOIN users ON posts.author_id = users.id`,
{ live: true }
);the semantics get deeper.
Here, rows in the result are composed from multiple relations. As underlying tables change, the visible effect is not always a simple add, update, or remove.
A table-level mutation may affect only the right-hand or left-hand side of a join. That may mean that a right-hand or left-hand side of the join that once matched no longer matches, or the reverse. In LinkedQL, this is understood as Join Transitions.
By default, certain join transitions should cause the corresponding old row in the view to disappear and be regained as a new row. But that would break continuity and identity for observers bound to those rows. LinkedQL prevents that by special-casing these transitions to preserve identity and continuity.
The result is: stable rows that simply mutate in-place in however the underlying tables change.
Scenario 1: A Join Materializes After an INSERT
Suppose a post already exists with an author_id: 42, but the matching user row does not yet exist. Suppose the following INSERT materializes the join:
INSERT INTO users (id, name) VALUES (42, 'Bob');The observed effect in the view would be: the existing row now gains a right-hand match.
┌──────────────────────────────────────────────────────────────┐
│ { id: 1, title: 'Untitled', author_name: null ─────→ 'Bob' } │
└──────────────────────────────────────────────────────────────┘Scenario 2: The Join Relationship Changes After an UPDATE
Suppose author_id is updated to point to another user. That would change the join relation.
UPDATE posts SET author_id = 43 WHERE title = 'Untitled';The observed effect would be: the row persists; the joined field changes; continuity is preserved
┌───────────────────────────────────────────────────────────────┐
│ { id: 1, title: 'Untitled', author_name: 'Bob' ─────→ 'Ada' } │
└───────────────────────────────────────────────────────────────┘Scenario 3: The Join Dissolves After a DELETE
Suppose the current right-hand match is deleted. That would dissolve the relationship.
DELETE FROM users WHERE id = 43;The observed effect would be: the corresponding row remains in the view but loses its right-hand match.
┌──────────────────────────────────────────────────────────────┐
│ { id: 1, title: 'Untitled', author_name: 'Ada' ─────→ null } │
└──────────────────────────────────────────────────────────────┘The overall effect becomes: identity persists. The view stays true to join semantics without leaking low-level mutation shape into the result model.
Frames and Ordinality
Queries that have ordering, limits, or offsets applied materialize in the view with the semantics of each modifier fully maintained.
const top5 = (await client.query(
`SELECT *
FROM posts
ORDER BY created_at DESC
LIMIT 5`,
{ live: true }
)).rows;This view will always represent "the newest 5 posts" across INSERT and DELETE operations.
Initially:
[A, B, C, D, E] // initial resultThen on "INSERT N":
[N, A, B, C, D] // N enters the view; E falls off because it’s now #6Then on "UPDATE C’s created_at to promote it one level higher in the list":
[N, A, C, B, D] // C and B swap places without initiating a full re-orderingEssentially, ordering and slicing remain stable relationships — they evolve as data changes, without recomputation.
Precision and Granularity
Live updates apply the smallest possible change needed to keep the view correct. This is a key design goal in LinkedQL.
(a) Field-level updates
If only one column changes in one row, only that column is updated in the corresponding row in the view.
const current3 = result.rows[2];UPDATE posts SET title = 'Draft' WHERE id = 3;console.log(result.rows[2].title); // 'Draft'
console.log(current3 === result.rows[2]); // true; persistent, only patched(b) Reordering without teardown
When the effect of an operation is simply a change in position (for queries with an ORDER BY clause), the view isn't re-ordered in full. Instead, the engine issues a positional swap event: "swap B and C"
Before: [A, B, C, D, E]
After: [A, C, B, D, E]C and B simply swap places.
Why that matters:
Precision and granularity keeps the system – all the way to the consumers bound to the view – highly efficient. When rendering on the UI, for example:
- The UI maintains state, avoids unnecessary rerenders, and never flickers.
- Components keyed by row identity keep their state.
- You don’t lose scroll position.
Observability and Atomicity
Live views are not just auto-updating — they are also observable.
LinkedQL exposes them through the Observer API. Observer is a general-purpose JavaScript API for observing object and array-level mutations.
This makes result.rows observable like any object.
Observer.observe(result.rows, (mutations) => {
console.log(`${mutations[0].type}: ${mutations[0].key} = ${mutations[0].value}`);
});You pass a callback, as shown above, to observe root-level changes — which, for result.rows, means row additions and deletions.
You observe field-level changes by adding the Observer.subtree() directive:
Observer.observe(result.rows, Observer.subtree(), (mutations) => {
console.log(`${mutations[0].type}: ${mutations[0].key} = ${mutations[0].value}`);
});LinkedQL leverages Observer’s batching feature to preserve the atomicity of the database transactions behind the emitted events. All mutations that occur within a single database transaction are delivered together in a single callback turn.
For example:
BEGIN;
UPDATE posts SET title = 'Pinned' WHERE id = 3;
UPDATE posts SET title = 'Pinned' WHERE id = 4;
COMMIT;Observer fires once, with a batch containing both updates:
Observer.observe(result.rows, Observer.subtree(), (mutations) => {
console.log(mutations);
/*
Example output:
[
{ type: 'set', path: [0, 'title'], value: 'Pinned' },
{ type: 'set', path: [1, 'title'], value: 'Pinned' }
]
*/
});Transactions are not split across multiple emissions. Each callback reflects a complete and consistent state transition.
Driving Application-Level Reactivity Directly
Live Views are designed to directly drive application-level reactivity. They come from the database layer as reactive state that are themselves observable.
That removes an entire layer of work:
- no event-to-state mapping layer
- no reducers or reconciliation logic
- no risk of state drifting from the source of truth
The database result itself becomes the state your application works with.
Here, the result object flows through your application as a regular object, your application logic directly reacts to its changes.
Observer.observe(result.rows, (mutations) => {
console.log(mutations);
});When used within application stacks where mutation-based reactivity is a first-class concept, Live Views integrate even more natively.
For example:
- reactivity in frameworks like Webflo is driven both on the backend and on the frontend – universally – by live states.
- reactivity on the UI via OOHTML can be driven entirely by live state.
Essentially, with Observer as the shared vocabulary of change, result.rows's "live state" nature can effectively drive reactivity universally for modern stacks without an explicit subscription.
For older stacks, a minimal, explicit subscription line – Observer.observe() – helps you acheive the same.
The Callback Mode
In LinkedQL, live views (result.rows) are the high-level interface. Underneath it is a lower-level commit stream.
You can subscribe to this stream directly and maintain your own state store. This is useful if you’re building a custom cache, or replication layer.
const q = `
SELECT id, title
FROM posts
ORDER BY created_at DESC`;
const liveHandle = await client.query(q, (commit) => {
console.log(commit);
}, { live: true });In this mode:
- all changes are observed from the callback alone as events
result.rowsrepresents only the initial result of the query, and behaves as a static result set as against a live objectresult.modeis set to'callback'– indicating the consumption mode
Compared to the default live view concept, custom event handling sits closer to the wire.
The Event Stream
The live query event stream is made of three event types:
| Event | Meaning |
|---|---|
diff (commit.type === 'diff') | Incremental inserts, updates, and deletes |
swap (commit.type === 'swap') | Positional swaps that satisfy an ORDER BY clause |
result (commit.type === 'result') | A new snapshot of the query result |
const liveHandle = await client.query(q, (commit) => {
if (commit.type === 'diff') for (const e of commit.entries) mutationState(e);
if (commit.type === 'swap') applySwaps(commit.entries);
if (commit.type === 'result') replaceState(commit.rows, commit.hashes);
}, { live: true });The diff Event
A typical diff event contains one or more entries describing result-level changes.
{
type: 'diff',
entries: [...],
...commitMeta
}insert Descriptor
{
op: 'insert',
new: { id: 1, name: 'Ada' },
newHash: '[[1]]',
}update Descriptor
{
op: 'update',
old: { id: 1, name: 'Ada' },
new: { id: 1, name: 'Ada Lovelace' },
oldHash: '[[1]]',
newHash: '[[1]]',
}delete Descriptor
{
op: 'delete',
old: { id: 1, name: 'Ada Lovelace' },
oldHash: '[[1]]',
}The hashes are the stable result-level row identifiers.
The swap Event
This event contains positional swaps that satisfy an ORDER BY clause.
{
type: 'swap',
entries: [...],
...commitMeta
}Entries are pairs of positional swaps by ID (the hashes):
[
['[[3]]', '[[1]]'],
['[[1]]', '[[3]]'],
]The above should have a re-ordering effect like:
[row1, row2, row3] -> [row3, row2, row1]The result Event
This event represents a new snapshot of the query result – for when diffrential updates aren't feasible for the query type. This typically happens with queries with aggregates.
{
type: 'result',
rows: [{ id: 1, name: 'Ada' }, { id: 2, name: 'Jane' }],
hashes: ['[[1]]', '[[2]]']
...commitMeta
}The rows and hashes array have the exact same meaning as the standard result's rows and hashes.
The handler is expected to replace local state with the new result.
The Two-Mode Consumption Model
The "callback" mode above and the default "live view" mode give you two ways to consume live queries. This is by design. And each caters to two very different needs:
- Live views: state-based consumption
- The callback mode: event-based consumption
A live view literally translates the event stream into application-ready state.
A callback opts out of that and in to the stream itself to manually interprete events.
Stable Subscription Slots
You can attach an id to a subscription:
const result = await db.query(
'SELECT * FROM posts ORDER BY id',
{ live: true, id: 'posts_slot' }
);That id is more than a label. It gives the query a durable slot identity, and LinkedQL binds that subscription to the same slot each time it is recreated with the same id.
Behaviour
With a durable slot identity, the runtime:
- is able to resume from the same logical slot on requeries
- catches the subscriber up on commits that were missed while away
- continues emitting to the subscriber from that state
State continuity also means:
- previous initial snapshot isn't redelivered; consequently...
result.rowsis emptyresult.initialisfalse
That matters when live queries 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 result = await db.query(
'SELECT * FROM posts ORDER BY id',
(commit) => commits.push(commit),
{ live: true, id: 'posts_slot' }
);
await db.query(`
BEGIN;
INSERT INTO public.posts (id, title) VALUES (1, 'Hello');
UPDATE public.posts SET title = 'Hello World' WHERE id = 1;
COMMIT;
`);
await result.abort();
await db.query(`DELETE FROM public.posts WHERE id = 1`);What happens:
- you get one commit event containing two diffs:
insertandupdate - you called
result.abort()and don't get the second commit
const result = await db.query(
'SELECT * FROM posts ORDER BY id',
(commit) => events.push(commit),
{ live: true, id: 'posts_slot' }
);What happens now:
- you re-subscribed to the same subscription slot
- you get the one commit event you missed:
delete result.rowsis emptyresult.initialisfalse
Dropping Slots
To drop the slot itself, pass { forget: true } to the abort() call:
await result.abort({ forget: true });Visibility and Security
Live Queries follow the same fundamental rule as any SQL query where visibility is policy-driven. A live query run within a policy-driven context fulfills the same contract as a normal query within that same context.
Transactions are the primary way to define that context.
Policy-Driven Execution
When you run a Live Query within a policy-bound transaction, the Live Query engine operates inside that same transaction. As a result, every re-evaluation of the query runs under the exact same security constraints – inheriting the visibility rules active at that time.
In systems like PostgreSQL, this pairs directly with Row-Level Security (RLS).
That typically looks like this:
const tx = await db.begin();
await db.query(
`SELECT set_config('claims.user_id', 'user_abc_123', true)`,
{ tx }
);
const result = await db.query(
`SELECT id, title FROM public.posts ORDER BY id`,
{ live: true, tx }
);Here:
- the transaction establishes the security context (
claims.user_id). How you establish that context will depend on you. Regardless: - the Live Query runs inside that context
- every update pushed to the client reflects only what that context is allowed to see
The result stream stops being a simple "updates to this query" — it becomes:
"updates to this query as visible under this policy"
That distinction is the entire model.
Relationship to the Commit Stream
Under the hood, Live Queries are powered by the same commit stream that powers commit-level subscriptions via db.wal.subscribe().
But the visibility model is intentionally different.
- Changefeeds expose raw commit entries. Visibility rules are enforced via explicit filtering –
resolveCommitVisibility() - Live Queries expose query results; driven by the same commit stream but work at a higher-level. Visibility is enforced by the query execution itself via transactions
Because of that, Live-Query-originated subscriptions are not passed through resolveCommitVisibility() by default. Re-applying row-level filtering at the commit level would be redundant and wasteful.
The engine already enforces visibility by re-running the query under the given context.
Optional Commit-Level Gating
That said, Live Queries can still participate in commit-level visibility control when needed. The effect would be:
- controlling what the Live Query engine itself sees
- while the engine's own execution model controls visibility for the user
To centralize commit-level visibility at resolveCommitVisibility() this way, the centralizeCommitVisibility flag is set to true:
const db = new PGClient({
centralizeCommitVisibility: true,
resolveCommitVisibility: async (entries, sub) => {
if (sub.liveQueryOriginated) {
// Subscription originated from a Live Query
}
// Normal changefeed subscription
return entries;
},
});Live-Query-originated subscriptions will be routed through the same hook — with sub.liveQueryOriginated === true.
The resolveCommitVisibility() handler is expected to handle Live-Query-originated subscriptions at a more coarse-grained level than regular subscriptions.
Good use cases include:
- blocking entire tables from ever reaching the Live Query engine
- suppressing sensitive relations (e.g.
audit_log) - enforcing column- or relation-level access rules
const db = new PGClient({
centralizeCommitVisibility: true,
resolveCommitVisibility: async (entries, sub) => {
if (sub.liveQueryOriginated) {
// Coarse-grained filtering only
return entries.filter(
(entry) => entry.relation.name !== 'audit_log'
);
}
// Normal changefeed behavior
return entries;
},
});What you should avoid for Live-Query-originated subscriptions is re-implementing row-level visibility lookups inside the hook (e.g. querying the database per entry). The Live Query engine's default behaviour already guarantees correctness at that level.
Mental Model
A clean way to think about the split is:
Changefeeds: "What changed in the database?"
→ optional visibility enforcement:resolveCommitVisibility()Live Queries: "What changed at the level of this query?"
→ visibility enforcement: transactionsCross-cutting visibility questions: "What commit-level rules apply in both cases?"
→ visibility enforcement:resolveCommitVisibility()+centralizeCommitVisibility: true
Scaling Model
Live queries in LinkedQL are built on a shared execution model that drastically minimizes computation and database lookup costs. When one or more queries overlap, the engine organizes them into a hierarchy of evaluation windows.
In this model, a broader query becomes a canonical query window, and any overlapping or more constrained queries become derived windows that inherit from the base.
This is called query inheritance.
To see how it works, suppose all five queries below are recieved by the Live Query engine:
SELECT * FROM users;
SELECT id, name FROM users;
SELECT * FROM users ORDER BY created_at DESC;
SELECT * FROM users LIMIT 20 OFFSET 40;
SELECT * FROM users WHERE active = true;The first statement establishes the canonical query window for users.
The remaining queries are derived windows. They do not re-run the base query. Instead, they inherit its result stream and apply additional shaping rules:
- column projection (
SELECT id, name) - filtering (
WHERE active = true) - ordering (
ORDER BY created_at DESC) - slicing (
LIMIT / OFFSET)
Inheritance works retroactively such that as new queries come, the broadest query is promoted to canonical status while the others inherit.
The result is that database lookups and computations, like diffing, are restricted to a single window. Other windows simply operate on the result of their parent window.
This is what makes live queries even tractable on mainstream databases like PostgreSQL in the first place.
Deep Dive
The mechanics of the engine are covered in the LinkedQL Realtime Engineering Paper.
Cost Profile
Without query inheritance, each live query is evaluated independently whenever the underlying data changes.
With query inheritance, the canonical window is evaluated once, and derived windows reuse its result stream.
In other words:
- one change to
usersproduces one base evaluation - all derived windows are updated from that shared result
In other words:
10,000 users subscribed to variations of users still all converge on the same canonical query window:
- 10,000 subscriptions
- = one canonical evaluation with derived updates
Cost in LinkedQL is a function of query diversity, not subscription count.
Inheritance Boundaries
Query inheritance only applies when queries exist within the same execution boundary.
Inheritance is evaluated only after confirming that queries share the same boundary conditions.
This boundary is defined by:
Structural equivalence
Queries must match in structure.
This ensures inheritance is based on query intent, not just formatting or syntactic variation.
Parameter equivalence
All query parameters must match exactly.
Any difference in parameter values creates a separate execution boundary, even when the SQL structure is identical.
Transaction context
Queries must share the same transaction or visibility scope.
This ensures inheritance does not cross transactional or policy boundaries — including RLS rules, visibility constraints, or any session-level enforcement tied to the transaction context.
In practice
Query inheritance is not a global optimization layer. It is strictly scoped to a single execution boundary in which canonical and derived query windows are allowed to exist.
Within that boundary:
- execution does not leak across parameterized queries
- transactional policies remain consistently enforced across all derived windows
- structural overlap is only exploited when visibility context is identical
Additional Reading
| If you want to learn about... | Go to... |
|---|---|
| the related changefeed subscription model | Query API |
the query() in API detail | Query API |
| the transaction API in detail | Transaction API |
Appendix A — Implied Schema and Dialect
The examples in this document assume a simple illustrative schema and a specific SQL dialect.
Default Dialect
Unless otherwise noted, all examples assume the PostgreSQL syntax and semantics.
Equivalent behavior applies across other SQL dialects (e.g., MySQL, MariaDB) where supported.
Reference Schema
The following minimal schema underpins most examples:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT,
email TEXT
);
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
title TEXT,
author_id INTEGER REFERENCES users (id),
created_at TIMESTAMP DEFAULT NOW()
);