@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/wasmengine maintaining a real top-N in your browser tab.
Next steps
- The browser client — add a server: the same engine, now synced with optimistic writes and instant local reads.
- The three-tier architecture — how this engine becomes a full synced app.
- Backends & the homes — the
Backendseam: the same store and view over wasm, native SQLite, or a server. - The change model — the delta vocabulary the view folds.
- Supported queries — what the builder can lower today.