Architecture
The big picture of Backlex in one page — repo shape, runtimes, and adapter layers.
The big picture in one page.
Repo shape
Section titled “Repo shape”backlex/├─ apps/│ └─ web/ One workspace — Hono API + Vite + React admin SPA│ (server/ + client/ + entries/{bun,worker,vercel,netlify}.ts)└─ packages/ ├─ core/ Types only (DSL, errors, adapter interfaces) ├─ db/ Drizzle schemas (pg + sqlite) + dynamic-DDL applier + DSL compiler ├─ auth/ better-auth wrapper + plugin selection ├─ ui/ shadcn radix-luma component library ├─ client/ `backlex` typed SDK └─ cli/ `backlex` CLIAdapter pattern
Section titled “Adapter pattern”Every cross-runtime concern hides behind a TypeScript interface in
@backlex/core/adapters. apps/web/src/server/context.ts::buildContext
picks the right implementation based on bindings/env.
| Interface | Bun | Cloudflare Workers | Vercel / Netlify (Node 22) |
|---|---|---|---|
StorageAdapter | fsStorage / bunS3Storage | r2Storage / s3FetchStorage | s3FetchStorage (S3 env vars required — Lambda zip has no local fs) |
VectorAdapter | pgvectorAdapter | vectorizeAdapter | pgvectorAdapter |
| Realtime | in-proc + SSE | DO (Hibernation API) → SSE bridge | SSE loads but impractical — Lambda is stateless, function timeout caps the stream |
EmailAdapter | console/resend/sendgrid/mailgun/ses/smtp | same minus smtp (no raw TCP) | console/resend/sendgrid/mailgun/ses/smtp |
ImageAdapter | bunImage | cfImage | sharpImage (Vercel) → wasmImage → passthroughImage |
EdgeImageAdapter | — | cfEdgeImage (Image Resizing) | netlifyEdgeImage (Netlify Image CDN) on Netlify |
SamlAdapter | samlify | samlify (via nodejs_compat) | samlify (Node 22 native crypto) |
LdapAdapter | ldapts | — (no raw TCP; aliased to a throwing shim) | ldapts (Node 22 has raw TCP) |
Hybrid schema ownership
Section titled “Hybrid schema ownership”System tables (users, sessions, roles, permissions, files,
activity, revisions, webhooks, flows, functions, plus auth /
SSO tables: app_sessions, app_users, app_verifications,
saml_providers, ldap_configs, external_identities, email_config,
auth_config, api_keys, i18n_strings, tenants, app_settings,
item_ownership) live in packages/db/src/{pg,sqlite}/schema.ts —
Drizzle owns them, and they migrate via hand-written SQL under
packages/db/drizzle/{pg,sqlite}/.
User collections live in physical tables whose name is whatever the
collection metadata row’s physical_table column says — the default
the unified create endpoint picks is c_<tenantPrefix12>_<slug>, but
adopted collections can wrap any existing table name. POST /api/collections
is the single create endpoint and runs DDL only on managed collections
(adopted: false); adopted: true writes the metadata row alone.
PATCH/DELETE /api/collections/:slug apply the same managed-vs-adopted
split.
applyCollection is additive only — it never drops or alters
existing columns and short-circuits on adopted collections. Field
removal goes through the explicit dropField function so admins can
audit destructive moves.
Permission DSL
Section titled “Permission DSL”One DSL, three execution paths:
| Path | Compiler | Output |
|---|---|---|
| REST + GraphQL filter | compileCondition | Drizzle SQL fragment, parameterized |
| Realtime per-event filter | matchesCondition | boolean (in-memory) |
Sandbox ctx.db.list/one | compileCondition (via host bridge) | SQL fragment |
Same operators, same variables ($user.id, etc.), same logical
combinators. A filter that works in one place works in the others.
Sandbox provider selection
Section titled “Sandbox provider selection”Three providers, one selector:
priority 1: remote-http → env.FUNCTIONS_EXEC_URL set (out-of-isolate executor)priority 2: bun-worker → Bun runtimepriority 3: quickjs → anywhere else (Workers, Vercel, Netlify, Node)The host bridge (apps/web/src/server/services/sandbox/host-bridge.ts) is the
single dispatcher for ctx.fetch / ctx.db / ctx.email / ctx.push / ctx.ai.
bun-worker calls it in-process; remote-http calls it over HTTP at
/api/_internal/sandbox-rpc with a Bearer token. Both paths funnel
through the same permission pipeline. quickjs reaches it not at all — the
bundled WASM is sync-only, so that provider installs each host call as a
function that refuses by name rather than leaving it undefined.
Event flow
Section titled “Event flow”CRUD routes call publishEvent(env, channel, payload, serverCtx):
┌─ items.ts route ─┐│ POST /api/items ││ │ ││ ▼ ││ publishEvent ────┼───► realtime (SSE / DO) ◄── connected subscribers│ │ ││ ├───────► dispatchWebhooks ──► HMAC-signed POST to webhook.url│ ││ ├───────► runFlows ──────► op chain (log/webhook/email/condition)│ ││ └───────► runEventFunctions ──► matching cron-/event-trigger functions└──────────────────┘ in the sandbox providerAll four downstream consumers see the same event payload. Webhooks + flows + functions are fire-and-forget — they don’t block the API response.
Activity log
Section titled “Activity log”The activity table is the single audit/log store — there is no
separate logging pipeline. Route handlers call recordActivity /
logActivity (services/activity.ts); actions are dot-namespaced
(item.create, auth.login, request.error). GET /api/activity
reads it back: admins see every row, non-admins only their own.
Query params (all optional, AND-combined with the non-admin scope):
action— namespace prefix, matched asaction LIKE '<prefix>%'(action=itemcatchesitem.create,item.update, …).from/to— epoch-ms bounds oncreated_at(a dialect-agnosticDatecolumn, so the window is server-enforced on both PG and SQLite).collection,itemId— exact-match filters.limit(≤ 200) /offset— pagination.meta=count— addsmeta.count, the total matching the same filters (ignoreslimit/offset), via one extraSELECT COUNT(*).
The admin surface is the single Logs page (pages/logs.tsx), which
toggles between a Stream lens (HTTP / data / automation / functions
/ storage projection) and a Table audit trail. Both views read the
same rows through a paginated useInfiniteQuery, pushing the time range
(from) and the category chip (action) to the server so the view is
never clipped to the freshest 200 rows. (The former standalone
“Activity log” page was merged into this; /activity redirects to
/logs.)
Why the admin keeps its own client
Section titled “Why the admin keeps its own client”The admin console does not use @backlex/client, and that is a design
decision rather than a gap somebody has not got round to closing.
apps/web/src/client/lib/api.ts carries a Cloudflare D1 Sessions API
bookmark (x-d1-bookmark) on every request, and the server half reads it
back in server/app.ts. The SDK has no equivalent. Routing admin reads through
it would silently drop read-your-writes on D1: save a row, and the screen that
re-reads it shows the previous one. The test suite runs on in-process
SQLite, so it would not catch that — the failure would appear only on a
deployed Worker.
The two clients are also doing genuinely different jobs. The admin’s is a React
Query cache with an ApiError type and 44 hooks over 1148 lines; an
application’s is the SDK’s live result array and a thrown BacklexError. What
IS shared is shared: the admin imports the SDK’s realtime channel naming rather
than rebuilding it.
A future SDK could carry the bookmark — it would need a d1Bookmark on the
transport, CORS exposure of the response header, and a Workers-only
integration test, since nothing in the local suite exercises D1’s session
semantics. Named here so the option stays visible and the omission does not
keep getting rediscovered as a bug.
Admin layout
Section titled “Admin layout”apps/web/src/client/admin/ is laid out by what a file is, and a
source-scan gate (tests/client/admin-ui-conventions.test.ts) keeps it
that way:
admin/ app.tsx config.ts types.ts api.ts queries.ts i18n.ts the shell: ui.tsx select.tsx sheet.tsx icons.tsx extras.tsx the app, its loading.tsx page-skeletons.tsx rule-builder.tsx kernel, and the preferences.tsx extension-frame.tsx *.css design layer lib/ logic with no chrome — formatting, row labels, the collab/signal transports, query-param builders fields/ the field model: the interface catalog, the add/edit dialogs, and one editor or input per field type collections/ the item workbench — list, form, editor, views, bulk edit, collection settings, the adopt wizard pages/<area>/ one folder per sidebar group: data, automation, observability, access, settings, developersThe root is an allow-list — a new file there fails the gate, which is the point: it forces the question “what is this?” at the moment the file is created rather than 76 files later.
Every page under pages/ is a lazy-imported chunk wired through
app.tsx. The sidebar order lives in config.ts::NAV_ITEMS; the
page-skeleton dispatcher in page-skeletons.tsx renders the per-page
placeholder during the chunk load. Notable surfaces:
- Overview (
overview) — adapter dashboard + live metrics. - Ask AI (
ask-ai) — natural-language MCP-tool dispatch; the same surface Claude Desktop sees but driven from the admin’s own session. Splitsplan(Claude → tool + args) andrun(execute + audit) into two endpoints under/api/admin/ai/. See ask-ai.md. - Collections / Items / Storage / Database / Logs — the workspace data-management cluster.
- Flows / Functions / Webhooks / Realtime — the automation cluster.
- Access / Users / App users / API keys — identity + permissions.
- REST Explorer / GraphQL / OpenAPI — the developer-tools group.
Adding a new page = pages/<area>/<id>.tsx export, NAV_ITEMS entry,
NAV_LABELS Lingui descriptor, lazy import + render branch in
app.tsx, a warmer in lib/page-prefetch.ts, and a skeleton case in
page-skeletons.tsx.
Auth pipeline
Section titled “Auth pipeline”Per-request middleware in apps/web/src/server/app.ts:
- CORS — origin =
env.APP_URL, credentials allowed. - buildContext — wires DB, adapters, schema-seeded roles.
- sessionMiddleware — better-auth cookie first, then
Bearer pak_…API key fallback. Loads role names intoc.var.auth. - Route handler — for resource routes, pairs with
requirePermissionmiddleware that resolves DSL + setsc.var.permission.
first user gets admin: better-auth’s databaseHooks.user.create.after
counts users; if it’s 1, assign admin, else authenticated.
State that’s local to a process
Section titled “State that’s local to a process”In-process state that doesn’t survive across multiple workers / regions:
- Realtime subscribers Map (Bun) — single-instance only
lastTickAtfor cron dedupe — per-process; safe becausecronTickis idempotent (only fires whenprev() ∈ (lastTickAt, now]), and the per-minute schedule means a cold instance’snow - 60sdefault covers exactly the window it missedrolesSeededflag — boots once per isolate; the seed function is idempotent so multi-isolate is fine
For multi-instance Bun deployments (rare), use Workers or accept that realtime fan-out is per-instance.
Periodic sweep throttles are NOT in this list — deliberately. The
backup / schema-snapshot / usage-gauge / activity-prune / demo-reset sweeps
run on a much coarser cadence than the per-minute tick (15 min to 24 h), so
“has this interval elapsed?” cannot be answered from process memory: every
serverless entry (the Workers scheduled() handler, Vercel, Netlify, Lambda,
GCP, Azure) may run each tick in a fresh instance, where a module-level
lastXSweepAt = 0 makes now - 0 >= interval trivially true and the throttle
never engages. They’re claimed instead through claimSweep in
services/scheduler.ts, an atomic compare-and-set on an app_settings row
(ON CONFLICT (id) DO UPDATE … WHERE updated_at <= cutoff, RETURNING), so
exactly one instance wins a given window. Anything periodic and expensive
added to the tick should use claimSweep, not a let.
Migrations
Section titled “Migrations”packages/db/drizzle/ pg/ PG migrations (hand-written SQL) sqlite/ SQLite migrations (hand-written SQL)Migrations in this repo are hand-written SQL — db:generate:* is
only used to refresh the drizzle snapshot. Both dialects must be edited
in lockstep. After schema changes:
bun run db:generate:pg # refresh snapshot only (interactive TTY)bun run db:generate:sqlitebun run db:migrate:pg # needs DATABASE_URL; also CREATE EXTENSION vectorbun run db:migrate:sqlite # writes to ./.data/backlex.sqlitebun run db:migrate:d1 # apply sqlite migrations to a local D1bun run db:migrate:d1:remote # same, but to deployed CF D1# or via the CLI:bun run backlex migrateWhy these tradeoffs
Section titled “Why these tradeoffs”- Hybrid schema instead of all-Drizzle: matches the Directus mental model where the user’s data shape evolves at runtime, but keeps system tables under static migration control.
- Own permission DSL instead of CASL: needed something the SQL compiler could read; CASL is row-evaluation-only and didn’t fit.
- Three sandbox providers instead of one: free-tier Workers users shouldn’t lose function execution; paid users shouldn’t be stuck on WASM-slow QuickJS.
- passkey-first in Phase 5.7: phishing-resistant, hardware-backed, better UX than TOTP. TOTP two-factor is also fully wired (enrolment, login challenge, backup codes, admin reset) for compliance use — see Auth planes → Control-plane auth features.