Rindle’s schema is generated from SQL: a TEXT column becomes string(), a JSON
column becomes json(). That recovers the column’s kind, but SQL can’t carry a refinement within
a kind — the element type of a JSON blob (json<Meta>()), or a status TEXT column that’s really a
"todo" | "doing" | "done" union. Those you layer on in TypeScript.
Do it with refineTable and refineSchema — not by editing schema.gen.ts, which is a
wholesale-regenerated artifact. Your refinements live in a small hand-written module and are reapplied
automatically every time you regenerate.
Narrow the columns, swap in the table
schema.gen.ts exports each table def and the assembled schema. Import both into a hand-written
module, re-type the columns you care about with refineTable, then swap the refined defs into the
schema with refineSchema:
// schema.ts — hand-written, sits in front of the generated file
import { refineSchema, refineTable, json, string } from "@rindle/client";
import { schema as schemaGen, issue as issueGen } from "./schema.gen.ts";
export type Label = "bug" | "feature" | "chore";
export interface Meta { spent: number; estimate: number }
// re-type the generated `issue` columns within their kind:
export const issue = refineTable(issueGen, {
status: string<"todo" | "doing" | "done">(), // string() → literal union
labels: json<Label[]>(), // json() → typed element
meta: json<Meta>(), // json() → typed object
});
// swap the refined def into the schema so query roots and result rows narrow too:
export const schema = refineSchema(schemaGen, { tables: [issue] });
Now everything downstream is narrowed: store.query.issue conditions, where/orderBy on status,
and the rows you read back (labels is Label[], not unknown[]). Build your query builder and named
queries off this schema, not the generated one.
Why not just cast in the generated file?
Because the generated file is yours to regenerate, not to edit — re-run rindle schema gen after any
migration and hand edits are gone. refineTable/refineSchema keep the refinement in a separate module
that imports the generated one, so a regen never disturbs it. The generated header even points here.
They’re also runtime-validated identities. A refinement may only narrow the TS type within a
column’s kind — it can never change the kind, the primary key, or the locality. refineTable rejects
string() on a json column; refineSchema rejects a def that doesn’t match the schema’s table of
that name. So the def still matches the daemon’s wire schema exactly — you get sharper types with zero
runtime risk of drift.
Composes with extendSchema in either order: refine your generated tables,
extend with local-only ones, hand the result to createRindleClient.
In the wild — Strut
Strut imports its generated tables with a Gen alias and refines
the columns SQL couldn’t carry — a component’s type discriminator and props blob, a deck’s
visibility, a collaborator’s role:
// shared/app-def.ts
import { json, refineSchema, refineTable, string /* … */ } from "@rindle/client";
import { component as componentGen, deck as deckGen, deck_share as deckShareGen,
schema as schemaGen, slide as slideGen } from "./schema.ts";
export const component = refineTable(componentGen, {
type: string<ComponentType>(), // string() → the shape discriminator union
props: json<ComponentProps>(), // json() → the typed props object
});
export const deck = refineTable(deckGen, {
visibility: string<Visibility>(), // 'private' | 'public-read'
});
export const deck_share = refineTable(deckShareGen, {
role: string<CollaboratorRole>(), // 'editor' | 'viewer'
});
export const schema = refineSchema(schemaGen, {
tables: [component, deck, deck_share, slide],
});
shared/app-def.ts L54–70 · Strut
Every query builder, fragment, and Row<typeof …> in Strut is built off that refined schema, so the
typed props object and the enums flow through the whole app — while schema.ts stays a pure, generated
file that’s overwritten on every migration.
See also
- Schema & migrations — how SQL declared types map to column kinds, and what a refinement adds on top.
- Compose the UI with fragments — the query layer that reads through the refined types.