Reactive queries
Keep a declared query's result current without polling — durable subscriptions, exactly-once delta delivery, held streams, and explicit rehydrates.
Reactive subscriptions keep a declared query’s result current for a client without polling: register a query with bindings, receive the initial rows at a committed head, then consume result deltas keyed to committed heads with exactly-once cursor resume. Every pushed result is a fresh execution of the query at that head — parity with one-shot execution is the correctness contract, and frames are byte-equal at identical cursors.
The subscription lifecycle
- Register —
POST /v1/queries/{name}/subscriptionswith bindings returns the subscription id, the initial rows, the committed head, the result digest, and the query’s transitive read set. Registrations are durable, store-backed leases that survive server restarts. - Consume — either hold the SSE stream
(
GET /v1/queries/subscriptions/{id}/stream, resuming viaLast-Event-ID) or fetch batches (GET /v1/queries/subscriptions/{id}/updates?since_update_id=N). Deltas carry the head, a per-subscription monotonic update id, adds and removes, and the result digest. - Rehydrate when told to. When history is unavailable — a stale cursor, a lost update log after restart, an oversized delta — the server sends an explicit full-replace rehydrate event. Never silence, never fabricated replay.
- Unsubscribe —
DELETE /v1/queries/subscriptions/{id}ends held streams and drops the lease. Expired leases reap to a non-disclosingquery.subscription_not_found, and the client simply re-registers.
Efficient by read set
Commits touching only relations outside a subscription’s static read set do no work.
Touched subscriptions re-execute at the committed head, and unchanged results are
digest-suppressed. One deliberate exception: any commit touching an access.* relation
re-executes every live subscription on the lineage — authorization can change what a
subscriber may see without touching the read set. Authorization loss emits a terminal
revoked event; subscription frames are row-filtered, so a policy-invisible tenant’s
append is invisible in the delta stream too.
Backpressure is typed, never silent: transient door pressure defers pushes; a held-stream
ring overflow closes the stream with fanout_buffer_full (a resume signal, not data
loss); per-tenant ceilings deny with the typed tenant_backpressure shape.
From the SDK
import { createTarskiClient } from '@tarski/client';
const client = createTarskiClient({ baseUrl: 'http://127.0.0.1:7411' });
for await (const item of client.subscribeQuery('workspace-board', {
bindings: { workspace_id: 'ws_1' },
})) {
switch (item.kind) {
case 'initial': render(item.rows); break;
case 'delta': apply(item.adds, item.removes); break;
case 'rehydrate': render(item.rows); break; // full replace — rebuild state
case 'closed': break;
}
}
subscribeQuery prefers the held stream and degrades to the batch resume loop on open
failure — exactly-once across the transition, cursors handled for you. In the Team Tasks
reference app, one workspace-board subscription is the board’s entire data source:
initial paints, deltas apply exactly the changed rows, rehydrates replace everything, and
writes never re-read.
Local serve tuning
tarski serve exposes subscription knobs as environment variables:
TARSKI_SUBSCRIPTION_STREAM_HEARTBEAT_MS, TARSKI_SUBSCRIPTION_STREAM_BUDGET,
TARSKI_SUBSCRIPTION_LEASE_TTL_MS, TARSKI_SUBSCRIPTION_LEASE_REAP_INTERVAL_MS,
TARSKI_TENANT_HELD_STREAM_CEILING, and TARSKI_TENANT_LIVE_SUBSCRIPTION_CEILING.