Skip to main content
Alforse 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. Both are served from 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:
  1. The ERP and the MES do not talk to the database directly. Their server code calls a gateway under /api/web in 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 in kernel/, modules/, products/, or packages/database/.
  2. The database enforces tenant access. Row-level security prevents access to another company’s rows. See Part 3.
  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.
  4. 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.
  5. 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 request completes after the rows are written. Notifications, webhooks, and other side effects run later, from the outbox.

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
Alforse 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: 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.
The service then writes the delivery and payment rows for the order. Each call to the client is a separate request to the gateway. For writes that must be atomic, the gateway routes to a native owner that runs the whole operation in one database transaction (see Part 3.3). 6. Database-side behavior. 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 is components/Table/Table.tsx, the generic table used by list screens.
Client state is limited. Shared reference lists live in stores under 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 use requirePermissions for authentication and authorization.
Defined at packages/web/auth/src/services/auth.server.ts:152, it performs these operations:
  1. Identifies the caller from the native session cookie or, for the public API, the alforse-key header. API keys are checked by the native key owner with the route’s required permissions, including rate limits. See API keys.
  2. Asks the native authorization owner for a decision. The required permissions (view, create, update, delete, optionally role) 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.
  3. 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.
  4. Returns a request-scoped client, companyId, companyGroupId, and userId.
Alforse uses multi-tenancy [10]: one database serves multiple companies. The active company comes from the session, not the URL. Row-level security (RLS) [16] is enabled and forced on tenant tables in 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 in packages/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.
Delivery is at-least-once, so handlers are idempotent. A retry policy (maximum attempts, exponential delay with a ceiling) decides when to retry a transient failure and when to stop on a permanent 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, via client.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.