Entities

A PRISM app has two ways to work with relational data:

  1. defineEntity: a config-driven admin generator. It maps one entity to full CRUD screens, backed by Platform API procedures.
  2. SQL queries: the framework's sql tagged template, for queries you write yourself.

defineEntity

Note

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}`);
Note

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