Entities
A PRISM app has two ways to work with relational data:
defineEntity: a config-driven admin generator. It maps one entity to full CRUD screens, backed by Platform API procedures.- SQL queries: the framework's
sqltagged template, for queries you write yourself.
defineEntity
defineEntity lives in the @bext-stack/manage kit, not the framework
(@bext-stack/framework). It is the building block for console and admin screens.
defineEntity maps one entity to list / detail / new / edit screens and the matching CRUD,
backed by Platform API tRPC procedures.
The read procedures are typed: list.proc and getById.proc only accept names from the kit's
procedure catalog, so a typo there is a compile error. The response type of list.proc is also
passed to the rows and total adapters. The crud.create, crud.update and crud.delete names
are plain strings: check them yourself.
Configuration
Reads and writes go through the kit, which calls the Platform API itself: you do not write a
client. The API address, tenant and site come from configureManage(...), called once in a
module that every route imports for its side effect:
// src/lib/manage-setup.ts
import { configureManage } from "@bext-stack/manage/config";
configureManage({
appId: "<app-id>",
cmBase: "…",
host: "…",
origin: "…",
tenantId: "…",
siteId: "…",
});
| Option | Role |
|---|---|
appId |
Your app id. |
cmBase |
The Platform API base address. |
host |
Host name of the administered site, sent to the API so it resolves the right tenant and site. |
origin |
Public origin of host, used for the origin check at sign-in. |
tenantId, siteId |
The tenant and site these screens administer. |
loginPath |
Optional. Where screens redirect when there is no session. Defaults to /manage/login. |
brandName |
Optional. The name shown in the sidebar and page titles. |
Thin re-export
To publish a generated entity, re-export the kit's entity module from a route file, after importing the setup module:
// src/app/manage/posts/page.tsx
import "../../../lib/manage-setup";
export { loader, default } from "@bext-stack/manage/entities/posts";
An entity ships one module per screen it has (some entities have only a list):
| Module | Screen | Typical route |
|---|---|---|
@bext-stack/manage/entities/<entity> |
List | …/page.tsx |
@bext-stack/manage/entities/<entity>.detail |
Detail | …/[id]/page.tsx |
@bext-stack/manage/entities/<entity>.form |
New and edit | …/new/page.tsx, …/[id]/edit/page.tsx |
Example: helpdesk knowledge base
The helpdesk knowledge-base "articles" entity is a defineEntity config wired to these
procedures:
| Procedure | Use |
|---|---|
helpdeskKb.getArticles |
List articles (list.proc), filtered by search, status and visibility |
helpdeskKb.getArticleById |
Fetch one article (getById.proc) |
helpdeskKb.createArticle, helpdeskKb.updateArticle, helpdeskKb.deleteArticle |
Create, update and delete (crud) |
helpdeskKb.getCategories |
Fill the form's category list (formOptions) |
An excerpt of that config:
import { defineEntity } from "@bext-stack/manage/lib/entity";
export const helpdeskKbArticles = defineEntity({
name: "article",
namePlural: "articles",
icon: "book-open",
basePath: "/manage/helpdesk/knowledge-base",
list: {
proc: "helpdeskKb.getArticles",
rows: (r) => (Array.isArray(r) ? r : []),
total: (r) => (Array.isArray(r) ? r.length : 0),
input: (q) => ({
search: q.search || undefined,
status: q.filters.status || undefined,
}),
},
getById: { proc: "helpdeskKb.getArticleById" },
crud: {
create: "helpdeskKb.createArticle",
update: "helpdeskKb.updateArticle",
delete: "helpdeskKb.deleteArticle",
},
// columns, filters and form fields omitted
});
See Helpdesk.
SQL queries
For hand-written queries, the framework exports a tagged-template SQL helper:
import { sql, database } from "@bext-stack/framework";
const db = database("data.sqlite");
const posts = db.all(sql`SELECT id, title FROM posts WHERE author_id = ${authorId}`);
| Export | Role |
|---|---|
sql |
Builds a query from a tagged template. sql.id(name) (identifier in double quotes), sql.raw(text), sql.if(cond, frag) and sql.join(frags, sep) compose fragments. |
database(path) |
Binds a SQLite database file. Returns synchronous all (rows, as objects), get (first row or null), run ({ changes, last_insert_rowid }) and compile ({ text, params }, without executing). |
SQLITE_DIALECT, POSTGRES_DIALECT |
Placeholder styles (? or $1) for the compiled text, passed as database's second argument or to a query's compile method. SQLite by default; execution always goes through SQLite. |
An interpolated array becomes a list of parameters, handy for IN (...). Fragments compose
without breaking parameter numbering:
const rows = db.all(sql`
SELECT id, title FROM ${sql.id("posts")}
WHERE id IN (${ids})
${sql.if(authorId, sql`AND author_id = ${authorId}`)}
ORDER BY created_at ${sql.raw(desc ? "DESC" : "ASC")}
`);
const conds = [sql`status = ${status}`, sql`author_id = ${authorId}`];
const count = db.get(sql`SELECT COUNT(*) AS n FROM posts WHERE ${sql.join(conds, " AND ")}`);
const { changes } = db.run(sql`UPDATE posts SET status = ${"archived"} WHERE id = ${id}`);
Every value interpolated with ${...} becomes a bound parameter: it is never concatenated into
the SQL text. Pass values this way rather than building query strings by hand. sql.raw inserts
unescaped text: keep it for fixed values such as ASC / DESC. When sql.if gets a false
condition it inserts nothing: put the keyword (AND, WHERE) inside the fragment itself.
The createSdk client also offers sdk.db.all, sdk.db.get and sdk.db.exec with ?
parameters (see the SDK Overview). These calls are asynchronous; exec
returns { changes, lastInsertId }. Every parameter is sent as a string: numbers are stringified,
and null or undefined becomes an empty string. Write NULL in the SQL text when you need a
real null.
Where to next
| Page | What it covers |
|---|---|
| Data Model | The per-tenant schema and key models |
| Helpdesk | The knowledge-base entity in context |
| Loaders & Actions | Where reads and writes run |
| KV Store | Storing small key/value state |