The engine

Schema & migrations

SQL is the source of truth. Define your tables in SQL, evolve them with additive migrations, and generate the typed schema your query builder needs — with one CLI: rindle migrate, rindle schema gen.

View as Markdown

Rindle’s data model is tables with typed columns — and where your data lives in SQLite (the rindled daemon, a Rust/Node replica, or a synced app), the SQL schema is the source of truth. You define your tables in ordinary SQL and evolve them with migrations; the daemon introspects the live file and maintains your queries against it.

The TypeScript schema you may have seen — table("issue").columns({…}).primaryKey("id") — is not where your data model lives. It is a generated, typed facade for the query builder: it gives the builder its column types, drives the comparator, and parses json columns on read. You generate it from the SQL with one command, so the client and the database can never drift. This page is that loop.

Writing Rust? The embedded rindle-replica path is already SQL-first — you run your own CREATE TABLE and build queries with the rindle::table AST builder, no generated TypeScript in sight. The migrate + schema gen loop below is for the daemon and its JS/TS clients.

The loop

One toolchain does all of it: the rindle CLI, shipped beside the daemon. (Rust: installed with rindled. JS/TS: npm i -D @rindle/cli, then npx rindle …; see @rindle/cli for the toolchain reference.)

rindle init          # scaffold rindle.ncl (the colocated pair) + an empty migrations/ directory
rindle up            # render rindle.ncl + supervise the local write-master + follower (Ctrl-C to stop)

rindle init writes a loopback dev topology and a migrations/ folder:

# rindle.ncl — the one topology (design 214): a write-master + follower(s)
{
  profile = "replicated",
  app = "my-app",
  followers = 1,   # 1 = the colocated pair, both processes on one box
}

rindle up renders it to the write-master + follower pair on loopback and supervises both. There’s no table list anywhere — your tables come from migrations, auto-discovered on the follower as the master’s DDL replicates.

1 · Author a migration in SQL

rindle migrate create init     # creates migrations/0001_init.sql

A migration is ordinary, additive SQL DDL — one statement per ;. Every table needs a single declared primary key (the engine indexes on it). Declare a column’s kind with its type name — including BOOLEAN and JSON (more below). Two habits pay off: use IF NOT EXISTS so a re-run is safe, and add an index for each direction your joins and windowed orderBys traverse. (Column order matters too — the engine reads it back with PRAGMA table_info, so append new columns rather than reordering.)

-- migrations/0001_init.sql
CREATE TABLE IF NOT EXISTS issue (
  id        TEXT PRIMARY KEY,
  title     TEXT    NOT NULL,
  closed    BOOLEAN NOT NULL DEFAULT 0,    -- declared BOOLEAN  → boolean()
  labels    JSON    NOT NULL DEFAULT '[]', -- declared JSON     → json()
  priority  INTEGER NOT NULL DEFAULT 0,
  createdAt REAL    NOT NULL
);
CREATE INDEX IF NOT EXISTS issue_created ON issue (createdAt DESC, id);  -- the paginated window

CREATE TABLE IF NOT EXISTS comment (
  id      TEXT PRIMARY KEY,
  issueId TEXT NOT NULL,
  body    TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS comment_issue ON comment (issueId);           -- the issue → comments join

2 · Apply it

rindle migrate apply           # POSTs each *.sql to the daemon, in order, idempotently

The CLI lints each statement (additive only — no DROP/RENAME; see the DDL subset), then applies it through the write-master’s ordered migration path, which mints a schema-version bump and replicates it to every follower. The new tables are auto-discovered — you don’t list them anywhere. Under rindle up, the pair reshapes to serve the new schema; a self-hosted deployment is bounced once (rindle restart, a process restart, or fly machine restart):

[migrate] applying 1 migration(s) from migrations/ → http://127.0.0.1:7611
  [applied] 0001_init  schemaVersion=0001_init
[migrate] done — 1 newly applied, 0 already present

rindle migrate apply is safe to re-run: the write-master dedups by migration id, so already-applied migrations report present and nothing re-runs. rindle migrate status diffs your local folder against what the write-master has applied.

3 · Generate the typed schema

rindle schema gen --out src/schema.gen.ts

This reads the follower’s introspected schema (GET /schema) and emits the @rindle/client definition — one const per table, sorted by name, plus the createSchema aggregate:

// Generated by `rindle schema gen` from the daemon's introspected schema (GET /schema).
// Do not edit by hand — re-run the generator after each migration.
import { boolean, createSchema, json, number, string, table } from "@rindle/client";

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

export const issue = table("issue")
  .columns({
    id: string(),
    title: string(),
    closed: boolean(),   // ← from the declared BOOLEAN
    labels: json(),      // ← from the declared JSON
    priority: number(),
    createdAt: number(),
  })
  .primaryKey("id");

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

That’s the whole loop: edit SQL → migrate applyschema gen. Re-run the last two after every schema change.

Adding local-only client tables to a generated schema

Do not hand-edit the generated file for browser-only tables such as drafts, selections, or view preferences. Define those tables in a separate module and extend the generated schema:

// src/schema.local.ts
import { extendSchema, string, table } from "@rindle/client";
import { schema as generatedSchema } from "./schema.gen.ts";

export const selection = table("selection", { local: true })
  .columns({ id: string(), issueId: string() })
  .primaryKey("id");

export const clientSchema = extendSchema(generatedSchema, { tables: [selection] });

Use clientSchema in the browser. Keep using the generated schema for your API server and any daemon-facing named-query registry. extendSchema accepts only { local: true } tables, which keeps real synced tables SQL-first and generated from daemon introspection.

Column types: arbitrary type names

SQLite has only five storage classes, but it stores the full declared type name verbatim and never restricts what you write. Rindle reads that declared name back, so you get the column kind you meant — not just a coarse affinity:

You declare Generates Notes
TEXT · VARCHAR(n) · CHAR · CLOB string() TEXT affinity
INTEGER · REAL · NUMERIC · DECIMAL · … number() numbers are f64
BOOLEAN · BOOL boolean() recovered from the declared name
JSON · JSONB json() recovered from the declared name; stored as TEXT
BLOB string() no blob type yet — store bytes as base64 TEXT

So the SQLite “type limitation” is a non-issue: declare BOOLEAN or JSON and the generated schema is boolean() / json(). This matches what the engine already does internally — a BOOLEAN column compares as a boolean, a JSON column is parsed on read — so the generated types agree with runtime behavior.

The one thing a declared name can’t carry is a refinement within a kind — the element type of json<T>(), or a string/number literal union. Those you layer on by hand after generating (the generated file is yours to re-annotate, then re-apply after each regen):

import { json, type Col } from "@rindle/client";

labels: json<string[]>(),                          // refine the JSON shape
status: string() as Col<"todo" | "doing" | "done">, // refine a string to a literal union

A bare INTEGER you intend as a boolean stays number() — the name carried no intent. Declare it BOOLEAN to recover it. (bigint and raw blob are refused at apply time: numbers are f64, and there is no blob column type yet.)

What the generated schema is for

The schema is purely for the typed query builder — it is never consulted for correctness. Concretely it gives you:

  • Typed queries and rows. schema types store.query.<table> and the rows you read back, so where/orderBy/select are checked against real columns and a result is { id: string; closed: boolean; labels: string[] }, not any.
  • The comparator. Each column’s kind drives ordering (strings bytewise, numbers by total order, booleans as 0/1) so a client sorts a view exactly as the engine does.
  • json parsing. json columns arrive as text on the wire and are parsed to objects once, on read.

What it is not: it carries no relationships. Query correlations (issue.id → comment.issueId) live in your named queries and fragments, not in the schema — which is why plain SQL introspection (columns + PK) is enough to generate it. And because the daemon validates a client’s schema fingerprint on subscribe, a stale generated schema is rejected and re-fetched, never silently wrong: regenerating after a migration is a convenience, not a correctness burden.

Import it wherever you build queries — the synced client and API server share the one value:

import { createRindleClient } from "@rindle/optimistic";
import { schema } from "./schema.gen.ts";          // generated
import { mutators } from "./mutators.ts";          // hand-written (your app logic)

export const app = await createRindleClient({ schema, mutators, /* … */ });

Evolving your schema

v1 migrations are additive: CREATE TABLE, ADD COLUMN, CREATE INDEX. (Destructive or rewriting DDL — DROP / RENAME / type changes — is rejected for now; fix forward with a new additive migration.) Each migrate apply advances the write-master’s schemaVersion, which namespaces live query results: an old-schema client can’t attach to a new-shape view, so on the bounce it simply re-leases against the new version. After any change, re-run rindle schema gen and ship the regenerated schema with your client.

Migrations are the one way to shape the schema: rindle migrate apply sends your DDL to the write-master, which replicates it to every follower. There’s no inline table list to maintain.

The one hand-authored case: standalone wasm

@rindle/wasm run with no server has no SQLite underneath — it maintains queries over in-memory rows you push with tx.add(…). There is no database to introspect, so for that standalone playground you write the same table(…).columns({…}) schema by hand. The moment a daemon backs it (a synced app), the schema becomes a generated artifact again — the source of truth moves back to SQL.

Next steps