๐ค Manifests
Manifests are the declarative path: an optional, equal alternative to hand-written handlers.
Declare a resource in manifest/<name>.toml, run rastrillo generate,
and you get its store, its four screens' worth of actions and templates,
and their locale keys โ as readable committed code composing the same
form and view helpers a hand-written app uses.
The two paths live side by side
Per resource. Declare the screens that are pure CRUD, hand-write the ones that are not, and move a resource between the paths whenever its needs change: eject one generated file, or delete your hand-written files and re-declare it.
examples/tickets is the fully generated proof โ one manifest resource,
zero hand-written actions, zero ejected templates. examples/blog is
the mixed case.
A resource
name = "posts"
route = "/admin/posts"
store = "exclusive"
[list]
columns = [{ field = "Title" }, { field = "Status" }]
search = true
[[list.filters]]
field = "Status"
values = ["draft", "published"]
[form]
basics = [{ name = "Title", required = true }, { name = "Body", kind = "textarea" }]
The vocabulary is honestly scoped
One flat resource. Three field kinds: text, textarea, money. No
relations.
Relations and custom flows take the code path. That boundary is where the generator currently stops, and reaching it is not a failure โ mixing the two paths is the design.
Every generated store adds fixed columns: id, created_at,
updated_at, plus owner for a scoped resource. A field colliding with
one of those is refused at generate time instead of producing a
confusing table.
Ownership
scope = "user"
Owner-filters every generated query by the session subject, so someone else's row answers 404 โ the same discipline the scope package enforces by hand, declared instead of written.
Mount the resource behind sessions.Require or auth.RequireSession.
The generated queries carry the filter; they do not authenticate.
examples/notes runs both halves side by side and proves them with one
two-user suite.
The two stores
store = "exclusive" is the default: one SQL table, with sqlc query
colocation for the store.
store = "mergeable" keeps each record as an
eventlog stream โ one stream per record,
derived reads, tombstone deletes, misses surfacing as sql.ErrNoRows. A
scoped mergeable resource checks the row's owner the same way the
exclusive one does.
Two limits before you choose it, both live today. Ids are writer-local,
because the platform's edge-sync transport is not built yet
(eventlog.Ingest is the seam it will call). And every generated
event's actor is "app"; threading a real actor through is not done.
If neither matters for your resource, it works. If they do, use
exclusive for now.
Schema evolution
Generated stores emit only the initial CREATE. There is no automatic
diff-and-ALTER for a declared resource, so evolving one's schema is
your own migration โ write it with rastrillo migration new.
There is a trap in how this meets the migration ledger. A generated
store's gen/store/<name>/migrations.go is a raw []string you wrap
into a *migrate.Set by hand. Reshape a resource after first boot and
the regenerated SQL no longer matches the ledger's recorded checksum, so
boot refuses.
Migrations has the
recovery, and its step order matters.
Read this page as markdown โ exact, unstyled, and cheap for an agent to fetch.