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
| Aspect | DB-backed CRUD | BREAD resource |
|---|---|---|
| Schema source | Introspected from a live database table | Supplied by you as a synthetic schema JSON file |
| Query layer | sql.ts (generated, talks to the database) | store.ts (a stub you implement) |
| Custom query layer | sql.custom.ts | store.custom.ts |
| Index grid | Generated | Generated (identical) |
| Edit form | Generated | Generated (identical) |
| Delete action | delete_record / archive_record in sql.ts | delete_item in store.ts |
| Generated DDL | Yes (sql/{mysql,sqlite}/) | No (no table to create) |
| Foreign keys | Supported (introspected) | Not supported (the schema has no FKs) |
| Image / file fields | Supported (write to the uploads table) | Not supported (nowhere to write without a table) |
| Tags / autocomplete | Supported (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:
- Resource name - a table-style identifier (letters, numbers, underscores), e.g.
site_settings. - Columns - name each column, give it a SQL type (
VARCHAR(255),TEXT,INTEGER, etc.), and say whether it is nullable. Theidcolumn is added automatically. Finish with a blank name. - Localized? - whether the store will hold content per locale (see Localized BREAD below).
- Overwrite without prompting? - force mode.
- 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 namedid(auto-increment integer), plus your content columnsforeign_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:
| Property | Type | Description |
|---|---|---|
name | string | Column name (valid identifier; must not duplicate) |
type_string | string | SQL type (VARCHAR(255), TEXT, INTEGER, etc.) |
is_nullable | boolean | Whether the column allows NULL |
is_primary_key | boolean | True only for the id column |
is_auto_increment | boolean | True only for the id column |
is_generated | boolean | Whether the DB generates the value (usually false) |
comment | string | Column 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;
}
| Function | Called by | Purpose |
|---|---|---|
get_all_items() | index.ts (list all) | Return every item (used when no search is provided) |
get_<resource>_select_options() | form.ree | Options 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:
| Kind | Why |
|---|---|
tags | Queries an options table that does not exist |
image | Upload plumbing writes to the images table |
file | Upload plumbing writes to the files table |
autocomplete | Foreign-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:
| Flag | Description |
|---|---|
--from <schema.json> | Path to the synthetic schema JSON file (required) |
--force | Overwrite 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.