Operator-graph queries
defineGraphCollection is the path for ranged subscriptions over big tables. A declarative builder (query(source).filter(...).orderBy(...)) compiles into an incremental operator graph: the source's hydrate pushes filters to SQL, and incremental changes flow through only the operators they touch — so live-update latency stays bounded as the table grows.
#The default-path cost
The default defineReactiveQuery path is fine for small tables, but its body re-runs on every change to the tables it read. With a naïve db.all('tasks').filter(...), that's O(table size) per change — bench-measured at ~580 ms live-update latency at 100k rows:
// The default reactive-query path:
engine.registerReactive(
defineReactiveQuery<Task, string>({
key: (task) => task.id,
name: 'tasksByAssignee',
run: async ({ db, params }) => {
// db.all reads the whole table on every change to 'tasks',
// then we filter + sort in JS. O(table size) per change.
const all = await db.all<Task>('tasks');
return all
.filter((task) => task.assignee === params)
.sort((a, b) => a.priority - b.priority);
}
})
);#defineGraphCollection — bounded live updates
The graph collection compiles your query(source).orderBy(...) chain into a pipeline. The source's hydrate runs the filtered SQL once per subscriber; match scopes incremental changes so a row that doesn't belong to this subscriber's view never enters its pipeline; the orderBy operator maintains a sorted result incrementally:
// The same query, wired through the operator graph:
engine.registerGraph(
defineGraphCollection<Task, string>({
key: (task) => task.id,
name: 'tasksByAssigneeGraph',
query: query<Task, string>({
table: 'tasks',
key: (task) => task.id,
// SQL pushdown: hydrate the source with the FILTERED row set,
// not the whole table. Runs once per subscriber, not per change.
hydrate: (assignee) =>
prisma.task.findMany({
where: { assignee },
orderBy: { priority: 'asc' }
}),
// Scope incremental changes: a row that fails this predicate
// leaves the view; a row that passes it joins.
match: (task, assignee) => task.assignee === assignee
}).orderBy({
key: (task) => task.id,
compare: (a, b) => a.priority - b.priority
})
})
);The 13.8× win at 100k rows isn't magic — the engine simply stops re-scanning the whole table. Live-update p50 drops to ~42 ms, bounded and independent of table size: the operator graph routes the changed row through this subscriber's pipeline; the other 99,800+ rows aren't touched. Cold-subscribe also improves (~2.7× at 100k) because the initial snapshot is the filtered SQL result, not the whole table.
#The builder — filter, map, join, leftJoin, groupBy, orderBy
The chain is purely declarative; each stage is a composable operator the engine wires into the graph:
import {
defineGraphCollection,
query
} from '@absolutejs/sync/engine';
const ordersByUser = defineGraphCollection<DenormalisedOrder, { userId: number }>({
name: 'ordersByUser',
key: (row) => row.id,
query: query<Order, { userId: number }>({
table: 'orders',
key: (order) => order.id,
hydrate: ({ userId }) =>
prisma.order.findMany({ where: { userId, status: 'open' } }),
match: (order, { userId }) =>
order.userId === userId && order.status === 'open'
})
// Project each row into its display shape.
.map((order) => ({
id: order.id,
total: order.total,
placedAt: order.createdAt
}))
// Join in the line items (incremental — adding an item updates the
// matching order's row, not every order).
.join(
{
table: 'orderItems',
key: (item) => item.id,
hydrate: ({ userId }) =>
prisma.orderItem.findMany({
where: { order: { userId, status: 'open' } }
}),
match: (item, _params, ctx: { orderIds: Set<number> }) =>
ctx.orderIds.has(item.orderId)
},
{
on: (order) => order.id,
rightOn: (item) => item.orderId,
select: (order, item) => ({ ...order, item }),
key: (out) => `${out.id}:${out.item.id}`
}
)
// Keep the top 50, sorted by recency.
.orderBy({
key: (row) => row.id,
compare: (a, b) => b.placedAt - a.placedAt,
limit: 50
})
});Joins and aggregations are equally incremental — adding one orderItem updates the matching order's row in the result, not every order. See JoinOptions / GroupByOptions / OrderByQueryOptions in @absolutejs/sync/engine for the full surface.
#When to reach for it
Use it when the query body would otherwise be O(table size) on every change:
- filtered subscriptions over big tables (
where assignee = $me) - top-N + ORDER BY (
limit 50 ORDER BY priority desc) - joins (
orders+ theirorderItems) - aggregations (
groupBy+ sum/count) - any case where you'd otherwise re-read the table and filter in JS
- the query genuinely depends on every row (e.g. a global count)
- the table is small enough that O(table) is cheap (< ~1k rows)
- the query is one-off / not subscribed to under load
#Head-to-head numbers (100k-row table)
Same workload, same engine, same hardware — only the query path differs. Full distribution + cold-subscribe numbers in absolutejs/benchmarks/sync/RESULTS.md:
| Path | Live-update p50 (100k rows) | Engine cost shape |
|---|---|---|
db.all + JS filter | ~580 ms | O(table size) per change |
defineGraphCollection | ~42 ms — a 13.8× speedup | O(diff) per change |
The bench scripts live in absolutejs/benchmarks under sync/scripts/reactive/.