Architecture
One journey from a button click, through the server, into Postgres, out to background jobs, and back to the screen.
Carbon uses two applications over one manufacturing data model. The ERP manages quoting, purchasing, planning, inventory, accounting, and shipping. The MES records and controls shop-floor work, typically from tablets at work centers.
This page traces a write from the UI through validation, authorization, persistence, background work, and UI refresh. Read Part 1 and Part 2 in order. The remaining sections are independent references.
Numbered references such as Postgres [17] link to definitions in the Glossary.
A snapshot, not a contract
File paths, line numbers, and counts are accurate as of August 2026 and may change as the repository evolves.
Part 1: The map
Carbon is a monorepo [10] containing several applications and approximately 25 shared libraries, managed with pnpm workspaces and Turborepo [25]. ERP and MES are the primary user-facing applications.
One Postgres [17] database is the system of record. It also enforces row-level permissions, computes derived values, and emits change events. Part 3 describes these responsibilities.
In each diagram, blue represents Carbon applications, pink represents stored state, and amber represents background work.
Key properties:
- The ERP app is the hub. It serves pages and executes background jobs. Inngest [5]
schedules work and sends requests to
/api/inngest; the ERP container executes the code. See Part 4. - Most database access is not raw SQL. It goes through PostgREST [18], a
service that translates
client.from("purchaseOrder").select(...)into SQL. Application code accesses it throughsupabase-js. - The database enforces tenant access. Row-level security prevents access to another company's rows, except when code explicitly uses the service-role client. See Part 3.
- Selected writes emit events automatically. Approximately 90 tables use database triggers to initiate subscribed search indexing, webhooks, audit logs, and customer workflows.
- The Rust service is optional. It converts CAD files to browser-renderable 3D models and plans disassembly sequences for assembly instructions. Core Carbon functions without it.
Part 2: Follow one click all the way down
This section follows a buyer creating a purchase order through the form, validation, permission check, database write, Postgres logic, background side effect, and UI refresh.
The short version
The request completes after the row is written. Search indexing runs asynchronously.
The same story, with real files
The following paths point to the implementation as of August 2026.
1. The button. It is a link, not a submit button.
apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrdersTable.tsx:502
primaryAction={
permissions.can("create", "purchasing") && (
<New label={t`Purchase Order`} to={path.to.newPurchaseOrder} />
)
}permissions.can(...) hides actions the user cannot perform, but the server performs the authoritative
permission check. path.to.* references the route registry in apps/erp/app/utils/path.ts; do not hardcode application URLs.
2. The form. apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrderForm.tsx:116
<ValidatedForm
method="post"
validator={purchaseOrderValidator}
defaultValues={initialValues}
>ValidatedForm wraps an HTML form with schema-based client and server error display. Fields such as
<Input name="..."> and <Supplier name="..."> come from ~/components/Form, which exports generic inputs
and Carbon-specific selectors.
3. The schema. apps/erp/app/modules/purchasing/purchasing.models.ts:127
export const purchaseOrderValidator = z.object({
id: zfd.text(z.string().optional()),
purchaseOrderId: zfd.text(z.string().optional()),
purchaseOrderType: z.enum(purchaseOrderTypeType, {
/* ... */
}),
supplierId: z.string().min(1, { message: "Supplier is required" }),
locationId: zfd.text(z.string().optional()),
});zod [28] provides runtime validation and inferred TypeScript types. zod-form-data (zfd)
normalizes HTML form values; for example, zfd.text() converts an empty field from "" to undefined.
The browser and server validate against the same schema, and the service argument type is derived from it.
4. The route. apps/erp/app/routes/x+/purchase-order+/new.tsx:29
export async function action({ request }: ActionFunctionArgs) {
assertIsPost(request);
const { client, companyId, companyGroupId, userId } =
await requirePermissions(request, {
create: "purchasing",
bypassRls: true,
});
const formData = await request.formData();
const validation = await validator(newPurchaseOrderValidator).validate(
formData
);
if (validation.error) return validationError(validation.error);
const result = await insertPurchaseOrder(client, {
...validation.data,
companyId,
companyGroupId,
createdBy: userId,
customFields: setCustomFields(formData),
});
if (result.error || !result.data) {
throw redirect(
path.to.purchaseOrders,
await flash(
request,
error(result.error, "Failed to insert purchase order")
)
);
}
throw redirect(path.to.purchaseOrder(result.data.id));
}Carbon writes follow this sequence:
assert the method → check permissions → validate → call a service → redirect.
A file under apps/erp/app/routes/ can export up to three things that matter here:
| Export | Runs where | Job |
|---|---|---|
loader [8] | server only | fetch the data a page needs |
action [1] | server only | handle a form submission |
export default | server first, then browser | the React component |
loader and action are excluded from browser JavaScript, so they can use secrets and server-only database clients.
5. The service function. apps/erp/app/modules/purchasing/purchasing.service.ts:1393
Routes call service functions instead of accessing the database directly. A service function accepts a database client as its first argument and returns Supabase's { data, error } result.
const seq = await client.rpc("get_next_sequence", {
sequence_name: "purchaseOrder",
company_id: input.companyId,
});
const order = await client
.from("purchaseOrder")
.insert({
purchaseOrderId,
supplierId: input.supplierId,
status: "Draft",
companyId: input.companyId,
createdBy: input.createdBy /* ... */,
})
.select("id, purchaseOrderId")
.single();
const [delivery, payment] = await Promise.all([
client.from("purchaseOrderDelivery").insert({
/* ... */
}),
client.from("purchaseOrderPayment").insert({
/* ... */
}),
]);
if (delivery.error || payment.error) {
await deletePurchaseOrder(client, orderId); // manual undo
return { data: null, error: delivery.error ?? payment.error };
}Each supabase-js .insert() call is a separate HTTP request, not part of a transaction. If a later write
fails, earlier writes remain committed unless explicitly reversed. Use Kysely [6] when writes require a transaction. See
Part 3.3.
6. Database-side behavior. Postgres performs two additional operations.
The order number. client.rpc("get_next_sequence", ...) calls an RPC [21], a
function that lives inside the database
(packages/database/supabase/migrations/20241115101526_rpc-get-next-sequence.sql). It
atomically increments a counter and formats the result with PO, zero-padding, and optional date tokens.
The event. Every purchaseOrder insert invokes a Postgres trigger. If the company has an active subscription
for that table and operation, the trigger queues a JSON message. The route does not initiate this behavior.
Part 4 traces the event.
7. The screen updates. The action ends with throw redirect(...). React Router follows the redirect,
runs the destination loader, reads current database state, and renders the page. After any non-GET submission,
React Router automatically revalidates the current page's loaders.
Part 3: Layer by layer
3.1 The browser and the route file
The applications use React Router v7 in framework mode with server-rendered React. A route file can define both data loading and UI. Carbon does not use Remix or Next.js for ERP and MES. Vite [27] builds the applications, and Tailwind v4 provides styling.
Routes are files on disk. apps/erp/app/routes/ has about 1,400 of them, and the
folder structure is the URL structure, with a few conventions:
| In a filename | Means |
|---|---|
x+/ | a URL segment and a folder: x+/purchase-order+/new.tsx → /x/purchase-order/new |
_public+/ | a folder for grouping that adds no URL segment |
_layout.tsx | a wrapper shared by every route in the folder |
$orderId | a URL parameter: $orderId.tsx → /:orderId |
. in the name | a / in the URL: dimensions.new.tsx → dimensions/new |
_index.tsx | the page for the folder's own URL |
*.server.ts | ignored by the router; a helper file that never reaches the browser |
At the top level, x+ contains authenticated application routes, api+ contains JSON endpoints,
_public+ contains authentication routes, and share+ contains token-authenticated public links.
Getting data in. A server-side loader calls service functions and returns JSON. The component reads it with
useLoaderData<typeof loader>(). apps/erp/app/routes/x+/_layout.tsx loads shared user, company, permission,
and feature-flag data. Hooks such as useUser(), usePermissions(), and useCompanySettings() read this loader data.
Getting data out. Forms post to an action, as shown in Part 2. Use useFetcher() for writes that should not navigate,
such as reordering, inline edits, or deletion. Fetcher submissions still trigger automatic revalidation.
Shared UI. Check these locations before adding a component:
packages/react/: the design system (@carbon/react). Buttons, modals, comboboxes, menus, and layout primitives built on Radix UI [19] and Tailwind.apps/erp/app/components/: app-level pieces. The big one iscomponents/Table/Table.tsx, a generic TanStack Table implementation used by list screens. It handles sorting, filtering, pagination, saved views, CSV import and export, column visibility, and inline editing. The server performs pagination from URL search parameters.
Client state is limited. Shared reference lists use nanostores [13] and are refreshed by
RealtimeDataProvider. Ephemeral UI state uses zustand [29]. User and permission data comes from route loaders.
Translations use Lingui [7]. Mark strings with t`...` or <Trans>, then run pnpm lingui:extract.
MES uses the same stack with a flatter structure. It has no modules/ folder: services are in
apps/mes/app/services/, all validators in one models.ts, routes are one flat level,
and the UI is built for gloved hands on a tablet.
3.2 The gate: who is allowed to do what
Server-side entry points use requirePermissions for authentication and authorization.
const { client, companyId, userId } = await requirePermissions(request, {
create: "purchasing",
});Defined at packages/auth/src/services/auth.server.ts:195, it performs four operations:
- Identifies the caller from a browser session cookie or the public API's rate-limited, scope-checked
carbon-keyheader. - Loads their permissions. Permissions are stored as
<module>_<action>keys such aspurchasing_createorsales_view, each mapping to the list of companies where the user holds it. They are fetched by a Postgres function calledget_claimsand cached in Redis for an hour. They are not in the JWT. - Rejects unauthorized requests. Unauthenticated users are redirected to login; unauthorized users receive an Access Denied message.
- Returns a user-scoped database client,
companyId, anduserId.
Carbon uses multi-tenancy [12]: one database serves multiple companies. Nearly every table has a
companyId and composite primary key ("id", "companyId"). The active company comes from the session, not the URL.
Row-level security (RLS) [20] adds each table's access policy to database queries. For example:
CREATE POLICY "SELECT" ON "public"."reportPin"
FOR SELECT USING (
"companyId" = ANY ((SELECT get_companies_with_employee_role())::text[])
);Queries for another company's records return no rows. Service functions also filter by companyId for defense in depth.
The escape hatch. bypassRls: true returns a service-role [22] client for trusted employee
requests on performance-sensitive paths. Because this client bypasses RLS, each loader must verify tenant ownership explicitly:
if (purchaseOrder.data?.companyId !== companyId)
throw redirect(path.to.purchaseOrders);Company membership assigns one of three roles: employee, customer, or supplier. Portal users have narrower
permissions, and routes can require { role: "employee" }. share+/ routes use an unguessable URL token instead of login authentication.
3.3 The service layer: four doors to the database
A service function accepts a database client as its first argument. Select the client based on the required security and transaction behavior.
1. The user-scoped client. What requirePermissions normally returns. Every query
runs as that user with RLS applied. Use it unless you have a reason not to.
2. The service-role client (getCarbonServiceRole()) bypasses RLS. Background jobs, share+/ routes,
and bypassRls: true requests use it. Every query must enforce companyId explicitly.
3. Kysely is a TypeScript SQL query builder that holds a real connection to Postgres, so it can open a real transaction. This is the answer to the "three inserts aren't atomic" problem from Part 2:
// updateSortOrder (apps/erp/app/modules/shared/sort-order.ts), which every
// drag-to-reorder route uses: one UPDATE for all the rows, inside a transaction.
await db.transaction().execute(async (trx) => {
const { rows } = await sql<{ id: string }>`
UPDATE "quoteLine" AS t SET "sortOrder" = v."sortOrder", …
FROM (VALUES ${values}) AS v("id", "sortOrder")
WHERE t."id" = v."id"
AND t."companyId" = ${companyId}
AND t."quoteId" = ${quoteId}
RETURNING t."id"`.execute(trx);
// A row from another company or another quote matched nothing. Throwing
// here rolls back the rows that did match: all or nothing.
if (rows.length !== updates.length) throw new Error("…");
});Kysely throws on failure and applies no RLS. Wrap calls in try/catch, authorize before execution, and scope
every statement by companyId and, when row IDs come from a request, the parent document ID. Update sets in one
statement instead of querying in a loop. A Node process shares one pool of 16 connections (getProcessPool()).
Inside a transaction, use only the provided trx handle; requesting another connection through db can deadlock
until timeout.
4. Server functions are the privileged, multi-step writes shared by the ERP, the
MES, the public API and background jobs. There are about thirty in
packages/server-functions/src/, one directory each, and they own the genuinely heavy
writes: posting a shipment or an invoice to the general ledger, issuing material,
converting a quote, CSV import. Each is built with defineServerFn, which validates the
input, checks the caller's permission, runs, and returns { data, error } instead of
throwing. Call one like any other function:
import { serverFns } from "@carbon/server-functions";
const { data, error } = await serverFns
.system({ db: getDatabaseClient(), companyId, userId })
.invoke("post-shipment", { type: "post", shipmentId });Server functions run large Kysely transactions and are callable from anywhere on the server: a route action, the MES, a background job, or the public API.
Keep function code thin and perform set-based work in Postgres. Use Inngest for work that runs for minutes and the
Rust service for compute-intensive CAD geometry. MRP [11] and production scheduling live in
@carbon/planning and run in Node the same way.
One small edge function [4], written for Deno [2], remains in
packages/database/supabase/functions/: embedding. It is self-contained and
called over HTTP with client.functions.invoke("embedding", { body }).
RPCs are a narrower fifth path: Postgres functions called with client.rpc("name", args). get_next_sequence from Part 2 is one example.
3.4 The database
Carbon uses Postgres 15 through Supabase [24], which adds PostgREST, authentication, file storage, realtime changes, and an edge-function runtime.
As of August 2026, the schema contains 923 migration files, 362 tables, 101 views, 238 database functions, and 154 enum types.
Each migration [9] is a timestamped SQL file in
packages/database/supabase/migrations/, named YYYYMMDDHHMMSS_kebab-name.sql. Create
one with pnpm db:migrate:new <name>, apply with pnpm db:migrate. They are applied in
filename order and never edited after merge.
A typical table shows every convention at once:
CREATE TABLE "itarCertification" (
"id" TEXT NOT NULL DEFAULT id('itc'),
"companyId" TEXT NOT NULL,
"createdBy" TEXT NOT NULL REFERENCES "user"("id"),
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
"updatedBy" TEXT REFERENCES "user"("id"),
"updatedAt" TIMESTAMP WITH TIME ZONE,
PRIMARY KEY ("id", "companyId"),
FOREIGN KEY ("companyId") REFERENCES "company"("id") ON DELETE CASCADE
);
ALTER TABLE "public"."itarCertification" ENABLE ROW LEVEL SECURITY;id('itc') generates a prefixed, sortable, URL-safe ID such as itc_3xK9....
Views [26] are the primary read path. Carbon writes to tables and reads from views.
For example, the plural purchaseOrders view joins the supplier name, calculates orderTotal, converts
currency, counts received quantities, and retrieves a thumbnail. The write path inserts source data; the view derives display values.
Views are defined in migrations with WITH (security_invoker = true) so the caller's RLS policies apply.
Database types are generated. pnpm run generate:types reads the local schema and writes
packages/database/src/types.ts, which describes each table, view, function, and enum. Application code imports from it:
import type { Database } from "@carbon/database";
type PurchaseOrderRow = Database["public"]["Tables"]["purchaseOrder"]["Row"];Run pnpm run generate:types after pulling or creating migrations and before typechecking. Do not edit generated types directly.
Database logic enforces invariants across ERP, MES, server functions, background jobs, the public API, and direct SQL. Examples:
update_picking_list_status()is a trigger that recomputes a picking list's header status whenever one of its lines changes. Written from four different places; recomputing it in each caller would drift.dispatch_event_batch()is the event trigger from Part 4. It must enqueue in the same transaction as the write that caused it, so a rolled-back write never emits a phantom event.
Files live in Supabase Storage buckets: private is the workhorse (attachments,
drawings, quality records), public and avatars are self-explanatory, plus one bucket
per company and temp-staging for large CAD uploads.
Part 4: What happens after the write
Event processing is independent of the code that performs a write. A table emits events only after a migration
registers it with attach_event_trigger('tableName'). The trigger enqueues work only when the company has an
active subscription for the table and operation. The same UPDATE can therefore invoke several handlers for one company and none for another.
PGMQ [16] stores the queue in Postgres so triggers can enqueue work in the same transaction. After commit, pg_net [15] sends an HTTP request to Inngest [5], the background job runner.
Inngest maps named events to registered HTTP functions and persists step results. Each step.run("id", fn)
is a checkpoint; a retry resumes after completed steps instead of executing them again.
The wiring is three files:
| What | Where |
|---|---|
| The client | packages/lib/src/inngest/client.ts |
| The list of all 55 job functions | packages/jobs/src/inngest/index.ts |
| The HTTP endpoint Inngest calls | apps/erp/app/routes/api+/inngest.ts |
Background jobs run inside the ERP container. Inngest schedules requests and persists execution state; it does not host Carbon's job code.
Firing a job on purpose. For explicit job initiation, call the typed trigger() helper:
await trigger("notify", {
event: NotificationEvent.JobAssignment /* ... */,
});Event names are always carbon/<something>.
Scheduled work has two sources. Inngest cron schedules run application jobs: MRP every three hours, exchange rates and audit archiving nightly. All of them are UTC, and any job that needs a company's local day computes it inside the run. Database-level maintenance uses pg_cron [14] instead, for refreshing cached aggregate tables, sweeping the event queue and purging old notifications. pg_cron also decides when two application jobs run: every fifteen minutes it checks whether any notifications are due to be rolled into a digest, and at 04:00 whether any workflow run history is due for cleanup, and it wakes the Inngest function only when there is work.
Notifications originate in packages/jobs/.../notifications/notify.ts and target in-app records, Resend email,
and Slack. The top bar receives in-app records through Realtime. packages/notifications defines event types but does not send messages.
Part 5: The other engines
Workflows: the customer's own automations
Customers configure workflows on a drag-and-drop canvas. These workflows are separate from Inngest jobs:
| Inngest jobs | Workflows | |
|---|---|---|
| Written by | us, in TypeScript | customers, on a canvas |
| Stored as | code | JSON rows in workflowVersion |
| Runs as | service role, sees everything | the workflow's owner, with their RLS |
| Changed by | a deploy | the customer, at any moment |
The WORKFLOW branch of the Part 4 event pipeline maps row changes to catalog events,
finds subscribed workflows, and queues a run for each. packages/workflows contains pure definition and validation logic.
packages/jobs/src/workflows/ contains the runtime. Carbon records every run and step for inspection.
The Assembler: the Rust service
apps/assembler plus crates/ is a Rust HTTP service that converts CAD files (STEP) into
web-renderable 3D (GLB) and plans a collision-free disassembly sequence for assembly
instructions. It wraps two C++ libraries (OpenCASCADE for geometry, FCL for collision).
packages/viewer renders its output in the browser.
The service is optional; other components start without it.
The API is asynchronous. A caller submits work, receives a job ID, and waits for a callback or polls as a fallback. The service reads input and writes output through signed storage URLs, without routing files through ERP or holding database credentials. Redis stores job state and result pointers and is required at startup.
Part 6: The repo map
Applications are under apps/, except for the root docs/ application:
| App | What it is | Dev port |
|---|---|---|
erp | The main product, and the host for all background jobs. By far the largest. | 3000 |
mes | Shop floor: job operations, work centers, scheduling. Touch-first. | 3001 |
academy | Training app for new Carbon users. | 4111 |
starter | A minimal example app showing auth + database + forms. | 4000 |
assembler | The Rust CAD/3D service. | varies |
docs | Public documentation site. The only Next.js app here. | 3002 |
Libraries are under packages/:
| Package | What it does |
|---|---|
react | The design system. Check here before writing any UI. |
database | Generated types, migrations, edge functions, database clients. |
server-functions | Privileged, transactional writes (posting, issuing, converting) shared by the apps, the API and jobs. |
planning | MRP and the finite scheduler. |
auth | Login, sessions, requirePermissions, API keys, permission caching. |
form | ValidatedForm, field components, zod helpers. |
jobs | Every Inngest background function, plus the workflow runtime. |
lib | Shared server utilities: the Inngest client, trigger(), email, Slack. |
utils | Pure helpers: dates, money, arrays, strings, BOM maths. |
workflows | Customer workflow schema, validation and pure runtime. No I/O. |
documents | PDF generation, email templates, barcode and label rendering. |
locale | Lingui setup and language switching. |
env | The one place environment variables are read and validated. |
logger | Structured logging, with request-id correlation. |
notifications | The notification event taxonomy (enums only). |
printing | Printer registry, label queue, physical print delivery. |
onboarding | New-company setup content. |
kv | Redis client plus rate limiting. |
stripe | Billing. Cloud edition only. |
ee | Enterprise-only code: Xero, Slack, Jira, Linear, Onshape, plan gating. |
viewer | 3D model and assembly-instruction rendering. |
tiptap | Rich text editor. |
dev | The crbn CLI that runs your local stack. |
checks | Repo conformance rules enforced in CI. |
config | Shared tsconfig, Tailwind theme, vitest preset. No runtime code. |
glossary | Canonical manufacturing term definitions used in field help. |
harness | Internal AI-agent tooling. Not shipped. |
Inside an ERP module (there are eighteen, under apps/erp/app/modules/{module}/):
purchasing/
├── purchasing.models.ts zod schemas and the types derived from them
├── purchasing.service.ts every database operation for this module
├── purchasing.server.ts server-only helpers (optional)
├── types.ts shared types
├── index.ts barrel, import from "~/modules/purchasing"
└── ui/ React components, grouped by featurePart 7: Where it all runs
The same codebase runs locally, in Carbon-operated cloud environments, and on customer-managed servers. Environment configuration determines the deployment model.
7.1 On a laptop
pnpm dev runs crbn up. The CLI starts an eleven-container service stack, waits for Postgres, applies
migrations, regenerates database types, seeds a test user, and starts the applications as host Node processes for hot reload.
Each Git worktree receives isolated ports, a database, and credentials. Worktrees share one Redis container but use separate numbered databases.
7.2 The cloud Carbon runs
Carbon operates a shared cloud on Vercel and dedicated customer installations on AWS.
The shared cloud runs on Vercel. app.carbon.ms for ERP, mes.carbon.ms for MES,
learn.carbon.ms (academy) and docs.carbon.ms all resolve to Vercel, as does a separate
EU region (app.eu.carbon.ms, mes.eu.carbon.ms) served by its own pair of projects. Each
React Router app opts in through its react-router.config.ts, which enables
vercelPreset() from @vercel/react-router when the VERCEL environment variable is
present and otherwise does nothing. The same source therefore builds for Vercel and containers. Vercel's Git integration performs deployment.
Dedicated installations run on AWS. ERP and MES Docker images run on ECS Fargate [3].
Each service uses a load balancer on port 3000, a /health check, and autoscaling from one to ten tasks based
on CPU and memory. sst.config.ts defines this infrastructure with SST [23]. The
AWS with SST recipe deploys the same stack. itar.carbon.ms runs in AWS GovCloud.
Each AWS customer has a separate installation. On each deployment, ci/src/deploy.ts reads the workspaces table and runs
sst deploy --stage prod once per row, each with that customer's own AWS account, region,
domain, certificates, Supabase project, and Redis. Customers are isolated by companyId within an installation (Part 3)
and by separate infrastructure. Adding an AWS customer requires a workspace row. If required workspace fields
are missing, deployment skips that row and logs the omission.
Postgres is managed Supabase on both paths. There is one Lambda: the Assembler ships as a single image that runs either as a Lambda function behind an API gateway (the default) or as a container, which is why 3D conversion can be turned on per customer without changing the main stack.
7.3 On a customer's own server
contrib/deploying/simple-docker-caddy/ contains a complete self-hosted stack: a Docker
Swarm file that brings up Postgres, the Supabase services, Redis, Inngest and both apps on
one machine, with Caddy in front as the only thing exposed to the internet. Secrets are
Swarm secrets rather than environment variables in a file. Migrations run as a throwaway
one-shot container before the apps roll. scripts/backup.sh creates a database dump and uploaded-file archive,
and scripts/harden.sh configures the firewall. See Backups.
Which features exist is decided by one variable, CARBON_EDITION: community,
cloud, enterprise or test. What each of those buys you is the
licensing model. Self-hosting defaults to community; the cloud fan-out
sets enterprise.
7.4 How a merge becomes production
Pushing to main triggers each Vercel project's Git-integrated build. AWS deployment uses four independent GitHub Actions workflows:
| Workflow | Fires when | What it does |
|---|---|---|
check.yml | every pull request | Biome, typecheck, tests, translation and workflow-catalog checks; an edge-functions job fails any ../ import out of a function's directory, then runs deno check |
deploy.yml | merge to main touching apps/erp, apps/mes or packages | builds both images from the one root Dockerfile, pushes them to Amazon's image registry tagged with the commit, then fans out sst deploy per workspace |
supabase.yml | merge touching packages/database/supabase | applies migrations and deploys edge functions, per workspace |
inngest.yml | after deploy.yml succeeds | calls PUT /api/inngest on each app so Inngest learns the current list of jobs |
check.yml does not build application images, so a successful pull request can still fail during image build.
Database and application deployments run without ordering, so migrations must remain compatible with the previous application version.
There is no version tagging and no rollback button. An image is identified by its commit,
so rolling back means redeploying an older commit. Configuration is not held in a secrets
manager: values are read from the workspaces table at deploy time and set as plain
environment variables on the container.
7.5 Knowing what is happening
LogTape emits colored local logs and newline-delimited JSON in production. It redacts sensitive fields and
propagates request IDs. Carbon does not provide separate log shipping; use CloudWatch on AWS or docker service logs
when self-hosted. PostHog provides product analytics and is disabled in controlled environments. Carbon does not use Sentry or distributed tracing.
Part 8: There's a bug, where do I look?
Start with the observed behavior and follow the request path inward.
Common failure modes:
- TypeScript says a column doesn't exist. Run
pnpm run generate:types. Your generated types are behind your migrations. - A query returns nothing but the row is definitely there. RLS. Either the
companyIdis wrong or you are using a user-scoped client where you needed service role. - A write half-succeeded. Chained
supabase-jscalls are not a transaction. Look for a manual rollback that didn't cover the failure. - A number on a list screen is wrong. Check the view that computes it.
Part 9: House rules
The root AGENTS.md contains the complete rules. Key constraints:
pnpm, nevernpm.- Never use JavaScript
Datefor parsing, formatting or arithmetic. Use@internationalized/dateand@carbon/utils. A build check enforces this. Timezones in a manufacturing schedule are not cosmetic. - Never hand-edit the generated database types. Regenerate them.
- Never skip
companyIdscoping, even though RLS would probably catch it. - Never chain
supabase-jswrites and call it a transaction. Use Kysely, or an RPC. - Never query inside a loop. Collect the ids and make one
.in()call. - One
{module}.service.tsand one{module}.models.tsper module. Don't scatter them. - Run
pnpm run generate:typesafter any migration, before typechecking.
Useful commands:
pnpm dev # boot everything
pnpm db:migrate:new <name> # create a migration
pnpm db:migrate # apply migrations
pnpm run generate:types # regenerate database types
pnpm exec turbo run typecheck --filter=erp # typecheck one package
pnpm run lint # Biome
pnpm run test # unit testsTypecheck a scoped package with --filter; whole-repository typechecking can exhaust memory.
Glossary
Numbered references used throughout this page, in alphabetical order.
1. Action: the function a route file exports to handle a form submission. Runs on the server only, never shipped to the browser.
2. Deno: an alternative JavaScript runtime to Node.js. Our one remaining edge
function (embedding) is written for it, which is why it uses https:// imports
instead of node_modules.
3. ECS Fargate: Amazon's service for running Docker containers without managing the servers underneath. The ERP and MES run here in production.
4. Edge function: a small server-side program deployed next to the database, called over HTTP and awaited like a normal function. Ours are written for Deno.
5. Inngest: the background job runner. Send it an event; it calls your function over HTTP and remembers each completed step, so a retry resumes rather than restarts.
6. Kysely: a TypeScript SQL query builder. Unlike supabase-js it holds a
real database connection, so it can run real transactions. It also bypasses RLS.
7. Lingui: the translation library. You mark strings in code; it extracts them into per-language files for translators.
8. Loader: the function a route file exports to fetch the data its page needs. Runs on the server only.
9. Migration: one timestamped SQL file describing a schema change. The full ordered set of them is the schema; there is no separate schema file.
10. Monorepo: one Git repository holding several applications and shared libraries that are versioned and released together.
11. MRP: Material Requirements Planning. Works out what to buy and make, and
when, given demand and current stock. Runs in Node, from @carbon/planning.
12. Multi-tenancy: one database and one running application serving many separate customer companies, with each company's data invisible to the others.
13. nanostores: a very small state library. We use it for reference lists (items, customers, suppliers) that many screens read and Realtime keeps fresh.
14. pg_cron: a Postgres extension that runs SQL on a schedule, inside the database. Used for cache refreshes and queue sweeping.
15. pg_net: a Postgres extension that lets the database make outbound HTTP calls. We use it to post events straight to Inngest after a commit, above all the ping that wakes the event queue.
16. PGMQ: a message queue implemented in ordinary Postgres tables. Lets a database trigger enqueue work inside the same transaction as the write that caused it.
17. Postgres: the relational database everything in Carbon is built on. Version 15.
18. PostgREST: a service that exposes Postgres tables and views as a REST
API. Every client.from(...).select(...) is a PostgREST call, not raw SQL.
19. Radix UI: an unstyled, accessible React component library. Our design system adds Tailwind styling on top of it.
20. RLS (Row Level Security): a Postgres feature where a per-table rule is automatically added to every query. It is what keeps one company's data invisible to another.
21. RPC: here, calling a function defined inside Postgres from application
code, via client.rpc("name", args).
22. Service role: a database credential that bypasses all RLS. Powerful; use carefully.
23. SST: infrastructure-as-code for AWS, written in TypeScript.
sst.config.ts defines our production cluster.
24. Supabase: an open-source bundle around Postgres: REST API, auth, storage, realtime and edge functions. We run its containers locally and use the managed version in production.
25. Turborepo: the build orchestrator that knows which packages depend on
which, so pnpm build only rebuilds what changed.
26. View: a saved query that behaves like a read-only table. Carbon writes to tables and reads from views; computed values like order totals live in the view.
27. Vite: the build tool and dev server behind all our React apps.
28. zod: a schema library. Describe data once, get validation and a TypeScript type from the same definition.
29. zustand: a small React state library. We use it only for throwaway UI state, like whether a modal is open.