app.alforse.com and mes.app.alforse.com; one backend serves both.
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 [12] link to definitions in the Glossary.
Part 1: The map
Alforse is a monorepo [8] managed with pnpm workspaces and Turborepo [17]. It has two layers. The screens.apps/erp and apps/mes are React Router applications with server-rendered React. Their
shared web packages live in packages/web/* (auth, database, api, jobs, planning, react,
utils, workflows, and others).
The backend. apps/backend is the single application host. It serves the API and composes the native layers:
One Postgres [12] database is the system of record. It enforces row-level permissions per company
and holds the transactional outbox that background work reads. Part 3 describes these responsibilities.
Key properties:
- The ERP and the MES do not talk to the database directly. Their server code calls a gateway under
/api/webin the backend. The gateway speaks the same REST, RPC, auth, and storage protocol the screens were built for and routes every call to a native owner inkernel/,modules/,products/, orpackages/database/. - The database enforces tenant access. Row-level security prevents access to another company’s rows. See Part 3.
- Writes that must succeed together share one transaction. A native owner joins the caller’s unit of work, and events are written to the outbox in that same transaction.
- Background work runs inside the backend. A runner inside the backend process executes scheduled and queued tasks, and a delivery worker sends notifications and webhooks. See Part 4.
- CAD conversion is part of the backend. Geometry conversion and assembly planning run in the backend
behind
/api/web/assembler; there is no separate service to operate.
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, document numbering, and UI refresh.The short version
The same story, with real files
1. The button. It is a link, not a submit button.apps/erp/app/modules/purchasing/ui/PurchaseOrder/PurchaseOrdersTable.tsx:535
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 (from @alforse/web-form) wraps an HTML form with schema-based client and server error display.
It receives the validator and the default values as props.
3. The schema. apps/erp/app/modules/purchasing/purchasing.models.ts:146
purchaseOrderValidator is a zod [19] object. zod provides runtime validation and inferred TypeScript
types, and 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.
4. The route. apps/erp/app/routes/x+/purchase-order+/new.tsx:30
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:
loader and action are excluded from browser JavaScript, so they can call server-only code.
5. The service function. apps/erp/app/modules/purchasing/purchasing.service.ts:1663 (insertPurchaseOrder)
Routes call service functions instead of accessing the database directly. A service function accepts a client as
its first argument and returns the { data, error } shape of supabase-js. That client is created by
getAlforseServiceRole() in packages/web/auth/src/lib/supabase/client.server.ts:13. Despite its historical name, it
points at the native gateway (/api/web) and acts as the signed-in user for the current request. It cannot be
used from another request.
client.rpc("get_next_sequence", ...) is an RPC [15] handled by the
master-document-numbering module (modules/master-document-numbering/). It allocates the next number inside the
submitting transaction and formats it with the company’s prefix, zero-padding, and optional date tokens. See
Sequences.
7. The screen updates. The action ends with throw redirect(...). React Router follows the redirect,
runs the destination loader, reads current state through the gateway, 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. Alforse does not use Remix or Next.js for ERP and MES screens. Vite [18] builds the applications, and Tailwind v4 provides styling. (apps/backend itself is a Next.js application host.)
Routes are files on disk. apps/erp/app/routes/ has well over 1,600 files, and the
folder structure is the URL structure, through remix-flat-routes (apps/erp/app/routes.ts):
At the top level,
x+ contains authenticated application routes, api+ contains JSON endpoints,
_public+ contains authentication routes, and share+ contains token-authenticated public links.
select-company+ and onboarding+ handle company selection and first-run setup.
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() (in
apps/erp/app/hooks/) 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/web/react/: the design system (@alforse/web-react), with buttons, modals, comboboxes, menus, and layout primitives.apps/erp/app/components/: app-level pieces. The big one iscomponents/Table/Table.tsx, the generic table used by list screens.
apps/erp/app/stores/ (items,
customers, suppliers, people) and are refreshed by RealtimeDataProvider. Ephemeral UI state uses zustand [20].
User and permission data comes from route loaders.
Translations use Lingui [5]. Mark strings with t`...` or <Trans>; catalogs are compiled with pnpm lingui:compile.
MES uses the same stack with a flatter structure. It has no modules/ folder: services are in
apps/mes/app/services/, routes are one flat level (about 100 files), 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 userequirePermissions for authentication and authorization.
packages/web/auth/src/services/auth.server.ts:152, it performs these operations:
- Identifies the caller from the native session cookie or, for the public API, the
alforse-keyheader. API keys are checked by the native key owner with the route’s required permissions, including rate limits. See API keys. - Asks the native authorization owner for a decision. The required permissions (
view,create,update,delete, optionallyrole) are sent to the backend, which answers from the user’s grants in the selected company. Grants accumulate from the employee type, user groups, and direct assignment. - Rejects unauthorized requests. An expired or missing session is destroyed and redirected to login; a missing permission redirects with an Access Denied message. A decision that does not match the session’s user or company is refused as an inconsistent identity.
- Returns a request-scoped client,
companyId,companyGroupId, anduserId.
packages/database/migrations/0001_baseline.sql
(ALTER TABLE ... FORCE ROW LEVEL SECURITY). Each transaction sets the current company and actor as
transaction-local settings, and the policies compare rows against them. Queries for another company’s records return no rows.
Platform versus workspace. The platform control plane (workspace lifecycle, plans, entitlements) is a separate
security domain from a customer’s workspace. Platform authority never grants implicit access to a customer’s
business records.
Portal users (customers and suppliers) 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: ways to write
The ERP and MES screens still use the data-access shapes they were built with. The backend maps each of them to a native owner instead of exposing a database connection. 1. The gateway client.from(table).select/insert/update/delete and rpc(name, args) calls go to
/api/web/rest/v1/[relation] and /api/web/rest/v1/rpc/[name]. Reads resolve to native read relations; writes
resolve to the owning product or module, which validates the command and writes through its store in
packages/database/src/.
2. Named native operations. Some screens use a Kysely [7] query builder
(getPostgresClient in packages/web/database/src/client.ts). It does not send SQL. The builder records the
structure of the query and resolves it to exactly one named operation registered in
packages/web/database/native-operations/ (for example purchasing.ts, inventory.ts, production-*, quality.ts).
Anything it cannot map is refused by name. This is how multi-row writes, such as drag-to-reorder (apps/erp/app/modules/shared/sort-order.ts),
stay all-or-nothing.
3. Native owners and the unit of work. The heavy writes (posting a shipment or an invoice, issuing material,
completing a production quantity) are owned by classes under products/* and packages/database/src/. They run in a
unit of work: one Postgres connection per business transaction, with the actor and company set for the
transaction. If the body fails, everything rolls back. Hooks that must follow a successful write run only after commit.
4. RPCs are a narrower path: named functions called with client.rpc("name", args). get_next_sequence from
Part 2 is one example. Each RPC is mapped to a native owner or explicitly listed as unmapped.
MRP [9] and production scheduling logic live in packages/web/planning and the planning owners.
3.4 The database
Alforse uses PostgreSQL. There is no PostgREST layer, no Supabase project, and no edge-function runtime: the backend is the only thing that talks to the database, as dedicated database roles (a runtime role subject to RLS, and separate roles for the scheduler and the delivery worker). Each migration [11] is a numbered SQL file inpackages/database/migrations/: 0001_baseline.sql is the
immutable baseline (the complete schema of a new database plus its reference rows), and every later change is a
file numbered 0002 and up, named <ordinal>_<description>.sql. They run in numeric order inside a
transaction owned by the runner, and an applied file is never edited, renamed, reordered, or removed (the runner
records a checksum for each). See packages/database/migrations/README.md and packages/database/src/migrations.ts.
The baseline creates about 740 tables in two schemas, platform (control plane and native owners) and web
(the contract schema the screens read), with views [21] for reads.
Identifiers are generated by the application, not the database: {prefix}_{base58(uuidv7)}, with a
globally unique prefix per record type and text primary keys. This keeps IDs sortable by creation time and readable in logs and URLs.
Reads and writes. Alforse writes through owners and reads from relations that derive display values
(totals, names, thumbnails). The write path inserts source data; the read side derives what the screen shows.
Files live in object storage behind the /api/web/storage route. The backend supports Cloudflare R2, S3-compatible
storage, and a customer storage gateway as providers (apps/backend/src/production/blob-storage-provider.ts and neighbours),
with per-company key namespaces, quotas, and an optional backup copy profile.
Part 4: What happens after the write
Event processing is independent of the code that performs a write. Owners publish a domain event to the transactional outbox (platform."outbox") in the same transaction as the business change, so a rolled-back write
never emits an event and a committed write never loses one.
kernel/events/ defines the event bus, the retry policy, and the webhook delivery.
The native runner lives inside the backend process (apps/backend/src/runner/). A task is accepted into
platform."backgroundTaskRun" with a company, a name, and an idempotency key; the runner claims it with a lease,
runs the registered handler inside a transaction bound to that company, and records the outcome (pending,
running, retrying, succeeded, or deadLettered). A failed attempt is retried up to a maximum. The idempotency key stays the same across retries and lease
take-overs, so a handler can tell it has already done the work. Task names such as alforse/company-export and the
packages/web/jobs package keep the job names the screens use; no external job service is involved.
Scheduled work is defined in apps/backend/src/runner/native-runner-schedules.ts. Cron expressions are
evaluated in UTC, and each tick fans out to every active company. Examples: MRP every 15 minutes, a nightly
replan at 01:00, exchange-rate updates at 00:00, audit-log archiving at 02:00, notification digests every 15
minutes, rental billing and contract-time billing hourly, workflow-run retention at 04:00. A separate set of
delivery schedules covers the notification relay (every minute), maintenance due-dispatch (every two minutes),
blob cleanup, and the backup copy of new files.
Notifications are stored in a per-user inbox. The ERP receives changes through change feeds the backend publishes
(for example the private topic user:<userId>:notification, which carries which rows changed, never their data) and
reloads the rows it needs. Email goes through the configured transactional email transport.
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 the runner’s built-in tasks:packages/web/workflows and kernel/workflows contain the definition and validation logic. The workflow
engine records every run and step for inspection, and the runner schedules the workflow backstop (every ten minutes)
and run-history retention. See Workflows.
CAD and assembly planning
CAD files (STEP) are converted into web-renderable 3D, and a collision-aware disassembly sequence is planned for assembly instructions. This runs inside the backend (apps/backend/src/cad/): geometry is read with
occt-import-js (OpenCASCADE compiled to WebAssembly), and the motion planner lives next to it. The ERP and MES
call it through /api/web/assembler, which requires a bearer key, and packages/web/viewer renders the output in the
browser. Long conversions run as runner tasks, so a page can wait for the result or poll for it.
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. 5. Lingui: the translation library. You mark strings in code; it extracts them into per-language files for translators. 6. Loader: the function a route file exports to fetch the data its page needs. Runs on the server only. 7. Kysely: a TypeScript SQL query builder. Here its builder is used only to describe an operation, which the backend maps to a named native operation. 8. Monorepo: one Git repository holding several applications and shared libraries that are versioned and released together. 9. MRP: Material Requirements Planning. Works out what to buy and make, and when, given demand and current stock. 10. Multi-tenancy: one database and one running application serving many separate customer companies, with each company’s data invisible to the others. 11. Migration: one numbered SQL file describing a schema change. The baseline plus the ordered set of later files is the schema. 12. Postgres: the relational database everything in Alforse is built on. 15. RPC: here, calling a named function from application code, viaclient.rpc("name", args). The backend maps it to a native owner.
16. 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.
17. Turborepo: the build orchestrator that knows which packages depend on
which, so pnpm build only rebuilds what changed.
18. Vite: the build tool and dev server behind the ERP and MES.
19. zod: a schema library. Describe data once, get validation and a
TypeScript type from the same definition.
20. zustand: a small React state library. We use it only for throwaway UI
state, like whether a modal is open.
21. View: a saved query that behaves like a read-only table.