Chapter 7 of 15

Live queries: lists that update themselves

Freddy opens his notes in two windows, edits one note in the first, and the second one changes by itself. He did not write a refresh anywhere. This chapter is about why.

The idea

A live query is a command the page subscribes to. Elyra records what it reads, re-runs it when that data changes, and pushes the new result to every window watching. It works because a desktop app's writes happen in the same process as its reads: the model layer sees every insert, update and delete, and knows the table.

Freddy's generated notes_index and notes_show from chapter 6 are already live. His own first live command is a badge in the nav with the number of pinned notes:

#[command(live, can = "notes.view")]
async fn pinned_count(ctx: Ctx) -> elyra::Result<i64> {
    let db = ctx.get::<Database>();
    Ok(Note::query().where_eq("pinned", true).count(&db).await?)
}

There is nothing live-specific inside it. On the page, rata codegen gave it a store in live.*, next to its api.* call:

<script>
  import { live } from "./bindings";
  const pinned = live.pinned_count();
</script>
{#if $pinned.value}<span class="badge">{$pinned.value}</span>{/if}

The store's value is a Live<T>: value, the latest result; error, the latest re-run's failure with the previous value kept; and loading, true until the first result. The subscription opens with the store's first listener and closes with its last, and with $derived a changed argument, a new search term say, opens a new subscription and closes the old one.

How Elyra decides what to re-run

  1. Reads are recorded. While a live command runs, every model-layer read adds its table to the command's read set: a query, a join, a relation, a pivot. Nothing to declare.
  2. Writes are reported. Every model-layer write reports its table once it is durable, which is chapter 5's last section. Inside a transaction the reports go out on commit, and a rollback sends none.
  3. What changed is matched against what was read. Only subscriptions whose read set contains the table re-run.

Then a burst of writes is coalesced into one re-run per subscription per batch window, one frame of 16 ms by default. A result byte-identical to the last one pushed is not pushed. The push goes only to the subscribing window, and re-runs go one round at a time, so an older result never arrives after a newer one.

Freddy pins a note in one window. The notes table is reported, both windows' notes_index and the badge re-run, and the badge's new number is pushed. Had he only edited a note's body, the count would have come out the same, and nothing would have been pushed for the badge.

What the model layer cannot see

Raw SQL, another process, a file, a cache or a setting: those he declares and reports himself. Keys are strings; a table's key is its name, and anything else is his to name:

#[command(live)]
async fn word_count(ctx: Ctx) -> elyra::Result<i64> {
    ctx.depends_on("notes");            // read with raw SQL below
    ctx.depends_on("settings:language");
    // ...
}

// after a raw write elsewhere
db.touch("notes");                      // or ctx.invalidate("notes")

The rules

  • A live command only reads. Since Elyra re-runs it whenever its data changes, a write inside it would re-trigger itself, so during a live run a model-layer write fails. The same command called once through api.* is an ordinary call.
  • Opt-in: only #[command(live)] can be subscribed to, and subscribing goes through the same guard as any call, including the command's ability.
  • Bounded: a window holds at most 64 subscriptions, and a window that goes away takes its subscriptions with it.
  • It needs the database feature, because the change tracking lives in the database layer.

Testing it

A test can hold a live handle and assert that a write pushes, rather than re-fetching. Freddy's version of the guide's example:

let mut pinned = app.live::<i64>("pinned_count", ()).await;
assert_eq!(pinned.value(), 0);

app.invoke_ok::<Note>("notes_store", (input,)).await;     // a pinned note
assert_eq!(pinned.next().await, 1);                          // pushed, not re-fetched

When not to

A live query re-runs the whole command. That is cheap for what a desktop app shows, a page of rows or a count, and it is what lists, details, dashboards and badges want. For a stream of fine-grained events such as the export's progress, chapter 4's channel is the tool; for an expensive report, an api.* call the user refreshes.

A live command can also be watched by an AI agent, which chapter 10 shows, and chapter 11 makes live queries follow a Laravel backend's data. The guide is Live queries.

What you learned

  • #[command(live)] makes a command subscribable; live.* in bindings.ts gives a store with value, error and loading.
  • Reads are recorded and writes reported per table; only matching subscriptions re-run, coalesced, and an unchanged result is not pushed.
  • Raw SQL and outside data are declared with ctx.depends_on and reported with db.touch or ctx.invalidate.
  • A live command only reads, and a window holds at most 64 subscriptions.
Next: in Chapter 8 Freddy makes the app feel like it belongs on the desktop.