Project files
An Appbricx app is a Postgres database you describe in SQL plus a normal TypeScript and React project. The platform reads both: it migrates the database, enforces your access policies, serves the data API, renders the pages, runs the actions and workflows, and checks all of it before a release. This page lists every kind of file, with a short example of each.
The layout
app.config.ts brand, locale, sign-in, roles (and their profile tables), config and secret names
db/schema/*.sql tables, references, lifecycles, triggers and functions
db/access/*.sql row-level security policies
db/queries/*.sql named queries with typed params
db/seeds/*.sql demo rows (re-runnable)
db/*.sql anything else: extensions, views
routes/ file routes: index.tsx is /, experiences/[id].tsx is /experiences/:id
_layout.tsx wraps every route below it
components/ lib/ your React components and helpers (import CSS freely)
styles/*.css app-wide CSS, after Tailwind
server/*.ts typed actions (defineAction) and their helpers
workflows/*.ts durable workflows: row changes, schedules, webhooks
messages/email|sms|notify/* templates with typed vars
prompts/*.md AI prompt templates
icons/*.svg your own icons (<Icon name="app:<file>" />)
scripts/*.ts scripts your agent runs with run_script
package.json, package-lock.json npm packagesYou edit these files in your editor (pull and push), or your agent edits them with the code and project_files tools. Every check and apply reads the whole tree and answers at the file and line.
app.config.ts
The app's settings. It runs in a sandbox with no clock, randomness or network and must export plain data; an unknown key is an error that names the closest one.
import { defineApp } from "@appbricx/define";
export default defineApp({
brand: { name: "Teamday", tagline: "Team experiences, booked in a minute", color: "#0B7A4E" },
locale: { language: "en-IN", currency: "INR", timezone: "Asia/Kolkata" },
auth: { providers: ["password", "magicLink", "google"], signup: { as: ["host", "hr"] } },
roles: {
host: { kind: "consumer", label: "Host", profile: "host_profile" },
hr: { kind: "consumer", label: "Company booker", profile: { table: "hr_profile", create: true } },
ops: { kind: "staff", label: "Teamday ops" },
},
config: {
SUPPORT_PHONE: { type: "string", public: true }, // readable in the browser
PAYMENTS_URL: { type: "string", required: ["prod"] },
},
secrets: {
PAYMENTS_KEY: { hosts: ["api.razorpay.com"], required: ["prod"] },
},
http: { allow: ["api.razorpay.com"] },
realtime: { live: ["booking"], channels: { lobby: { access: "signed_in", presence: true } } },
});- roles:
consumerroles sign up and act on their own rows;staffroles run the app. A role'sprofiletable links each person by itsuser_idcolumn. Withcreate: truethe platform also inserts the row when someone signs up or is invited (every other not-null column needs a default). - auth:
providersleft out are off;signupis"invite"or the roles a visitor may sign up as. - config and secrets: names and rules only. Values are set per environment with the
secretstool or the console; they never enter the code or a version. Server code readsctx.config.PAYMENTS_URL; pages read public values withuseConfig(); a secret goes by reference,ctx.secrets.ref("PAYMENTS_KEY"), and only to itshosts. - realtime: tables whose changes stream to the browser, and named channels with who may join and send. analytics: Google tag ids.
Every apply makes one demo sign-in per role; the answer lists them, and verify uses them.
db/: the database
db/schema/*.sql
Tables, written as create table if not exists. Change a file and the apply works out the migration: added columns, indexes and constraints run (rows kept); a drop waits as a warning until you allow it. The platform's ctx helpers give you typed domains and common behaviour.
-- db/schema/booking.sql
create table if not exists booking (
id uuid primary key default gen_random_uuid(),
ref text not null unique default ctx.short_ref('BK'),
experience_id uuid not null references experience on delete restrict,
hr_id uuid not null references hr_profile on delete restrict,
team_size int not null,
total ctx.money not null default 0,
status text not null default 'requested',
email ctx.email not null,
notes text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
deleted_at timestamptz,
search tsvector generated always as (to_tsvector('simple', ref || ' ' || coalesce(notes, ''))) stored,
constraint booking_team_size_positive check (team_size >= 1)
);
create index if not exists booking_hr on booking (hr_id);
select ctx.lifecycle('booking', 'status', $$
requested -> confirmed | cancelled
confirmed -> completed | cancelled
$$);
select ctx.touch('booking'); -- keeps updated_at current
select ctx.soft_delete('booking'); -- delete moves the row to the trash; restore brings it back
comment on column booking.email is '@pii @hide host'; -- hosts never see the column- Domains:
ctx.email,ctx.url,ctx.image,ctx.file,ctx.phone,ctx.slug,ctx.money,ctx.encrypted. - Helpers:
ctx.lifecycle(a state column that moves only along its transitions),ctx.touch,ctx.soft_delete,ctx.short_ref, and in policies and triggersctx.uid(),ctx.has_role('ops'),ctx.profile_id('hr'). - Column and table comments:
@pii,@hide <roles>,@label,@api off | read. - A generated
tsvectorcolumn gives the table full-text search.
Rules that depend on the data are triggers in the same file. A raise exception reaches the caller as its message (422); with using errcode = 'unique_violation' it is a 409.
create or replace function booking_size_guard() returns trigger
language plpgsql set search_path = public as $$
begin
if new.team_size > 60 then
raise exception 'A booking is at most 60 people. Talk to us for bigger groups.';
end if;
return new;
end $$;
create or replace trigger booking_size_guard before insert or update on booking
for each row execute function booking_size_guard();A security definer function runs past row-level security, so the lint asks for set search_path and a -- @definer <why> line right above it: your sign-off, shown in the diff and in verify's report.
db/access/*.sql
Who may read and change which rows: Postgres row-level security, checked on every request. One policy per role and command; using is what a role reads or changes, with check what it may write. A table with no policy for a role gives it nothing; a signed-out visitor has no role (ctx.uid() is null). Wrap helpers in (select …) so they run once per query.
-- db/access/experience.sql: the catalogue is public; hosts edit their own; ops all.
drop policy if exists experience_public_read on experience;
create policy experience_public_read on experience for select
using (status = 'published');
drop policy if exists experience_host_write on experience;
create policy experience_host_write on experience for all
using (host_id = (select ctx.profile_id('host')))
with check (host_id = (select ctx.profile_id('host')));
drop policy if exists experience_ops_all on experience;
create policy experience_ops_all on experience for all using ((select ctx.has_role('ops')));db/queries/*.sql
Named queries: -- @param lines declare typed params, -- @roles who may call it, and :name binds a param. They run as the caller. Postgres checks and types each one at check time, so a page calling it gets typed rows.
-- db/queries/experience-bookings.sql: how full an experience is.
-- @param experience uuid
-- @roles host, ops
select count(*)::int as bookings, coalesce(sum(team_size), 0)::int as people
from booking where experience_id = :experience;db/seeds/*.sql
Demo rows, run on every apply in dev, so each statement must be re-runnable (on conflict … do nothing; the lint says so). Only a seed marked -- @env prod runs in production. ctx.seed_id('label') is a stable id per label. Profile rows seeded with user_id null are claimed by the role's demo sign-in.
insert into host_profile (id, user_id, name, city) values
(ctx.seed_id('host-asha'), null, 'Asha', 'Bengaluru')
on conflict (id) do nothing;
insert into experience (id, host_id, title, city, price, status) values
(ctx.seed_id('exp-pottery'), ctx.seed_id('host-asha'), 'Pottery Jam', 'Bengaluru', 1800, 'published')
on conflict (id) do nothing;db/*.sql
Anything else the database needs, such as an extension or a view. A view is served read-only by the data API only when it is security_invoker, so the underlying policies apply.
-- db/extensions.sql
create extension if not exists btree_gist; -- exclusion constraints (no double booking)routes/: pages
Each file is a page: routes/index.tsx is /, routes/experiences/[id].tsx is /experiences/:id, and routes/_layout.tsx wraps the routes below it. /signin is built in unless you write routes/signin.tsx. A route exports its meta and a default component.
// routes/experiences/[id].tsx
import { defineRoute, useRow } from "@appbricx/ui";
export const meta = defineRoute({
public: true, // signed-out visitors; server-rendered; in the sitemap
row: { table: "experience" }, // the row is found by the route's last param
seo: { title: "{{title}} · Teamday", sitemap: { where: { status: "published" } } },
});
export default function Experience() {
const { row, notFound } = useRow("experience"); // typed from db/schema
if (notFound) return <main>No such experience.</main>;
if (!row) return <main>Loading…</main>;
return <h1>{row.title}</h1>;
}meta is plain data: public, roles (who may open it; default any signed-in role), title, row and seo. A public page is rendered on the server with what a signed-out visitor may read and hydrates from the same reads; a row a visitor can't read is a 404. Read data with useQuery, useRow and useList, write with useTable or an action: see the data reference.
// routes/bookings.tsx
import { defineRoute, useQuery } from "@appbricx/ui";
import { confirmBooking } from "../server/bookings";
export const meta = defineRoute({ roles: ["hr", "ops"], title: "Bookings" });
export default function Bookings() {
const { rows } = useQuery("booking", { sort: "-created_at", embed: "experience(title)" });
return (
<ul>
{rows.map((b) => (
<li key={b.id}>
{b.ref} {b.status}
{b.status === "requested" ? <button onClick={() => void confirmBooking({ booking: b.id })}>Confirm</button> : null}
</li>
))}
</ul>
);
}Pages render on the server and in the browser: take "now" from useClock(), format dates with an explicit time zone, and render browser-only values after mount.
components/, lib/, styles/, icons/
Your React components and helpers. Any React works; defineComponent adds typed, defaulted props, and client: true or lazyComponent keeps browser-only libraries off the server render. Import CSS from a component, or put app-wide CSS in styles/ (after Tailwind, which is on unless ui: { tailwind: false }).
// components/ExperienceCard.tsx
import { Icon, Link } from "@appbricx/ui";
import "./ExperienceCard.css";
export function ExperienceCard(props: { id: string; title: string; city: string }) {
return (
<Link to={"/experiences/" + props.id} className="experience-card block rounded-lg border p-4">
<Icon name="lucide:map-pin" /> {props.city}
<h2 className="text-xl font-semibold">{props.title}</h2>
</Link>
);
}<Icon name="lucide:calendar-check" /> takes any Iconify icon, or app:<name> for icons/<name>.svg; the build puts the icons you use into one sprite. Images and fonts you upload with the assets tool are served at /assets/<name>.
server/: typed actions
An exported defineAction is a function your pages import and call. It runs on the platform as the caller (your policies and triggers apply) after its input is checked; roles says who may call it, and "guest" lets signed-out visitors call it too.
// server/bookings.ts
import { defineAction, v } from "@appbricx/server";
export const confirmBooking = defineAction({
input: v.object({ booking: v.uuid(), note: v.string({ max: 200 }).optional() }),
roles: ["ops"],
handler: async (ctx, input) => {
const row = await ctx.db.update("booking", input.booking, { status: "confirmed", notes: input.note ?? null });
if (!row) throw new Error("No such booking.");
return { ref: String(row.ref), status: String(row.status) };
},
});ctx has db (get, list, create, update, remove), me, config, secrets, http.fetch (public HTTPS, or only http.allow hosts), signal (wakes a waiting workflow), email, sms and pdf. Server code runs in a V8 isolate: no Node built-ins, no global fetch. See the actions reference.
workflows/: durable workflows
// workflows/booking.ts
import { cron, on } from "@appbricx/workflows";
type Booking = { id: string; ref: string; email: string; team_size: number };
export const bookingConfirmed = on.transition<Booking>("booking", "status", "confirmed", async ({ row }, step) => {
await step.send.email("booking-confirmed", {
to: row.email,
vars: { ref: row.ref, team_size: row.team_size, link: step.url("/bookings") },
});
await step.sleep("1d");
const feedback = await step.waitFor<{ stars: number }>("feedback", { key: row.id, timeout: "7d" });
if (feedback) await step.run("keep the stars", () => step.ctx.db.update("booking", row.id, { notes: feedback.stars + " stars" }));
});
export const morningDigest = cron("06:00", { tz: "Asia/Kolkata" }, async (_e, step) => {
const rows = await step.sql("waiting", "select count(*)::int as n from booking where status = 'requested'");
const count = Number(rows[0]?.n ?? 0);
if (count > 0) await step.send.notify("bookings-waiting", { role: "ops", vars: { count } });
});Triggers: on.created, on.updated, on.transition, on.deleted, cron and webhook (at /__appbricx/hooks/<name>, with verify: "stripe", "github" or "razorpay"). Every step.* is recorded, so after a sleep, a wait or a retry the code runs again from the top and the steps it took answer from the journal: an email goes once. Take the time from step.now() and put anything random in step.run(); the checks say where you didn't. See the workflows reference.
messages/ and prompts/: templates
---
subject: "Booking {{ref}} is confirmed"
preheader: Your team day is on.
vars:
ref: string
team_size: number
link: url
sample: { ref: BK-1234, team_size: 12, link: https://teamday.example.com/bookings }
---
# You're booked
Booking **{{ref}}** for {{team_size}} {{plural team_size "person" "people"}} is confirmed.
[See your bookings]({{link}})messages/email/<name>.md(Markdown in the platform's email layout),.htmlor.mjml;messages/sms/<name>.txt;messages/notify/<name>.md(title,link);prompts/<name>.md(model,max_tokens,system) forstep.prompt.varsare typed (string number boolean date url email html, lists, optional, objects), and everystep.sendcall is checked against them. Values are escaped.- A locale variant sits next to the default:
booking-confirmed.hi-IN.md. - The
message_previewstool renders every template with itssample.
package.json and package-lock.json
npm packages for pages and server code. Add them with appbricx add recharts or the packages tool, which write both files; the platform installs exactly the lockfile, with no install scripts. A version must be at least 7 days old, packages are checked against known vulnerabilities (a critical one blocks a production deploy), and server code may use only packages that run in a V8 isolate. React and @appbricx/* come with the platform.
scripts/*.ts
Scripts your agent runs by name with run_script { name }: a smoke test or a data fix, versioned with the app.
// scripts/smoke.ts: who sees how many bookings
export default async (tools: any) => {
const read = (as: unknown) => tools.call_app({ path: "/__appbricx/api/v1/booking", as });
const [guest, hr, ops] = await Promise.all([
read({ kind: "visitor" }),
read({ kind: "user", role: "hr" }),
read({ kind: "user", role: "ops" }),
]);
return { guest: guest.json?.rowCount, hr: hr.json?.rowCount, ops: ops.json?.rowCount };
};What a check covers
- The SQL: parse and lint (re-runnable seeds,
@definersign-offs), and the migration plan. - TypeScript in strict mode over routes, components, lib, server and workflows, against the SDK, React, your tables' types, your templates' vars and your config and secret names.
- The platform's lint: no writes to
location, noeval, no browserfetchto other hosts,@appbricx/serveronly in server/, no clock or randomness in a workflow outsidestep.run, no sandbox escapes in server code, literal icon names. - Bundle budgets (120 KB gzip at boot, 100 KB per route) as warnings.
Coming from a JSON contract
Apps made before code-first were one JSON contract. Convert one with appbricx migrate --to code <contract.json | project dir> <new dir>: it writes this layout, and a MIGRATION.md with what to finish by hand. Push it to a new project.