Use the engine directly

Reactive queries in the browser

The dream API — a typed schema, a fluent query builder, and a live materialized view, all running on the IVM engine compiled to wasm, in-process in the browser.

@rindle/wasm is the IVM engine compiled to WebAssembly and wrapped in a typed TypeScript client. You define a schema, build a query with a fluent builder, and materialize() it into a live view — a reference-stable array that stays equal to a fresh query as you write. The engine runs in-process, in the tab: no server round-trip, no polling, ~228 kB gzipped.

This is the engine on its own, with no server — the primitive underneath the synced browser client. The schema, the builder, and the view you learn here are identical once you add a server: the synced client runs this very engine, fed by a daemon. See backends & the homes for the seam that makes one view serve every tier.

Install

@rindle/wasm re-exports everything in @rindle/client, so a local app imports it all from one package. (Pre-release: depend on it through the workspace today.)

pnpm add @rindle/wasm

Define a schema

A schema is a set of typed tables. Each column has a type (string, number, boolean, json<T>()); the type drives the comparator and JSON parsing. Declare a primary key per table.

import { table, string, number, boolean, createSchema } from "@rindle/wasm";

const issue = table("issue")
  .columns({ id: number(), title: string(), priority: number(), closed: boolean() })
  .primaryKey("id");

const comment = table("comment")
  .columns({ id: number(), issueID: number(), body: string() })
  .primaryKey("id");

const schema = createSchema({ tables: [issue, comment] });

Open a store

createWasmStore initializes the wasm module (once) and returns a Store wired to the in-process backend. It is async because the first call boots the engine.

import { createWasmStore } from "@rindle/wasm";

const store = await createWasmStore(schema);

Build a query, materialize a live view

store.query.<table> is a typed builder. Chain where / orderBy / limit and finish with materialize() — you get back an ArrayView whose .data is the current result and that you subscribe to for updates.

import { eq, gt } from "@rindle/wasm";

const view = store.query.issue
  .where.closed(false)          // field proxy: `closed = false`
  .where.priority(gt(3))        // operators: eq / ne / gt / ge / lt / le / like / ilike / inList …
  .orderBy("priority", "desc")
  .limit(20)
  .materialize();

const unsubscribe = view.subscribe((rows) => {
  // rows: { id: number; title: string; priority: number; closed: boolean }[]
  render(rows);
});

subscribe fires immediately with the current data, then again after every write that affects the query — each time with the new materialized array. The contract is view-after-write == fresh-query: fold nothing yourself; the view is always exactly what a from-scratch query would return.

where is both callable and a field proxy, and there is camelCase sugar:

store.query.issue.where(or(issue.priority(gt(8)), issue.closed(true)));  // condition form
store.query.issue.where.closed(false);                                  // field proxy
store.query.issue.whereClosed(false);                                   // sugar — same thing

Write

store.write takes a function that receives a transaction. Rows are plain objects keyed by column; the store positionalizes them (and stringifies json columns) before the engine sees them.

await store.write((tx) => {
  tx.add("issue", { id: 1, title: "first", priority: 5, closed: false });
  tx.edit(
    "issue",
    { id: 1, title: "first", priority: 5, closed: false }, // old
    { id: 1, title: "first", priority: 9, closed: false }, // new
  );
  tx.remove("issue", { id: 1, title: "first", priority: 9, closed: false });
});

In the wasm backend the write is applied synchronously and the affected views update before write resolves. (In the synced client the same call goes through your API server and the resulting deltas stream back — same API, async timing.)

Nesting: sub

Nest a child relationship by explicit correlation — there are no schema-declared relationships; you state the join inline. The result type grows the alias as a typed array.

const view = store.query.issue
  .where.closed(false)
  .sub("comments", comment, { parent: ["id"], child: ["issueID"] }, (c) =>
    c.orderBy("id", "asc"),
  )
  .materialize();

view.subscribe((rows) => {
  // rows: ( …Issue & { comments: Comment[] } )[]
});

A single row: one()

A top-level .one() flips materialize() to a SingularArrayView whose .data is the single row or null (the engine caps the query to limit 1). The unwrap is type-level too.

const view = store.query.issue.where.id(eq(42)).one().materialize();
view.subscribe((row) => render(row)); // row: Issue | null, not Issue[]

Paging: start

start(cursor, { exclusive }) pages from a partial cursor row over the sort columns (it lowers to a Skip in the engine).

const page2 = store.query.issue
  .orderBy("id", "asc")
  .start({ id: lastSeenId }, { exclusive: true })
  .limit(20)
  .materialize();

Projection, aggregates, scalar subqueries

Three more builder methods — each covered in depth on supported queries:

import { exists } from "@rindle/wasm";

// projection — sync only the columns you select
store.query.issue.select("id", "title");

// aggregate — a live count of a correlated child, as a scalar `number`
store.query.issue.countAs("commentCount", comment, { parent: ["id"], child: ["issueID"] });

// scalar subquery — fold a unique lookup to a literal at build time (a snapshot)
store.query.issue.where(
  exists(comment, { parent: ["id"], child: ["issueID"] }, (c) => c.where.id(42), { scalar: true }),
);

select drives what syncs — over the wire and into the engine — and hides a row that lacks a selected column; countAs adds the alias to each row as a number; a scalar exists is resolved once at build time and does not react to later child changes.

Reads are reference-stable

view.data is structurally shared: an update re-projects only the subtrees that changed, so untouched rows keep their object identity. Frameworks that memoize on identity (React, Solid) re-render only what moved. json columns are parsed to objects once, on read.

Initialization details

createWasmStore calls initWasm() for you. If you construct a backend by hand, await initWasm() first; pass initWasm(moduleOrPath) to supply the wasm yourself (a WebAssembly.Module, URL, or bytes — useful for custom asset pipelines).

import { initWasm, WasmBackend, Store } from "@rindle/wasm";

await initWasm();                       // browser: fetches the wasm; Node: reads it from the package
const store = new Store(schema, new WasmBackend(schema));

The front-page playground is exactly this code path — the real @rindle/wasm engine maintaining a real top-N in your browser tab.

Next steps