Mortar — architecture
Status: shipped; public launch in progress. This doc tracks the code; for past decisions see git history.
Mortar is a Backend-as-a-Service: HTTP API in front, Postgres + Redis + object storage + SSE in back. Built for mainland-China mobile / web apps; starts on a single VPS, swaps to Aliyun RDS / OSS / Tencent Cloud CDB / COS without changing a line of code.
Same shape as Supabase / Firebase, so AI agents recognize the API
surface at a glance — these shapes are everywhere in their training
data. Clients address a project by its host subdomain
(https://<tenant>.api.mortar.appunvs.com/v1/{capability}, Supabase’s
<ref>.supabase.co model); internally a SubdomainTenant middleware
folds that back into the /v1/{tenant}/{capability} path the router and
the rest of the stack operate on (see
§ “Per-project tenant_id isolation”).
Design principles
Internally, Mortar is a 7 primitive × (5 core feature + 3 extension feature) two-layer structure. This is the core design principle — the product’s spine.
5 core: auth / data / files / realtime / usage (shipped).
3 extensions: cron / customdomain / queue (planned,
opt-in via env / flag).
7 primitives (cost dimensions)
Split into 3 baseline (bundled in tier monthly fee) and 4 metered (quota → overage → cap, billed against credit).
| Primitive | Type | What it is | Cloud bill line |
|---|---|---|---|
compute | baseline | pool ECS instance time (per-tenant slice) | ECS instance hours |
database | baseline | Postgres slice (co-located with mortar binary on pool ECS) | Folded into ECS hours (self-hosted PG) or RDS instance baseline |
cache | baseline | Self-hosted Redis slot (co-located on pool ECS) | Folded into ECS hours (self-hosted Redis) |
storage | metered | Object storage GB-month | OSS / COS / S3 GB-month |
network | metered | Outbound bandwidth (egress) | Public outbound GB (OSS request fees absorbed by Mortar) |
function | metered | User FaaS CPU-min | FC / SCF / Lambda CPU-min + GB-second |
comms | metered | Outbound transactional messages (email + sms as two ledger resources at distinct unit prices) | Aliyun DirectMail per-message + Aliyun SMS per-message (or Tencent SES/SMS) |
The 7 primitives share the shape of public-cloud bill categories. Aligning our cost structure with the cloud’s means:
- Customer-facing bills (7 cost dimensions) can be traced back directly to what we pay the cloud vendor
- Any primitive can have its underlying backend swapped (Aliyun → Tencent Cloud → AWS) without affecting feature code on top
- Monitoring + alerting splits cleanly by primitive (slow
function≠ slowdatabase≠ slownetwork), so on-call doesn’t get confused - Baseline vs metered camps enforce differently: baseline is fixed
once a tier is chosen; metered is three-state (quota → overage →
cap). See
pricing.mdfor the details.
5 features (user capabilities)
| Feature | What the user can do | Composed primitives |
|---|---|---|
auth | Signup / login (email+password, phone-OTP) / API key issuing / email-verify / password-reset | database + comms (sms/email code delivery) + cache (OTP / token + send-side throttle) |
data | Table + row CRUD + full-text search | database + cache (query cache) |
files | File upload / download / signed URLs | storage + database (metadata) |
realtime | SSE row-change subscription + broadcast + presence | database (LISTEN) + cache (pub/sub fan-out + presence roster) |
usage | Usage ledger + balance | database |
Each feature composes multiple primitives into one directly callable capability. For example:
data= database + cache. Row storage in Postgres; hot-table queries hit a Redis query cache (TTL ~5s, write-through invalidate).realtime= database + cache. Three pieces (aligned with Supabase Realtime):- postgres_changes (row changes): Postgres triggers fire
pg_notify; a Go listener picks them up and fans out across replicas via cache (Redis pub/sub) to SSE subscribers on every Mortar instance. - broadcast: client→client ephemeral messages (cursors / drag-in-progress).
Routed through
ChannelBroker, fanned out the same way via cache pub/sub; received over SSE, sent overPOST .../broadcast(keeps a single SSE transport — no WebSocket). - presence: who’s online + each member’s ephemeral state (cursor position / selection / color). The roster lives in cache KV (an in-process store for a single process), with its TTL refreshed by the SSE connection’s heartbeat; on disconnect it’s cleared and a leave is broadcast. Together these let Mortar directly support collaborative UIs (e.g. a simple Figma). See realtime multi-region.
- postgres_changes (row changes): Postgres triggers fire
files= storage + database. Blobs in OSS / COS / LocalDisk; metadata (owner / mime / size / created_at) in Postgres.
Extension features (planned)
On top of the 5 core features, 3 opt-in extension features are planned.
Each is an independent Go package (internal/feature/<name>/),
individually toggleable, with no impact on core paths.
| Feature | One-liner | Switch / default |
|---|---|---|
cron | Periodic scheduling → compute Runner | Default on; disable with MORTAR_CRON=off |
customdomain | Project-level custom domain + ACME TLS issuance | Project-scoped config, no ENV gate |
queue | Postgres-backed job queue + worker SDK | MORTAR_QUEUE_BACKEND=<postgres|off> (default postgres) |
Why we keep the two layers separate
The shape marketing + SDK users see is deliberately different from the internal one.
| Audience | What shape they see |
|---|---|
| User SDK / docs | 9 capability modules on MortarClient (auth db storage realtime compute usage cron queue comms) + customDomains + tokens on AccountClient (control plane) |
| Internal code + billing | 7 primitive × (5 core + 3 extension) feature |
| Marketing copy | ”7 cost dimensions on your bill: 3 baseline + 4 metered” |
Cross-language SDK status: TS SDK is the reference implementation and covers all 8 modules + customDomains. Swift / Kotlin / Dart SDKs currently cover the original 6 (
auth/db/storage/realtime/compute/usage) +account; planned additions (cron/queue/customDomains) haven’t been ported across languages yet. When needed, port them by copying the TS implementation — the HTTP surface is stable, schema inopenapi.yaml.
These are two orthogonal concerns:
- Feature shape for users: they ask “how do I upload a file?”,
reach for
mortar.storage.upload(), and don’t care which primitives it composes. - Primitive shape for billing + ops: every primitive = one cloud bill line = one usage meter = one replaceable backend.
Forcing the two together makes both sides miserable. Keep two schemas, give each audience the right one. Detailed justification: § “SDK schema vs internal schema”.
Architecture diagram
┌──────────────────────────────────────────────┐
│ SDK (TS / Swift / Kotlin / Dart) │
│ @mortar-ai/client / mortar-swift / etc. │
└────────────────────┬──────────────────────────┘
│ HTTPS
▼
┌──────────────────────────────────────────────┐
│ Mortar HTTP API (Go, cmd/mortar) │
│ /v1/{tenant}/{auth | data | files | realtime | │
│ compute | usage} │
│ Middleware: tenant + JWT + rate-limit │
└────────────────────┬──────────────────────────┘
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ feature/ │ │ feature/ │ │ feature/ │
│ auth │ ... │ data │ ... │ realtime │
└─────┬────┘ └─────┬────┘ └─────┬────┘
│ │ │
└────────────────────┼────────────────────┘
▼
┌──────────┬──────────┬──────────┬──────────┬──────────┐
▼ ▼ ▼ ▼ ▼
compute database network storage cache
(FC/SCF/ (Postgres / (OSS- (OSS / COS (Tair /
Lambda) RDS / Neon egress / S3 / ElastiCache /
/ local) meter) LocalDisk) local-redis)
│
▼
┌──────────────────────────────────────────────┐
│ Cloud backend │
│ Today: single VPS (local-redis + LocalDisk +│
│ single Postgres) │
│ Roadmap: Aliyun RDS / OSS / FC / Tair │
│ Tencent Cloud CDB / COS / SCF │
│ Roadmap: cross-region (cn-hangzhou/cn-shenzhen)│
└───────────────────────────────────────────────┘
The control plane (account / project lifecycle / billing) runs under
/v1/_accounts/*, split from the /v1/{tenant}/* data plane. Mortar
Center uses AccountJWT auth; end-user app traffic uses APIKey
auth. The two never cross.
Per-primitive
Each primitive is one Go package defining an interface + multiple
backend implementations. Code path: mortar/internal/primitive/<name>/.
primitive/compute
What: User function execution runtime. POST /v1/{tenant}/compute/{fn_name}
takes a JS / TS code blob plus an input payload, returns the output.
Backend options:
- Today (dev/default): Local Node child-process (dev only, not production-ready)
- Roadmap: Aliyun FC (Function Compute) — domestic default
- Roadmap: Tencent Cloud SCF (Serverless Cloud Function) — Aliyun failover
- Roadmap: AWS Lambda (overseas users)
When to use which: production default = Aliyun FC (best regional coverage + fastest cold start); overseas region → Lambda; tests → local child-process.
Key file: mortar/internal/primitive/compute/compute.go.
primitive/database
What: Relational data storage. Mortar uses Postgres (not SQLite);
all tenant-scoped tables use RLS isolation (SET LOCAL mortar.tenant_id = '<uuid>').
Backend options:
- Today (dev/default): Local Postgres 15 single instance
- Roadmap: Aliyun RDS PostgreSQL
- Roadmap: Tencent Cloud CDB PostgreSQL
- Roadmap: Neon serverless Postgres (overseas, on-demand)
- Roadmap: Cross-region read replicas (see
read-replicas)
When to use which: production → RDS; overseas → Neon; CI / dev → local Postgres docker container.
Key files: mortar/internal/primitive/database/database.go (pgx
pool wrapper) + setup.go (schema migration) +
verify_rls_integration_test.go (RLS-isolation contract test).
primitive/network
What: Outbound bandwidth metering. Not a directly callable API —
it’s a side effect of every other primitive. We wrap
http.ResponseWriter to count bytes-out, plus pull OSS access logs to
attribute egress to projects.
Backend options:
- Today (dev/default): Response-writer wrapping (in-process counter) + Postgres buffer
- Roadmap: OSS access-log pull + offline reconciliation
- Roadmap: Aliyun CDN egress real-time report
When to use which: every region uses response-writer wrapping (cheap + accurate); OSS / CDN egress reconciles against the cloud bill separately.
Key files: mortar/internal/primitive/network/network.go. The
adapter lives at mortar/internal/feature/usage/network_adapter.go +
network_buffered.go (buffered counter).
primitive/storage
What: Object-storage abstraction. The ObjectStorage interface
has 4 methods (Put / Get / Sign / Delete).
Backend options:
- Today (dev/default):
LocalDisk(writesMORTAR_STORAGE_LOCAL_ROOTon disk + nginx serve) - Roadmap: Aliyun OSS (domestic default)
- Roadmap: Tencent Cloud COS
- Roadmap: AWS S3 (overseas)
- Roadmap: Cloudflare R2 (overseas, zero egress fee)
When to use which: production-domestic → Aliyun OSS (most complete ecosystem); overseas → R2 (zero-egress friendly); dev / CI → LocalDisk.
Key file: mortar/internal/primitive/storage/storage.go.
primitive/cache
What: KV + pub/sub. Mortar uses this internally for (a) rate-limit
counters (b) realtime cross-replica fan-out (c) query caching.
Internal only — not exposed to the SDK; may later add
mortar.cache.{get,set,publish} for end users.
Backend options:
- Production default: self-hosted Redis on the pool ECS (co-located with Postgres + the mortar binary) — ¥0 marginal cost; this is what makes Mortar’s multi-tenant cache amortization work.
- Dev / tests: in-process map (
backend/local). - Managed alternatives (Aliyun Tair / Tencent Redis / AWS ElastiCache /
Upstash): same Redis protocol → point
MORTAR_CACHE_REDIS_URLat their endpoint. Costs ~¥100-150/GB-month → breaks Mortar’s multi-tenant margin model; only sensible for dedicated tiers or BYOC. No vendor-specific cluster-mode / IAM backends are in-tree.
When to use which: production-domestic → Tair; overseas → Upstash (serverless + zero ops); CI → redis-mock or local Redis container.
Key file: mortar/internal/primitive/cache/cache.go.
primitive/comms
What: outbound transactional messages — email and sms billed as two
distinct ledger resources at distinct unit prices. One primitive, two
channels — cleaner than promoting each channel to its own primitive,
more honest than folding them into network (the vendor bills them
separately).
Backend options:
- Dev:
local— appends todata/comms/<tenant>/outbox.jsonl(inspectable, no cloud deps). - Production:
aliyun(Dysmsapi for SMS, reusingsignACS3; DirectMail email TODO) /tencent(TODO). - env knob:
MORTAR_COMMS_BACKEND=local|aliyun, plusMORTAR_COMMS_ALIYUN_{ACCESS_KEY_ID,ACCESS_KEY_SECRET,SIGN_NAME, ENDPOINT}.
Billing: each message writes one credit_ledger row keyed by
channel — email at YuanPerEmail, sms at YuanPerSMS.
Key files: mortar/internal/primitive/comms/comms.go +
mortar/internal/backend/{local,aliyun}/comms.go.
Per-feature
Each feature is a Go package composing multiple primitives into a
user-facing capability. Code path:
mortar/internal/feature/<name>/.
feature/auth
HTTP routes:
POST /v1/_accounts/signup/signin/me— Mortar account itselfPOST /v1/_accounts/tokens/GET/DELETE— account-scope long-lived PAT (mtr_pat_*) CRUDPOST /v1/{tenant}/auth/signup/signin/signout— AppUser (end user of AI bundle)POST /v1/{tenant}/auth/phone/{start,verify}— phone-OTP login (composescomms.sms+cache)POST /v1/{tenant}/auth/email/verify/{start,confirm}— email verification (composescomms.email+cache)POST /v1/{tenant}/auth/password/reset/{start,confirm}— password reset (composescomms.email+cache)POST /v1/{tenant}/_admin/keys— mint API key
Composition: database (stores mortar_accounts + account_keys
(PAT) + app_users + api_keys) + comms (OTP / token delivery) +
cache (OTP / token storage + send-side cooldown / daily-cap throttle).
Token kinds:
- Account JWT — Mortar account login, scoped to
/v1/_accounts/* - Account PAT — long-lived
mtr_pat_*, same scope as Account JWT, passed viaMORTAR_ACCOUNT_TOKENenv for CI / headless use. - API Key — project-scoped, prefixed
mtr_live_/mtr_test_ - AppUser JWT — end-user-of-AI-bundle login, scoped to
/v1/{tenant}/{auth,data,files,realtime,compute,comms}/*
Key files: mortar/internal/feature/auth/{apikey,jwt,middleware, password,users_dao,apikeys_dao}.go + JWT keys cache
keys_cache.go. See auth.md.
feature/data
HTTP routes:
POST /v1/{tenant}/db/tables/GET/DELETE— table adminPOST /v1/{tenant}/db/{table}/rows/GET/PATCH/DELETE— row CRUDGET /v1/{tenant}/db/{table}/rows?q=...— full-text search
Composition: database (row storage + metadata) + cache (query
cache, planned).
Schema design: user “tables” are app_tables + app_columns
metadata; real rows live in app_rows.data JSONB with a GIN index. AI
can create new tables without ops involvement (no ALTER TABLE).
Trade-off details in § “Key design decisions”.
Key files: mortar/internal/feature/data/{tables_dao,rows_dao, fts,models}.go.
feature/files
HTTP routes:
POST /v1/{tenant}/storage/files?bucket=&key=— upload (raw body)GET /v1/{tenant}/storage/files/{bucket}/{key...}— download or redirect to signed URLDELETE /v1/{tenant}/storage/files/{bucket}/{key...}GET /v1/{tenant}/storage/sign/{bucket}/{key...}— mint signed URL
Composition: storage (blob) + database (metadata row).
Key files: mortar/internal/feature/files/{files_dao,models}.go.
feature/realtime
HTTP routes (aligned with the Supabase Realtime triad):
GET /v1/{tenant}/realtime/{table}— SSE long-poll, server pushes row-change events (postgres_changes)GET /v1/{tenant}/realtime/channel/{channel}— SSE long-poll, receives broadcast + presence;?ref=&key=binds this connection to a presence member (leave on disconnect)POST /v1/{tenant}/realtime/channel/{channel}/broadcast— send a broadcastPOST /v1/{tenant}/realtime/channel/{channel}/presence— report / update presence state (track)DELETE /v1/{tenant}/realtime/channel/{channel}/presence?ref=— explicit leave (untrack; an SSE disconnect also auto-untracks)
Composition: database (LISTEN mortar_row_change) + cache
(Redis pub/sub cross-replica fan-out + presence roster KV).
Two-layer broker design (one set each for row changes + channels, symmetric structure):
- Single-process:
InMemoryBroker/InMemoryChannelBroker+InMemoryPresenceStore - Multi-replica:
RedisBroker(pub/sub) /CacheChannelBroker+CachePresenceStore— cross-replica fan-out via cache pub/sub; presence roster on cache KV - Cross-region (planned):
NSBrokerplaceholder — see realtime multi-region
Key files: mortar/internal/feature/realtime/{realtime,listener, inmemory,redis}.go.
feature/usage
HTTP routes:
GET /v1/{tenant}/usage/me— current project balance + usage + tier cap
Composition: database (ledger rows). This feature is
read-only + writes the ledger; it’s not itself a user-facing
cost dimension.
Ledger model: every primitive use appends a row to credit_ledger
((tenant_id, primitive, qty, yuan, ts)). Credit balance = total
top-ups − cumulative ledger cost. (Currently doesn’t split usage_event /
usage_ledger into two layers — only credit_ledger.)
Key files: mortar/internal/feature/usage/{ledger,cost,ticker, noisy_neighbor}.go. Noisy-neighbor detection (a project’s
utilization spiking) lives in noisy_neighbor.go.
Billing model
Credit ledger
Each METERED primitive use appends a row to credit_ledger. The
ticker (feature/usage/ticker.go) — every 1 h by default (override
via MORTAR_USAGE_TICKER) — aggregates tick-driven resources (tier
baseline accrual + storage at rest) + deducts from the project credit
balance. Once balance ≤ 0, the plan gate rejects writes (reads still
pass, giving the user a chance to top up). Cache is baseline-only —
LRU evicts when full; never enters the ledger.
6 cost dimensions
3 baseline (bundled in tier monthly fee) + 3 metered (quota → overage → cap).
| Dimension | Type | Pricing | Metering |
|---|---|---|---|
| Compute | baseline | Part of tier monthly fee | pool ECS time × vCPU share |
| Database | baseline | Part of tier monthly fee | self-hosted Postgres RAM/disk share on ECS |
| Cache | baseline | Part of tier monthly fee | self-hosted Redis RAM share on ECS |
| Storage | metered | ¥0.15 / GB-month | OSS / COS bucket usage (above quota) |
| Network | metered | ¥0.50 / GB egress | Public outbound (above quota) |
| Function | metered | ¥0.05 / CPU-min | FC / SCF / Lambda execution duration (above quota) |
The monthly bill shows 6 lines, each of which maps to what we pay
the cloud vendor. Transparent + auditable. See pricing.
Plan gate (Tier 1–5)
Each project picks a tier (1=hobby / 2=starter / 3=pro / 4=growth / 5=enterprise). Tier determines:
- Monthly free quota
- Per-primitive hard caps (to prevent noisy neighbors)
- Advanced feature toggles (planned additions such as
cron, etc.)
Plan-gate middleware in internal/plan/gate.go runs on every
endpoint; over-tier-cap returns 402 Payment Required.
Payment providers
| Provider | Region | Use |
|---|---|---|
| WeChat Pay | Domestic | WeChat QR / H5 / mini-program payments |
| Alipay | Domestic | Alipay QR / H5 |
| Stripe | Overseas | International credit cards / Apple Pay / Google Pay |
Implementation: mortar/internal/backend/{wechatpay,alipay}/ (Stripe
planned). Webhook endpoint /v1/_webhooks/payment/{provider};
callback signature is verified, then a +amount row is appended to
the ledger. See pricing.
Mortar Center web admin
Where it lives
Code path: mortar/center/. A Next.js 14 app.
mortar/center/
├── src/
│ ├── app/ ← Next.js app router
│ │ ├── login/
│ │ ├── projects/[id]/ ← project dashboard
│ │ ├── usage/ ← billing / ledger viewer
│ │ └── settings/ ← account + API keys
│ └── lib/ ← Mortar SDK wrapper
└── package.json
How it talks to the HTTP API
Completely split from end-user traffic:
- Mortar Center uses
AccountJWT(issued by Mortar account login) to call/v1/_accounts/*+/v1/_projects/*+/v1/_webhooks/payment/*(control plane) - End-user app traffic uses
APIKey(minted inside a project) to call/v1/{tenant}/{auth,data,files,realtime,compute}/*(data plane)
Both paths share the same Go binary but use different middleware
chains. The control plane attaches accountJWTMiddleware; the data
plane attaches apikeyMiddleware + tenantMiddleware +
rlsSetLocalMiddleware.
Mortar Center deploys as a standalone nginx vhost
(center.mortar.appunvs.com) → Next.js SSR + shared cookie domain
with api.mortar.appunvs.com.
Multi-tenancy
Per-project tenant_id isolation
tenant_id is the data-isolation boundary key, and its value is the
project’s UUID (project = the tenant). Every tenant-scoped Postgres
table has a tenant_id UUID NOT NULL column + an RLS policy:
CREATE POLICY tenant_isolation ON app_rows
USING (tenant_id = auth.tenant_id());
auth.tenant_id() is Mortar’s analog of Supabase’s auth.uid(): a
STABLE helper reading the current_setting('mortar.tenant_id') GUC.
Policies use it instead of an inline current_setting cast, so they
read declaratively and a migrating Supabase project can map its
auth.uid()-shaped policies onto the same form (though Mortar’s
isolation grain is the project’s tenant_id, not the end-user). Defined
in internal/primitive/database/setup.go’s rlsAndTriggers.
Request flow: tenant middleware resolves the tenant_id from APIKey
or AppUserJWT → SET LOCAL mortar.tenant_id = '<uuid>' →
auth.tenant_id() then returns that UUID → every subsequent query
auto-filters by tenant_id. Forgetting SET LOCAL = zero rows
returned (a loud failure, not silent data leakage). Unit
tests verify the SET LOCAL call on every endpoint.
Where the tenant comes from on the wire: clients put it in the
host subdomain (https://<tenant>.api.mortar.appunvs.com, dev
http://<tenant>.localhost:8080) and call tenant-less paths
(/v1/auth/signin, /v1/db/tables/..., /v1/storage/...). A
SubdomainTenant middleware reads the leftmost host label and rewrites
/v1/<capability>/… → /v1/<tenant>/<capability>/… before the
router, so the router, tenant middleware, handlers, and egress metering
below all keep operating on the internal /v1/{tenant}/… shape unchanged.
The base domains are driven by MORTAR_TENANT_BASE_DOMAINS (default
api.mortar.appunvs.com,localhost); wildcard TLS is the operator’s job
(Caddy on-demand TLS), Mortar doesn’t issue certs. Legacy path-based
calls to the apex host (/v1/{tenant}/… directly) still work, so the
change is backward compatible. Account-scope routes (/v1/_accounts/*)
always stay on the apex host and are always tenant-less.
JWT 3-layer scope
| Layer | Token | Scope | Use |
|---|---|---|---|
| Account | AccountJWT | One Mortar account | Control plane (Mortar Center login) |
| Project | APIKey (mtr_live_*) | One project (its tenant_id) | Server-side SDK / CI |
| AppUser | AppUserJWT | One project + one end user | End user inside a mobile / web app |
The three middleware chains check separately, and tokens aren’t
interchangeable (Account can’t call /v1/{tenant}/db; APIKey can’t call
/v1/_accounts/me). See auth.
SDK schema vs internal schema {#sdk-schema-vs-internal-schema}
Mortar has two parallel ways of organizing the same product:
- Internal architecture — what the Go binary cares about: cost dimensions + swappable cloud backends
- SDK / API surface — what users care about: which capability to call
The two aren’t the same shape. Different concerns → different groupings. Forcing them together makes both look bad.
Internal: 7 primitive + (5 core + 3 extension) feature
See § “Design principles” above.
SDK: 9 capability modules (mirrors HTTP paths)
The /v1/{tenant}/… paths below are the internal route shape. On the
wire MortarClient is configured with createClient({ url, apiKey })
(no tenant option) — the tenant rides in the host subdomain
(https://<tenant>.api.mortar.appunvs.com) and the SDK hits the
tenant-less form (/v1/auth/*, /v1/db/*, …); the SubdomainTenant
middleware folds it back to /v1/{tenant}/…. AccountClient always
talks to the tenant-less apex host (/v1/_accounts/*).
@mortar-ai/client
├── MortarClient (project tenant, API-key auth)
│ ├── auth ─→ /v1/{tenant}/auth/* (AppUser signup/login + phone-OTP + email verify + password reset)
│ ├── db ─→ /v1/{tenant}/db/* (tables + rows CRUD)
│ ├── storage ─→ /v1/{tenant}/storage/* (file upload/download)
│ ├── realtime ─→ /v1/{tenant}/realtime/{table} + /realtime/channel/{channel} (SSE: row changes + broadcast + presence)
│ ├── compute ─→ /v1/{tenant}/compute/* (function deploy / invoke)
│ ├── usage ─→ /v1/{tenant}/usage/me (dashboard)
│ ├── cron ─→ /v1/{tenant}/cron/schedules (scheduled functions)
│ ├── queue ─→ /v1/{tenant}/queue/jobs (background jobs)
│ └── comms ─→ /v1/{tenant}/comms/send (email + sms send)
└── AccountClient (account-scope JWT or PAT, control plane)
├── projects ─→ /v1/_accounts/projects/*
├── customDomains ─→ /v1/_accounts/projects/{id}/domains/*
└── tokens ─→ /v1/_accounts/tokens/* (mtr_pat_* PATs)
The db domain splits into two accessors — mortar.from(table) (hot
path: row CRUD) + mortar.tables.{...} (cold path: table admin). So
MortarClient has 10 accessors over 9 domains. customDomains and
tokens live on AccountClient because the routes live under
_accounts (account JWT or PAT, not project API key).
Why three primitives don’t appear in the SDK
network: not user-callable. It’s a side effect of every other call. We wrap the response writer to count bytes-out; users never writemortar.network.send().cache: internal only (rate-limit + realtime fan-out + query cache). May later addmortar.cache.{get,set,publish}for users.compute: This is the pool ECS instance-time allocation per tenant — there’s no runtime API to call. Surfaces in the tier baseline monthly fee plus the noisy-neighbor monitor’s per-tenant CPU attribution (Trackerinterface ininternal/primitive/compute/compute.go).
Why auth + usage aren’t primitives
They’re features (capabilities composing primitives), not cost dimensions.
authuses database (user rows) + network (egress on signin response), but isn’t itself a bill line.usageis a read-only ledger meta-layer; it doesn’t itself produce billable work.
Cross-product positioning
Mortar vs Fabric
Core distinction: Mortar is the backend for AI-generated apps; the Appunvs AI Editor is the IDE that generates the apps.
| Mortar | Fabric | |
|---|---|---|
| Role | Backend-as-a-Service | AI-powered IDE |
| Whose customer | AppUser of AI bundles | Developer using the editor |
| Own backend | Own Postgres + Redis + OSS | fabric-server Postgres + Redis + LocalFS |
| DB contents | AppUser signups / app data / files | User accounts / project metadata / ai_turns / build artifacts |
⚠️ Fabric itself doesn’t run on Mortar. User accounts / project metadata / ai_turns / project builds / bundle storage all live in fabric-server’s own Postgres + Redis + LocalFS. Same pattern as an AI builder not running its own editor on the BaaS it provides users. Only the AI bundle’s own end-user-facing data goes through Mortar (via host bridge).
See fabric/docs/architecture.md §
“Fabric self-hosted infrastructure”.
Mortar vs Keel
Completely independent. Keel works with any backend (Supabase / Firebase / your own / Mortar); Mortar works with any runtime (Keel / Expo / bare RN / web / Flutter / native).
- There’s no
@keel-ai/mortarplugin — users wire Mortar with the standard@mortar-ai/client, same as they’d wire Supabase with@supabase/supabase-js - Mortar docs don’t mention Keel; Keel docs mention Mortar in passing as “one of many BaaS options”
Why: lets each side sell to non-overlapping customers independently. Mortar’s ICP = “developer wanting a China-compliant BaaS”; Keel’s ICP = “developer wanting a China-friendly RN pipeline”. The two groups overlap but don’t have to be bundled.
See keel/docs/architecture.md §
“Relationship to Mortar”.
Key design decisions (recap)
1. Postgres from day 1 (not SQLite)
SQLite suits single-binary local apps. Mortar is a service that eventually runs on managed Postgres (Aliyun RDS / Neon / Supabase Postgres). Starting on SQLite = painful schema + driver rewrite later. We pay a bit more dev-loop cost up front.
2. RLS is the only multi-tenant safety net
At the API layer: SET LOCAL mortar.tenant_id = '<uuid>'; on every
tenant-scoped table an RLS policy enforces tenant_id = auth.tenant_id() (the helper reading
current_setting('mortar.tenant_id')). Forget the SET LOCAL → zero
rows returned (covered by tests, loud failure) — no silent
cross-tenant leakage.
3. JSONB row storage, not user-driven DDL
app_tables + app_columns are metadata; the real row data lives
in app_rows.data JSONB + GIN index. AI can
host().db.from('todos').insert({...}) without ops involvement.
Trade-offs in the original architecture.md lines 78–82.
4. SSE realtime, not WebSocket
SSE is one-directional (server → client) over plain HTTP. Mortar realtime’s only requirement is “push row changes”, which SSE matches perfectly + zero extra infra (HTTP/1.1 long poll) + passes through any proxy / CDN / corporate firewall (as long as HTTP is allowed).
Broadcast + presence ride SSE too — no WebSocket. Collaboration
that looks like it needs a bidirectional pipe (cursors / presence) is
split into “receive over SSE + send over HTTP POST”: the client receives
broadcast / presence diffs on GET /v1/{tenant}/realtime/channel/{channel},
sends a broadcast via POST .../broadcast and reports presence via
POST .../presence. WebSocket’s “bidirectional over one connection” is a
liability for us (sticky sessions / hand-rolled heartbeats / failing
through some proxies), whereas POST+SSE is naturally stateless — any
replica can serve any request, with fan-out over cache pub/sub. Writes
already go through POST, so no second write channel is needed.
5. ObjectStorage interface, LocalDisk default
storage.ObjectStorage has 4 methods (Put / Get / Sign /
Delete). Today LocalDisk writes MORTAR_STORAGE_LOCAL_ROOT + nginx
serves; planned AliyunOSS / TencentCOS / S3 impls satisfy the same
interface. Swap backend = change ENV.
6. Notify-driven realtime
Postgres trigger on app_rows INSERT/UPDATE/DELETE →
pg_notify('mortar_row_change', payload). The Mortar Go listener
LISTEN mortar_row_change → parses the payload → fans out to SSE
subscribers. Redis pub/sub is used only for cross-replica fan-out
(>1 Mortar process). See realtime multi-region.
Explicitly NOT doing
Edge Functions (arbitrary user code)→ planned viaprimitive/compute- Aliyun FC
Vector / embeddings / pgvector→ plannedrag-cookbookOAuth providers→ email + password only; planned WeChat / Apple IDMulti-region→ plannedmulti-regionAuto-scaling / k8s→ plannedWeb admin dashboard→ themortar/centerskeleton existsStripe / Alipay billing→ shipped, seepricing
References
- Milestones + ship schedule:
roadmap - API contract (authoritative routes):
openapi.yaml - Auth details:
auth - Billing + tiers:
pricing - Multi-region prep:
multi-region+realtime multi-region - Performance SLO:
performance SLO - Deployment / ops:
deployment+oncall playbooks