Skip to content
Start

Architecture

The big picture of Backlex in one page — repo shape, runtimes, and adapter layers.

The big picture in one page.

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` CLI

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.

InterfaceBunCloudflare WorkersVercel / Netlify (Node 22)
StorageAdapterfsStorage / bunS3Storager2Storage / s3FetchStorages3FetchStorage (S3 env vars required — Lambda zip has no local fs)
VectorAdapterpgvectorAdaptervectorizeAdapterpgvectorAdapter
Realtimein-proc + SSEDO (Hibernation API) → SSE bridgeSSE loads but impractical — Lambda is stateless, function timeout caps the stream
EmailAdapterconsole/resend/sendgrid/mailgun/ses/smtpsame minus smtp (no raw TCP)console/resend/sendgrid/mailgun/ses/smtp
ImageAdapterbunImagecfImagesharpImage (Vercel) → wasmImagepassthroughImage
EdgeImageAdaptercfEdgeImage (Image Resizing)netlifyEdgeImage (Netlify Image CDN) on Netlify
SamlAdaptersamlifysamlify (via nodejs_compat)samlify (Node 22 native crypto)
LdapAdapterldapts— (no raw TCP; aliased to a throwing shim)ldapts (Node 22 has raw TCP)

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.

One DSL, three execution paths:

PathCompilerOutput
REST + GraphQL filtercompileConditionDrizzle SQL fragment, parameterized
Realtime per-event filtermatchesConditionboolean (in-memory)
Sandbox ctx.db.list/onecompileCondition (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.

Three providers, one selector:

priority 1: remote-http → env.FUNCTIONS_EXEC_URL set (out-of-isolate executor)
priority 2: bun-worker → Bun runtime
priority 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.

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 provider

All four downstream consumers see the same event payload. Webhooks + flows + functions are fire-and-forget — they don’t block the API response.

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 as action LIKE '<prefix>%' (action=item catches item.create, item.update, …).
  • from / to — epoch-ms bounds on created_at (a dialect-agnostic Date column, so the window is server-enforced on both PG and SQLite).
  • collection, itemId — exact-match filters.
  • limit (≤ 200) / offset — pagination.
  • meta=count — adds meta.count, the total matching the same filters (ignores limit/offset), via one extra SELECT 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.)

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.

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, developers

The 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. Splits plan (Claude → tool + args) and run (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.

Per-request middleware in apps/web/src/server/app.ts:

  1. CORS — origin = env.APP_URL, credentials allowed.
  2. buildContext — wires DB, adapters, schema-seeded roles.
  3. sessionMiddleware — better-auth cookie first, then Bearer pak_… API key fallback. Loads role names into c.var.auth.
  4. Route handler — for resource routes, pairs with requirePermission middleware that resolves DSL + sets c.var.permission.

first user gets admin: better-auth’s databaseHooks.user.create.after counts users; if it’s 1, assign admin, else authenticated.

In-process state that doesn’t survive across multiple workers / regions:

  • Realtime subscribers Map (Bun) — single-instance only
  • lastTickAt for cron dedupe — per-process; safe because cronTick is idempotent (only fires when prev() ∈ (lastTickAt, now]), and the per-minute schedule means a cold instance’s now - 60s default covers exactly the window it missed
  • rolesSeeded flag — 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.

packages/db/drizzle/
pg/ PG migrations (hand-written SQL)
sqlite/ SQLite migrations (hand-written SQL)

Migrations in this repo are hand-written SQLdb:generate:* is only used to refresh the drizzle snapshot. Both dialects must be edited in lockstep. After schema changes:

Terminal window
bun run db:generate:pg # refresh snapshot only (interactive TTY)
bun run db:generate:sqlite
bun run db:migrate:pg # needs DATABASE_URL; also CREATE EXTENSION vector
bun run db:migrate:sqlite # writes to ./.data/backlex.sqlite
bun run db:migrate:d1 # apply sqlite migrations to a local D1
bun run db:migrate:d1:remote # same, but to deployed CF D1
# or via the CLI:
bun run backlex migrate
  • 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.