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 packages

You 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: consumer roles sign up and act on their own rows; staff roles run the app. A role's profile table links each person by its user_id column. With create: true the platform also inserts the row when someone signs up or is invited (every other not-null column needs a default).
  • auth: providers left out are off; signup is "invite" or the roles a visitor may sign up as.
  • config and secrets: names and rules only. Values are set per environment with the secrets tool or the console; they never enter the code or a version. Server code reads ctx.config.PAYMENTS_URL; pages read public values with useConfig(); a secret goes by reference, ctx.secrets.ref("PAYMENTS_KEY"), and only to its hosts.
  • 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 triggers ctx.uid(), ctx.has_role('ops'), ctx.profile_id('hr').
  • Column and table comments: @pii, @hide <roles>, @label, @api off | read.
  • A generated tsvector column 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), .html or .mjml; messages/sms/<name>.txt; messages/notify/<name>.md (title, link); prompts/<name>.md (model, max_tokens, system) for step.prompt.
  • vars are typed (string number boolean date url email html, lists, optional, objects), and every step.send call is checked against them. Values are escaped.
  • A locale variant sits next to the default: booking-confirmed.hi-IN.md.
  • The message_previews tool renders every template with its sample.

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, @definer sign-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, no eval, no browser fetch to other hosts, @appbricx/server only in server/, no clock or randomness in a workflow outside step.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.

Related