AbsoluteJS

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:

TS
// 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:

TS
// 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:

TS
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:

Reach for defineGraphCollection when
  • filtered subscriptions over big tables (where assignee = $me)
  • top-N + ORDER BY (limit 50 ORDER BY priority desc)
  • joins (orders + their orderItems)
  • aggregations (groupBy + sum/count)
  • any case where you'd otherwise re-read the table and filter in JS
Stick with defineReactiveQuery when
  • 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:

PathLive-update p50 (100k rows)Engine cost shape
db.all + JS filter~580 msO(table size) per change
defineGraphCollection~42 ms — a 13.8× speedupO(diff) per change

The bench scripts live in absolutejs/benchmarks under sync/scripts/reactive/.