Recipes

Refining schema types

Your generated schema knows a column is `json()` or `string()`, but not the shape inside — the element type of a JSON blob, or a status column that's really a literal union. `refineTable` + `refineSchema` narrow those TS types once, in a hand-written module, and survive every regeneration of `schema.gen.ts`.

View as Markdown

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 refineSchemanot 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