Machine-translated draft — terminology + flow still being reviewed.

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).

PrimitiveTypeWhat it isCloud bill line
computebaselinepool ECS instance time (per-tenant slice)ECS instance hours
databasebaselinePostgres slice (co-located with mortar binary on pool ECS)Folded into ECS hours (self-hosted PG) or RDS instance baseline
cachebaselineSelf-hosted Redis slot (co-located on pool ECS)Folded into ECS hours (self-hosted Redis)
storagemeteredObject storage GB-monthOSS / COS / S3 GB-month
networkmeteredOutbound bandwidth (egress)Public outbound GB (OSS request fees absorbed by Mortar)
functionmeteredUser FaaS CPU-minFC / SCF / Lambda CPU-min + GB-second
commsmeteredOutbound 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 ≠ slow database ≠ slow network), 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.md for the details.

5 features (user capabilities)

FeatureWhat the user can doComposed primitives
authSignup / login (email+password, phone-OTP) / API key issuing / email-verify / password-resetdatabase + comms (sms/email code delivery) + cache (OTP / token + send-side throttle)
dataTable + row CRUD + full-text searchdatabase + cache (query cache)
filesFile upload / download / signed URLsstorage + database (metadata)
realtimeSSE row-change subscription + broadcast + presencedatabase (LISTEN) + cache (pub/sub fan-out + presence roster)
usageUsage ledger + balancedatabase

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 over POST .../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.
  • 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.

FeatureOne-linerSwitch / default
cronPeriodic scheduling → compute RunnerDefault on; disable with MORTAR_CRON=off
customdomainProject-level custom domain + ACME TLS issuanceProject-scoped config, no ENV gate
queuePostgres-backed job queue + worker SDKMORTAR_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.

AudienceWhat shape they see
User SDK / docs9 capability modules on MortarClient (auth db storage realtime compute usage cron queue comms) + customDomains + tokens on AccountClient (control plane)
Internal code + billing7 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 in openapi.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 (writes MORTAR_STORAGE_LOCAL_ROOT on 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_URL at 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 to data/comms/<tenant>/outbox.jsonl (inspectable, no cloud deps).
  • Production: aliyun (Dysmsapi for SMS, reusing signACS3; DirectMail email TODO) / tencent (TODO).
  • env knob: MORTAR_COMMS_BACKEND=local|aliyun, plus MORTAR_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 itself
  • POST /v1/_accounts/tokens / GET / DELETE — account-scope long-lived PAT (mtr_pat_*) CRUD
  • POST /v1/{tenant}/auth/signup / signin / signout — AppUser (end user of AI bundle)
  • POST /v1/{tenant}/auth/phone/{start,verify} — phone-OTP login (composes comms.sms + cache)
  • POST /v1/{tenant}/auth/email/verify/{start,confirm} — email verification (composes comms.email + cache)
  • POST /v1/{tenant}/auth/password/reset/{start,confirm} — password reset (composes comms.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 via MORTAR_ACCOUNT_TOKEN env 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 admin
  • POST /v1/{tenant}/db/{table}/rows / GET / PATCH / DELETE — row CRUD
  • GET /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 URL
  • DELETE /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 broadcast
  • POST /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): NSBroker placeholder — 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).

DimensionTypePricingMetering
ComputebaselinePart of tier monthly feepool ECS time × vCPU share
DatabasebaselinePart of tier monthly feeself-hosted Postgres RAM/disk share on ECS
CachebaselinePart of tier monthly feeself-hosted Redis RAM share on ECS
Storagemetered¥0.15 / GB-monthOSS / COS bucket usage (above quota)
Networkmetered¥0.50 / GB egressPublic outbound (above quota)
Functionmetered¥0.05 / CPU-minFC / 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

ProviderRegionUse
WeChat PayDomesticWeChat QR / H5 / mini-program payments
AlipayDomesticAlipay QR / H5
StripeOverseasInternational 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 AppUserJWTSET 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

LayerTokenScopeUse
AccountAccountJWTOne Mortar accountControl plane (Mortar Center login)
ProjectAPIKey (mtr_live_*)One project (its tenant_id)Server-side SDK / CI
AppUserAppUserJWTOne project + one end userEnd 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:

  1. Internal architecture — what the Go binary cares about: cost dimensions + swappable cloud backends
  2. 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 write mortar.network.send().
  • cache: internal only (rate-limit + realtime fan-out + query cache). May later add mortar.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 (Tracker interface in internal/primitive/compute/compute.go).

Why auth + usage aren’t primitives

They’re features (capabilities composing primitives), not cost dimensions.

  • auth uses database (user rows) + network (egress on signin response), but isn’t itself a bill line.
  • usage is 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.

MortarFabric
RoleBackend-as-a-ServiceAI-powered IDE
Whose customerAppUser of AI bundlesDeveloper using the editor
Own backendOwn Postgres + Redis + OSSfabric-server Postgres + Redis + LocalFS
DB contentsAppUser signups / app data / filesUser 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/mortar plugin — 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 via primitive/compute
    • Aliyun FC
  • Vector / embeddings / pgvector → planned rag-cookbook
  • OAuth providers → email + password only; planned WeChat / Apple ID
  • Multi-region → planned multi-region
  • Auto-scaling / k8s → planned
  • Web admin dashboard → the mortar/center skeleton exists
  • Stripe / Alipay billing → shipped, see pricing

References