BREAD Resources

Every generated CRUD resource is backed by a sql.ts file that owns the database queries. A BREAD resource (Browse/Read/Edit/Add/Delete) is the same CRUD pipeline - the same index grid, the same edit form, the same delete action - but backed by a store.ts stub instead of sql.ts. There is no database table behind it: the store is a hand-written contract you implement against whatever actually holds the data (a JSON file, an external API, an in-memory map, a filesystem).

Use BREAD when you need the full CRUD editing experience for data that does not live in a SQL table: a site-wide settings singleton, content pulled from a headless CMS, records served by a third-party API, or a curated list maintained in a config file.

How It Differs From DB-Backed CRUD

AspectDB-backed CRUDBREAD resource
Schema sourceIntrospected from a live database tableSupplied by you as a synthetic schema JSON file
Query layersql.ts (generated, talks to the database)store.ts (a stub you implement)
Custom query layersql.custom.tsstore.custom.ts
Index gridGeneratedGenerated (identical)
Edit formGeneratedGenerated (identical)
Delete actiondelete_record / archive_record in sql.tsdelete_item in store.ts
Generated DDLYes (sql/{mysql,sqlite}/)No (no table to create)
Foreign keysSupported (introspected)Not supported (the schema has no FKs)
Image / file fieldsSupported (write to the uploads table)Not supported (nowhere to write without a table)
Tags / autocompleteSupported (query an options table)Not supported (no option table to query)

Everything the form and index render is identical - the same form.ree, the same index.ree, the same field components, the same validation. The only difference is that index.ts imports from ./store instead of ./sql, and the function names say item instead of record.

Creating a BREAD Resource

There are two ways to generate a BREAD resource: the interactive reeman menu, or the CLI with a synthetic schema JSON file.

The Interactive Flow

Run bun reeman, then choose Fake-table resource (BREAD) from the menu. The flow walks you through:

  1. Resource name - a table-style identifier (letters, numbers, underscores), e.g. site_settings.
  2. Columns - name each column, give it a SQL type (VARCHAR(255), TEXT, INTEGER, etc.), and say whether it is nullable. The id column is added automatically. Finish with a blank name.
  3. Localized? - whether the store will hold content per locale (see Localized BREAD below).
  4. Overwrite without prompting? - force mode.
  5. Generate - confirm and the resource is written.

The CLI

bun reeman create_bread --from schema.json [--prefix <dir>] [--route-name <name>] [--force]

The --from flag points at a JSON file holding the synthetic schema. The schema must have:

  • type: "table"
  • name - the resource name (valid identifier)
  • columns - an array with exactly one primary-key column named id (auto-increment integer), plus your content columns
  • foreign_keys: [] (BREAD schemas do not support foreign keys)
  • has_view: false
{
    "type": "table",
    "name": "site_settings",
    "columns": [
        { "name": "id", "type_string": "INTEGER", "is_nullable": false, "is_primary_key": true, "is_auto_increment": true, "is_generated": false, "comment": "" },
        { "name": "site_title", "type_string": "VARCHAR(255)", "is_nullable": false, "is_primary_key": false, "is_auto_increment": false, "is_generated": false, "comment": "" },
        { "name": "description", "type_string": "TEXT", "is_nullable": true, "is_primary_key": false, "is_auto_increment": false, "is_generated": false, "comment": "" },
        { "name": "is_active", "type_string": "INTEGER", "is_nullable": false, "is_primary_key": false, "is_auto_increment": false, "is_generated": false, "comment": "" }
    ],
    "foreign_keys": [],
    "has_view": false
}

Each column object:

PropertyTypeDescription
namestringColumn name (valid identifier; must not duplicate)
type_stringstringSQL type (VARCHAR(255), TEXT, INTEGER, etc.)
is_nullablebooleanWhether the column allows NULL
is_primary_keybooleanTrue only for the id column
is_auto_incrementbooleanTrue only for the id column
is_generatedbooleanWhether the DB generates the value (usually false)
commentstringColumn comment (can set the field type, e.g. markdown)

The SQL type is resolved through the same type mapper as DB-backed CRUD, so VARCHAR(255) becomes a text input, TEXT becomes a textarea, INTEGER becomes a number, and so on. Use a column comment like markdown or textarea to override the input type, the same way you would for a DB-backed table.

What Gets Generated

Running create_bread produces the same file set as crud, minus the SQL layer:

apps/main/site_settings/
    index.ts              # Route handlers (imports from ./store, not ./sql)
    form.ree              # Edit form (identical to DB-backed)
    index.ree            # Index grid (identical to DB-backed)
    store.ts             # The stub you implement (replaces sql.ts)
    store.custom.ts      # Add custom store helpers here (never overwritten)
    schema/
        table.ts          # Column config, route_param, pagination strategy
        table.generated.ts # TypeScript types and field definitions
        validation_server.ts # Zod validation schemas
    locales/
        en-us.json        # Translation keys (labels, actions, errors)

The generator also:

  • Registers the route in apps/main/routes.ts
  • Seeds the translation files (labels, actions, validation messages)
  • Syncs the nav translation entry
  • Notifies the running server to hot-reload

The sql.ts and sql.custom.ts files that the shared CRUD pipeline initially writes are deleted - the resource only ships store.ts and store.custom.ts.

The Store Contract

store.ts is a stub: every function returns a safe empty value. The generated index.ts and form.ree call these functions by name, so the signatures are fixed - implement the body, keep the signature.

export const RESOURCE_NAME = "site_settings";

export interface Item {
    id: number;
    site_title: string;
    description: string | null;
    is_active: number;
}

export interface Options {
    option_value: number | string;
    option_text: string;
}

// Return every item.
export async function get_all_items(): Promise<Item[]> {
    return [];
}

// Return { option_value, option_text } pairs for select/autocomplete inputs.
export async function get_site_settings_select_options(): Promise<Options[]> {
    return [];
}

// Return the item with this id, or undefined if it does not exist.
export async function get_item_by_id(id: number): Promise<Item | undefined> {
    return undefined;
}

// Return the matching page of items plus the total count.
export async function search_items(
    search: string = "",
    offset: number = 0,
    limit: number = 20,
    order_by: string = "id::asc",
    scope_clause: string = "",
    filter_clauses: { clause: string; params: any[] }[] = []
): Promise<{ items: Item[]; total: number }> {
    return { items: [], total: 0 };
}

// Persist a new item and return it (with its assigned id).
export async function create_item(item: Omit<Item, "id">): Promise<Item> {
    return { id: 0, ...item } as Item;
}

// Persist changes to an existing item and return the updated item.
export async function update_item(id: number, item: Omit<Item, "id">): Promise<Item | undefined> {
    return undefined;
}

// Remove the item and return whether it existed.
export async function delete_item(id: number): Promise<boolean> {
    return false;
}
FunctionCalled byPurpose
get_all_items()index.ts (list all)Return every item (used when no search is provided)
get_<resource>_select_options()form.reeOptions for select/foreign_key fields (return [] if none)
get_item_by_id(id)index.ts (edit/delete)Fetch one item by id
search_items(search, offset, limit, ...)index.ts (search/paginate)Filtered, paginated search with total count
create_item(item)index.ts (create)Persist a new item, return it with its id
update_item(id, item)index.ts (edit)Persist changes, return the updated item
delete_item(id)index.ts (delete)Remove the item, return whether it existed

The search_items signature includes scope_clause and filter_clauses parameters that mirror the DB-backed search_records signature. For a BREAD store, these are strings your implementation interprets - they arrive from the index grid's filter panel and global scopes. If your store does not support filtering, ignore them and return the full set (or an empty result).

store.custom.ts is created alongside store.ts and is never overwritten by the generator. Add helper functions there - batch fetches, cache wrappers, joins with other data sources - and import them from store.ts.

Implement the Store

The stub returns empty values, so the generated UI works immediately (it just shows no data). Implement each function against your data source. A settings singleton backed by a JSON file:

import { join } from "node:path";

const SETTINGS_FILE = join(import.meta.dir, "..", "..", "data", "settings.json");

async function read_settings(): Promise<Record<string, any>> {
    const file = Bun.file(SETTINGS_FILE);
    if (!(await file.exists())) return {};
    return await file.json();
}

async function write_settings(data: Record<string, any>): Promise<void> {
    await Bun.write(SETTINGS_FILE, JSON.stringify(data, null, 2));
}

export async function get_all_items(): Promise<Item[]> {
    const data = await read_settings();
    return [{ id: 1, ...data }] as Item[];
}

export async function get_item_by_id(id: number): Promise<Item | undefined> {
    const items = await get_all_items();
    return items.find((item) => item.id === id);
}

export async function update_item(id: number, item: Omit<Item, "id">): Promise<Item | undefined> {
    await write_settings(item);
    return { id, ...item } as Item;
}

// create_item and delete_item can no-op for a singleton,
// or throw if the resource should not support them.

The store is a plain TypeScript module - no base class, no interface to implement, no framework to import. The function signatures are the contract; everything else is up to you.

Localized BREAD

bun reeman create_localized_bread --from schema.json [--prefix <dir>] [--route-name <name>] [--force]

create_localized_bread generates a BREAD resource whose store is expected to hold content per locale. The generated form gets the same locale-tabs editor, copy-locale route, and locale_code-aware store signatures as a DB-backed localized table - but the storage layer is whatever you implement in store.ts, not per-locale clone tables.

Every store function gets an extra locale_code parameter (empty string means the default locale):

// Localized store - every function takes a locale_code parameter.
export async function get_all_items(locale_code: string = ""): Promise<Item[]> {
    return [];
}

export async function get_item_by_id(id: number, locale_code: string = ""): Promise<Item | undefined> {
    return undefined;
}

export async function search_items(
    search: string = "",
    offset: number = 0,
    limit: number = 20,
    order_by: string = "id::asc",
    scope_clause: string = "",
    filter_clauses: { clause: string; params: any[] }[] = [],
    locale_code: string = ""
): Promise<{ items: Item[]; total: number }> {
    return { items: [], total: 0 };
}

export async function create_item(item: Omit<Item, "id">, locale_code: string = ""): Promise<Item> {
    return { id: 0, ...item } as Item;
}

export async function update_item(id: number, item: Omit<Item, "id">, locale_code: string = ""): Promise<Item | undefined> {
    return undefined;
}

export async function delete_item(id: number, locale_code: string = ""): Promise<boolean> {
    return false;
}

The locale_code follows the same convention as localized DB-backed CRUD: an empty string means the default locale. The copy-locale and generate-locale routes are generated alongside the form, calling into store.ts (not lib/localized_copy.ts - there are no clone tables). See Localized Content for how the locale-tabs editor works.

Unsupported Field Kinds

Because there is no database table behind a BREAD resource, these field kinds are rejected at generation time:

KindWhy
tagsQueries an options table that does not exist
imageUpload plumbing writes to the images table
fileUpload plumbing writes to the files table
autocompleteForeign-key autocomplete queries an options table

If your synthetic schema includes a column that resolves to one of these kinds, the generator throws with the offending column names. Change the column's type to one that does not require a backing table (text, textarea, number, select, checkbox, date, etc.).

Refreshing a BREAD Resource

Use bun reeman refresh-crud <resource> the same way you would for a DB-backed table. The refresh re-runs the shared CRUD pipeline against the existing schema/table.ts, regenerating index.ree, form.ree, and index.ts. The store.ts stub is overwritten only with --force (or when it does not exist); store.custom.ts is never overwritten.

Flags

The create_bread and create_localized_bread subcommands accept the same flags as crud:

FlagDescription
--from <schema.json>Path to the synthetic schema JSON file (required)
--forceOverwrite existing generated files without prompting
--prefix <dir>Scope the resource under a sub-path (e.g. admin)
--route-name <name>Override the generated route's folder/nav name
--pagination <type>offset (default) or cursor
--render-strategy <type>load (default) or stream
--template-tags <type>flat (default) or tags

See Generators for the full flags reference.