Mortar — architecture
Status: 已实装;公开 launch 进行中。这份文档跟随 代码演进;过往决策见 git history。
Mortar 是 Backend-as-a-Service:HTTP API 在前,Postgres + Redis + 对象存储 + SSE 在后。给国内 mobile / web app 用,单 VPS 起步, swap to 阿里云 RDS / OSS / 腾讯云 CDB / COS 不改一行代码。
跟 Supabase / Firebase 走同一种 shape,AI agent 在 chat 里看一眼就懂
—— 这些 API 形状在它训练数据里到处都是。客户端用 host 子域名寻址某个
project(https://<tenant>.api.mortar.appunvs.com/v1/{capability},即
Supabase 的 <ref>.supabase.co 模型);内部由 SubdomainTenant 中间件
把它折回 router 和后续整条栈所用的 /v1/{tenant}/{capability} 路径
(见 § “Per-project tenant_id 隔离”)。
设计原则
Mortar 内部走 7 primitive × (5 core feature + 3 extension feature)
两层结构。这是核心设计原则,是整个产品的脊椎。5 core:
auth / data / files / realtime / usage(已实装)。
3 extension:cron / customdomain / queue(规划中,
opt-in via env / flag)。
7 primitive(成本维度)
分两类计费:3 baseline(含在 tier 月费里)+ 4 metered(配额 + 超量计费 + 硬 cap)。
| Primitive | 类型 | 是什么 | 对应的云账单 |
|---|---|---|---|
compute | baseline | pool ECS 实例时间(per-tenant 切片) | ECS instance hours |
database | baseline | Postgres 切片(与 mortar binary co-located on pool ECS) | 折算进 ECS hours(self-hosted PG),或 RDS instance baseline |
cache | baseline | Self-hosted Redis 槽位(co-located on pool ECS) | 折算进 ECS hours(self-hosted Redis) |
storage | metered | 对象存储 GB-月 | OSS / COS / S3 storage GB-月 |
network | metered | 出向带宽(egress) | 公网下行 GB(OSS request 费 Mortar 替吃,不入账单) |
function | metered | 用户 FaaS 函数 CPU-min | FC / SCF / Lambda CPU-min + GB-second |
comms | metered | 出站事务消息(email + sms 两条 ledger resource,单价独立) | Aliyun DirectMail per-message + Aliyun SMS per-message(或 Tencent SES/SMS) |
7 个 primitive 同形于公有云账单的主类别。让自家成本结构跟云成本 结构对齐,意味着:
- 给客户的账单(7 个 cost dimensions)可以直接溯源到我们自己 付给云厂的钱
- 任何 primitive 都可以替换底层 backend(阿里云 → 腾讯云 → AWS)而不影响上层 feature
- 监控告警按 primitive 切(function 慢 ≠ database 慢 ≠ network 慢), on-call 不混淆
- baseline 与 metered 两类的 enforcement 不同:baseline 是 tier 选了
就预算固定;metered 三段(quota → overage → cap)。详见
pricing.md
5 feature(用户能力)
| Feature | 用户能干什么 | 组合了哪些 primitive |
|---|---|---|
auth | 用户注册 / 登录(email+password、phone-OTP) / API key / email-verify / password-reset | database + comms(sms / email 发码)+ cache(OTP / token + 发送侧限频) |
data | 表 + 行 CRUD + 全文检索 | database + cache(查询缓存) |
files | 文件上传 / 下载 / 签名 URL | storage + database(metadata) |
realtime | SSE 行变更订阅 + 广播 broadcast + 在线状态 presence | database(LISTEN)+ cache(pub/sub fan-out + presence roster) |
usage | 用量 ledger + 余额 | database |
每个 feature 组合多个 primitive 来实现一个用户能直接调的能力。 比如:
data= database + cache。行存 Postgres,热表查询走 Redis 缓存(TTL ~5s,写穿透 invalidate)。realtime= database + cache。三件套(对齐 Supabase Realtime):- 行变更 postgres_changes:Postgres trigger 发
pg_notify,Go listener 收到后通过 cache (Redis pub/sub) 跨副本 fan out 到所有 Mortar 实例的 SSE 订阅者。 - 广播 broadcast:客户端→客户端的瞬时消息(光标 / 拖拽中)。走
ChannelBroker,同样用 cache pub/sub fan out;收走 SSE、发走POST .../broadcast(保持单 SSE 传输,不引入 WebSocket)。 - 在线状态 presence:谁在线 + 各自的瞬时 state(光标位置 / 选区 /
颜色)。roster 存 cache KV(单进程则用进程内 store),TTL 由 SSE
连接的心跳续期、断连即清并广播 leave。
这三者让 Mortar 能直接撑住协作型 UI(如简版 Figma)。详见
realtime-multi-region.md。
- 行变更 postgres_changes:Postgres trigger 发
files= storage + database。文件 blob 在 OSS / COS / LocalDisk 里,metadata(owner / mime / size / created_at)在 Postgres 里。
Extension features(规划中)
5 core feature 之外,规划了 3 个 opt-in extension feature。每个
都是一个独立 Go 包(internal/feature/<name>/),可单独开关,不影响
core 路径。
| Feature | 一句话 | 开关 / 默认 |
|---|---|---|
cron | 周期任务调度 → compute Runner | 默认 on;MORTAR_CRON=off 关闭 |
customdomain | 项目自定义域名 + ACME TLS 证书签发 | 项目级配置,无 ENV gate |
queue | Postgres-backed job queue + worker SDK | MORTAR_QUEUE_BACKEND=<postgres|off>(默认 postgres) |
为什么把两层分开
跟 marketing / SDK 用户看到的 shape 故意不一样。
| 看 Mortar 的角度 | 用什么 shape |
|---|---|
| 用户 SDK / docs | 9 capability modules on MortarClient(auth db storage realtime compute usage cron queue comms)+ customDomains + tokens on AccountClient(控制平面) |
| 内部代码 + 账单 | 7 primitive × (5 core + 3 extension) feature |
| Marketing 文案 | ”7 cost dimensions on your bill: 3 baseline + 4 metered” |
跨语言 SDK 状态:TS SDK 是 reference 实现,覆盖全部 8 modules
- customDomains。Swift / Kotlin / Dart SDK 当前只覆盖原始 6 个 (
auth/db/storage/realtime/compute/usage)+account;规划中的cron/queue/customDomains尚未跨语 言铺开。需要时按 TS 实现照抄即可——HTTP 接口已稳定,schema 见openapi.yaml。
这是两个正交关注点:
- Feature shape 给用户:他们问”怎么上传文件?“,找
mortar.storage.upload(),不关心底下走哪些 primitive。 - Primitive shape 给账单 + 运维:每个 primitive = 一条云账单 = 一份用量监控 = 一个可替换的 backend。
试图把两层强行合并 = 两边都难受。保留两套 schema,每个受众读 对的那套。详细论证见 § “SDK schema vs internal schema”。
架构图
┌──────────────────────────────────────────────┐
│ 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 │
│ 当前: 单 VPS(local-redis + LocalDisk + │
│ single Postgres) │
│ 规划中: 阿里云 RDS / OSS / FC / Tair │
│ 腾讯云 CDB / COS / SCF │
│ 规划中: 跨 region(cn-hangzhou / cn-shenzhen)│
└───────────────────────────────────────────────┘
控制平面(账户 / 项目生命周期 / 计费)走 /v1/_accounts/*,跟
/v1/{tenant}/* 数据平面分流。控制平面入口是 mortar CLI(@mortar/cli),
走 AccountJWT 鉴权;end-user app 流量走 APIKey 鉴权。两套互不串。
Per-primitive
每个 primitive 一个 Go 包,定义一个 interface + 多个 backend impl。
代码路径:mortar/internal/primitive/<name>/。
primitive/compute
是什么:用户函数执行 runtime。POST /v1/{tenant}/compute/{fn_name}
拿一段 JS / TS 代码加 input payload,返回 output。
Backend 选项:
- 当前: 本地 Node child-process(dev only,不可生产)
- 规划中: 阿里云 FC(Function Compute)—— 国内首选
- 规划中: 腾讯云 SCF(Serverless Cloud Function)—— 阿里云容灾备份
- 规划中: AWS Lambda(海外用户)
何时用谁:production 默认阿里云 FC(v3 region 覆盖最广 + 冷启动 最快);海外 region 走 Lambda;test 走本地 child-process。
关键文件:mortar/internal/primitive/compute/compute.go。
primitive/database
是什么:关系数据存储。Mortar 用的是 Postgres(不是 SQLite),
所有 tenant-scoped 表走 RLS 隔离(SET LOCAL mortar.tenant_id = '<uuid>')。
Backend 选项:
- 当前: 本地 Postgres 15 单实例
- 规划中: 阿里云 RDS PostgreSQL
- 规划中: 腾讯云 CDB PostgreSQL
- 规划中: Neon serverless Postgres(海外按需)
- 规划中: 跨 region read replicas(详见
read-replicas.md)
何时用谁:production 走 RDS;海外走 Neon;CI / dev 走本地 Postgres docker container。
关键文件:mortar/internal/primitive/database/database.go(pgx
pool wrapper)+ setup.go(schema migration)+
verify_rls_integration_test.go(RLS 隔离的 contract test)。
primitive/network
是什么:出向带宽 metering。不是用户能直接调的 API —— 它是每
个其他 primitive 的副作用。我们 wrap http.ResponseWriter 统计
bytes-out,再加上 OSS access log 把 egress 算到 project 头上。
Backend 选项:
- 当前: response-writer wrapping(in-process counter)+ Postgres buffer
- 规划中: OSS access-log 拉日志 + 离线对账
- 规划中: 阿里云 CDN egress 直接报量(real-time)
何时用谁:所有 region 都用 response-writer wrapping(cheap + 准确);OSS / CDN egress 单独拉云账单对账。
关键文件:mortar/internal/primitive/network/network.go。
adapter 在 mortar/internal/feature/usage/network_adapter.go +
network_buffered.go(buffered counter)。
primitive/storage
是什么:对象存储抽象。ObjectStorage interface 4 个方法
(Put / Get / Sign / Delete)。
Backend 选项:
- 当前:
LocalDisk(写MORTAR_STORAGE_LOCAL_ROOT磁盘 + nginx serve) - 规划中: 阿里云 OSS(国内首选)
- 规划中: 腾讯云 COS
- 规划中: AWS S3(海外)
- 规划中: Cloudflare R2(海外,零 egress 费)
何时用谁:production 国内走阿里云 OSS(生态最完整);海外走 R2 (零 egress 友好);dev / CI 走 LocalDisk。
关键文件:mortar/internal/primitive/storage/storage.go。
primitive/cache
是什么:KV + pub/sub。Mortar 内部用来 (a) rate-limit 计数 (b)
realtime 跨副本 fan-out (c) 查询缓存。当前内部使用,不暴露给
SDK;规划中考虑加 mortar.cache.{get,set,publish} 给用户。
Backend 选项:
- 生产默认:self-hosted Redis 跟 Postgres + mortar binary 跑在同一 台 pool ECS 上,¥0 边际成本——这是 Mortar 多租户 cache 摊薄的前提
- Dev / tests:in-process map (
backend/local) - managed 备选(阿里云 Tair / 腾讯云 Redis / AWS ElastiCache / Upstash):
同 Redis 协议,把
MORTAR_CACHE_REDIS_URL指过去就行。但 ~¥100-150/GB-月 直接破坏多租户摊薄,仅 dedicated tier 或 BYOC 才用。 专属 cluster-mode / IAM backend 不内置——客户真要再加。
关键文件:mortar/internal/primitive/cache/cache.go。
primitive/comms
是什么:出站事务消息(email + sms 两条 ledger resource,单价 独立)。一个 primitive、按渠道两条计费维度——比 “email/sms 各自独立 primitive” 简洁,比 “塞进 network” 真实(vendor 账单确实分开)。
Backend 选项:
- Dev:
local(写到data/comms/<tenant>/outbox.jsonl,可 inspect、零云依赖) - 生产:
aliyun(Dysmsapi for SMS,复用signACS3签名; DirectMail email TODO)/tencent(TODO) - env knob:
MORTAR_COMMS_BACKEND=local|aliyun,加MORTAR_COMMS_ ALIYUN_{ACCESS_KEY_ID,ACCESS_KEY_SECRET,SIGN_NAME,ENDPOINT}
计费:每条 message 按渠道写一条 credit_ledger 行,resource =
email 或 sms,单价 YuanPerEmail / YuanPerSMS。
关键文件:mortar/internal/primitive/comms/comms.go +
mortar/internal/backend/{local,aliyun}/comms.go。
Per-feature
每个 feature 一个 Go 包,组合多个 primitive 实现一个 user-facing
能力。代码路径:mortar/internal/feature/<name>/。
feature/auth
HTTP 路由:
POST /v1/_accounts/signup/signin/me— Mortar 账号自身POST /v1/_accounts/tokens/GET/DELETE— 账号级长效 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 登录 (composescomms.sms+cache)POST /v1/{tenant}/auth/email/verify/{start,confirm}— 邮箱验证 (composescomms.email+cache)POST /v1/{tenant}/auth/password/reset/{start,confirm}— 密码找回 (composescomms.email+cache)POST /v1/{tenant}/_admin/keys— mint API key
组合:database(存 mortar_accounts + account_keys(PAT)+
app_users + api_keys)+ comms(OTP/token 发送)+ cache
(OTP/token 存储 + 发送侧限频 cooldown / daily cap)。
鉴权 token 类型:
- Account JWT —— Mortar 账户登录,scope
/v1/_accounts/* - Account PAT —— 长效
mtr_pat_*,与 Account JWT 同 scope, 支持MORTAR_ACCOUNT_TOKENenv 用于 CI / headless - API Key —— project-scope,前缀
mtr_live_/mtr_test_ - AppUser JWT —— end-user-of-AI-bundle 登录,scope
/v1/{tenant}/{auth,data,files,realtime,compute,comms}/*
关键文件:mortar/internal/feature/auth/{apikey,jwt,middleware, password,users_dao,apikeys_dao}.go + JWT keys cache
keys_cache.go。详见 auth.md。
feature/data
HTTP 路由:
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=...— 全文检索
组合:database(行存 + 元数据)+ cache(查询缓存,规划中)。
Schema 设计:用户”表”是 app_tables + app_columns 元数据;
真实行在 app_rows.data JSONB 加 GIN 索引。AI 创建新表不需要 ops
介入(无 ALTER TABLE)。tradeoff 详见 §“key design decisions”。
关键文件:mortar/internal/feature/data/{tables_dao,rows_dao, fts,models}.go。
feature/files
HTTP 路由:
POST /v1/{tenant}/storage/files?bucket=&key=— 上传(raw body)GET /v1/{tenant}/storage/files/{bucket}/{key...}— 下载或 redirect to 签名 URLDELETE /v1/{tenant}/storage/files/{bucket}/{key...}GET /v1/{tenant}/storage/sign/{bucket}/{key...}— mint signed URL
组合:storage(blob)+ database(metadata 行)。
关键文件:mortar/internal/feature/files/{files_dao,models}.go。
feature/realtime
HTTP 路由(对齐 Supabase Realtime 三件套):
GET /v1/{tenant}/realtime/{table}— SSE 长连,server 推行变更事件 (postgres_changes)GET /v1/{tenant}/realtime/channel/{channel}— SSE 长连,收 broadcast + presence;?ref=&key=把本连接绑定到一个 presence 成员(断连即 leave)POST /v1/{tenant}/realtime/channel/{channel}/broadcast— 发广播POST /v1/{tenant}/realtime/channel/{channel}/presence— 上报 / 更新 presence state(track)DELETE /v1/{tenant}/realtime/channel/{channel}/presence?ref=— 显式 离开(untrack;SSE 断连也会自动 untrack)
组合:database(LISTEN mortar_row_change)+ cache(Redis
pub/sub 跨副本 fan-out + presence roster KV)。
两层 broker 设计(行变更 + channel 各一套,结构对称):
- 单进程:
InMemoryBroker/InMemoryChannelBroker+InMemoryPresenceStore - 多副本:
RedisBroker(pub/sub) /CacheChannelBroker+CachePresenceStore—— 跨副本 fan-out via cache pub/sub;presence roster 走 cache KV - 跨 region(规划中):
NSBroker占位,详见realtime-multi-region.md
关键文件:mortar/internal/feature/realtime/{realtime,listener, inmemory,redis}.go。
feature/usage
HTTP 路由:
GET /v1/{tenant}/usage/me— 当前 project 余额 + 用量 + tier cap
组合:database(ledger 行)。本身是只读 + 写 ledger,不
是 user-facing cost dimension。
Ledger 模型:每次 primitive 使用 → 直接追加一行
credit_ledger((tenant_id, primitive, qty, yuan, ts))。Credit 余
额 = 充值总额 - ledger 累计成本。(当前没有 usage_event /
usage_ledger 两表分层 —— 只有 credit_ledger。)
关键文件:mortar/internal/feature/usage/{ledger,cost,ticker, noisy_neighbor}.go。Noisy-neighbor detection(一个 project 占用
率突增)见 noisy_neighbor.go。
计费模型
Credit ledger
每次 metered primitive 使用 → credit_ledger 行追加。Ticker
(feature/usage/ticker.go)every 1h by default (override via
MORTAR_USAGE_TICKER) 聚合 tick-driven resources(tier baseline 累计
- storage at rest) + 扣减 project credit balance。Balance ≤ 0 触发 plan-gate 拒绝写(读仍允许,给用户机会充值)。Cache 现在纯 baseline, 不进 ledger(LRU evict 即可)。
6 cost dimensions
3 baseline(含在 tier 月费里)+ 3 metered(quota → overage → cap)。
| 维度 | 类型 | 计价 | 计量 |
|---|---|---|---|
| Compute | baseline | tier 月费的一部分 | pool ECS 时间 × vCPU 切片 |
| Database | baseline | tier 月费的一部分 | self-hosted Postgres 占 ECS RAM/disk |
| Cache | baseline | tier 月费的一部分 | self-hosted Redis 占 ECS RAM |
| Storage | metered | ¥0.15 / GB-月 | OSS / COS bucket 用量(超 quota) |
| Network | metered | ¥0.50 / GB egress | 公网下行(超 quota) |
| Function | metered | ¥0.05 / CPU-min | FC / SCF / Lambda 执行时长(超 quota) |
每月账单展示 6 行,每一行对得上我们付给云厂的钱。透明 + 可
审计。详见 pricing.md。
Plan gate(Tier 1-5)
每个 project 选一个 Tier(1=hobby / 2=starter / 3=pro / 4=growth / 5=enterprise)。Tier 决定:
- 每月免费额度
- 单一 primitive 的硬上限(防 noisy neighbor)
- 高级 feature 开关(规划中增量,如
cron等)
Plan gate 中间件在 internal/plan/gate.go,每个
endpoint 都过;超 tier cap 返回 402 Payment Required。
支付 providers
| Provider | 区域 | 用途 |
|---|---|---|
| WeChat Pay | 国内 | 微信扫码 / H5 / 小程序支付 |
| Alipay | 国内 | 支付宝扫码 / H5 |
| Stripe | 海外 | 国际信用卡 / Apple Pay / Google Pay |
实装:mortar/internal/backend/{wechatpay,alipay}/(Stripe 规划中)。
Webhook 端点 /v1/_webhooks/payment/{provider},回调签名校验后 →
ledger credit +amount 行。详见 pricing.md。
控制平面入口:mortar CLI
它在哪
代码路径:mortar/cli/。Node 实现,发布为 @mortar/cli。
唯一的控制平面客户端 —— CLI 优先是因为面向 AI 的产品需要可脚本化、
可被 agent 直接调用,不依赖浏览器。Web admin UI 暂不存在;如果以后
有需求,要么生成 GitHub-style 静态 dashboard,要么走 mortar projects info
这类 CLI 命令。
mortar/cli/
├── src/
│ ├── commands/ ← projects / keys / cron / queues / realtime / functions
│ ├── lib/ ← AccountClient + project APIKey 包装
│ └── config.ts ← ~/.mortar/config.json (mode 0600)
└── package.json
它怎么跟 HTTP API 交互
跟 end-user 流量完全分流:
mortarCLI 走AccountJWT(mortar login拿的),调/v1/_accounts/*+/v1/_projects/*+/v1/_webhooks/payment/*控制 平面 API- End-user app 流量走
APIKey(项目内 mint 的),调/v1/{tenant}/{auth,data,files,realtime,compute}/*数据平面 API
两条路径走同一份 Go binary 但不同 middleware 链。控制平面挂
accountJWTMiddleware;数据平面挂 apikeyMiddleware +
tenantMiddleware + rlsSetLocalMiddleware。
CLI 不需要单独部署 —— 装在用户机器 / CI runner / agent 容器里,直接
调 api.mortar.appunvs.com。
多租户
Per-project tenant_id 隔离
tenant_id 是数据隔离边界 key,其值就是 project 的 UUID(project =
tenant)。所有 tenant-scoped Postgres 表都有 tenant_id UUID NOT NULL 列 + RLS 策略:
CREATE POLICY tenant_isolation ON app_rows
USING (tenant_id = auth.tenant_id());
auth.tenant_id() 是 Mortar 版的 Supabase auth.uid():一个
STABLE helper,读 current_setting('mortar.tenant_id') GUC。policy
全部用它而不是内联 current_setting,可读性更好,且迁移自 Supabase
的项目可以把 auth.uid() 形状的 policy 直接映射过来(只是隔离粒度是
project tenant_id,不是 end-user)。定义见
internal/primitive/database/setup.go 的 rlsAndTriggers。
请求进来 → tenant middleware 从 APIKey 或 AppUserJWT 解出
tenant_id → SET LOCAL mortar.tenant_id = '<uuid>' → auth.tenant_id()
随之返回该 UUID → 之后所有 query 自动按 tenant_id filter。忘了 SET
LOCAL = 零行返回(不是漏数据,是噪声明显),单测覆盖每个 endpoint
的 SET LOCAL 调用。
tenant 在 wire 上从哪来:客户端把它放进 host 子域名
(https://<tenant>.api.mortar.appunvs.com,dev 为
http://<tenant>.localhost:8080),调的是 tenant-less 路径
(/v1/auth/signin、/v1/db/tables/...、/v1/storage/...)。一个
SubdomainTenant 中间件读取最左侧 host label,在 router 之前把
/v1/<capability>/… 改写成 /v1/<tenant>/<capability>/…,所以下游的
router、tenant middleware、handler、egress 计量全都照旧在内部
/v1/{tenant}/… 形状上工作,不受影响。base domain 由
MORTAR_TENANT_BASE_DOMAINS 驱动(默认
api.mortar.appunvs.com,localhost);泛域名 TLS 是运维侧的事(Caddy
on-demand TLS),Mortar 不签发证书。旧的 path-based 调用(直接打
apex host 的 /v1/{tenant}/…)依然有效,向后兼容。Account-scope 路由
(/v1/_accounts/*)始终留在 apex host 上、始终 tenant-less。
JWT 3 层 scope
| 层 | Token | Scope | 用途 |
|---|---|---|---|
| Account | AccountJWT | 一个 Mortar 账户 | 控制平面(mortar login 登录) |
| Project | APIKey (mtr_live_*) | 一个 project(其 tenant_id) | 服务器端 SDK / CI |
| AppUser | AppUserJWT | 一个 project + 一个 end user | mobile / web app 里的最终用户 |
3 层中间件分别校验,不可互替(Account 不能调 /v1/{tenant}/db,APIKey
不能调 /v1/_accounts/me)。详见 auth.md。
SDK schema vs internal schema {#sdk-schema-vs-internal-schema}
Mortar 有两套并行的代码组织方式,描述同一份产品:
- 内部架构 —— Go binary 关心的:成本维度 + 可替换 cloud backend
- SDK / API surface —— 用户关心的:用哪个 capability
两者不是同形。不同关注点 → 不同分组。强行合并 → 两边都难看。
内部:7 primitive + (5 core + 3 extension) feature
见上文 § “设计原则”。
SDK:12 capability 模块(mirrors HTTP paths)
下面的 /v1/{tenant}/… 是内部路由形状。在 wire 上 MortarClient
用 createClient({ url, apiKey }) 配置(没有 tenant 选项)——tenant
跑在 host 子域名里(https://<tenant>.api.mortar.appunvs.com),SDK 打的
是 tenant-less 形式(/v1/auth/*、/v1/db/*……);由 SubdomainTenant
中间件折回 /v1/{tenant}/…。AccountClient 始终打 tenant-less 的 apex
host(/v1/_accounts/*)。
@mortar-ai/client
├── MortarClient (project tenant,API-key auth)
│ ├── auth ─→ /v1/{tenant}/auth/* (AppUser 注册/登录 + phone-OTP + email verify + 密码找回)
│ ├── db ─→ /v1/{tenant}/db/* (表 + 行 CRUD)
│ ├── storage ─→ /v1/{tenant}/storage/* (文件上传/下载)
│ ├── realtime ─→ /v1/{tenant}/realtime/{table} + /realtime/channel/{channel} (SSE:行变更 + broadcast + presence)
│ ├── compute ─→ /v1/{tenant}/compute/* (函数 deploy / invoke)
│ ├── usage ─→ /v1/{tenant}/usage/me (dashboard)
│ ├── cron ─→ /v1/{tenant}/cron/schedules (定时函数)
│ ├── queue ─→ /v1/{tenant}/queue/jobs (后台任务)
│ └── comms ─→ /v1/{tenant}/comms/send (email + sms 出站)
└── AccountClient (account-scope JWT 或 PAT,控制平面)
├── projects ─→ /v1/_accounts/projects/*
├── customDomains ─→ /v1/_accounts/projects/{id}/domains/*
└── tokens ─→ /v1/_accounts/tokens/* (`mtr_pat_*` PAT)
db 域细分两个 accessor —— mortar.from(table)(hot path row
CRUD)+ mortar.tables.{...}(cold path table admin)。所以
MortarClient 共 13 个 accessor / 12 个 domain。customDomains /
tokens 走 AccountClient 因为路由在 _accounts 下(账号 JWT 或
PAT,不是项目 API key)。
为什么三个 primitive 不出现在 SDK
network:不是用户能调的。是所有其他调用的副作用。我们 wrap response-writer 计 bytes-out,用户从不写mortar.network.send()。cache:当前内部用(rate-limit + realtime fan-out + 查询缓 存)。规划中考虑加mortar.cache.{get,set,publish}给用户。compute:是 ECS instance 时间分摊,没运行时 API 可调。 现身于 tier baseline 月费 + noisy-neighbor monitor 的 per-tenant CPU 归因(internal/primitive/compute/compute.go的Trackerinterface)。
为什么 auth + usage 不是 primitive
它们是 feature(组合 primitive 的能力),不是 cost dimension。
auth用 database(存 user 行)+ network(egress on signin response),但自己不是一条账单线。usage是只读 ledger meta-layer,本身不产生 billable work。
跨产品定位
Mortar vs Fabric
核心区别:Mortar 是给 AI-generated app 的 backend;Appunvs AI Editor 是生成 app 的编辑器。
| Mortar | Fabric | |
|---|---|---|
| 角色 | Backend-as-a-Service | AI-powered IDE |
| 谁的客户 | AI bundle 的 end users(AppUser) | 编辑器的开发者用户 |
| 自己用什么 backend | 自己的 Postgres + Redis + OSS | fabric-server 的 Postgres + Redis + LocalFS |
| 数据库里有什么 | AppUser 注册 / app data / files | User 账号 / project metadata / ai_turns / 构建产物 |
⚠️ Fabric 自己不跑在 Mortar 上。User accounts / project metadata / ai_turns / project builds / bundle storage 都在 fabric-server 自己的 Postgres + Redis + LocalFS 里。同理,AI 构建器 自己的编辑器也不跑在它对外提供的 BaaS 上。只有 AI bundle 自己的 end-user-facing data 走 Mortar(via host bridge)。
详见 fabric/docs/architecture.md §
“Fabric 自身基础设施”。
Mortar vs Keel
完全独立。Keel works with 任何 backend(Supabase / Firebase / 自家 / Mortar);Mortar works with 任何 runtime(Keel / Expo / RN 裸 / web / Flutter / native)。
- 没有
@keel-ai/mortarplugin —— 用户接 Mortar 用标准@mortar-ai/client,跟接 Supabase 用@supabase/supabase-js同一种方式 - Mortar 文档不提 Keel;Keel 文档把 Mortar 当”众多 BaaS 选项之 一”提一句
为什么:让两边各自能独立卖给非交叉客户。Mortar 的 ICP 是”想要 国内合规 BaaS 的开发者”,Keel 的 ICP 是”想要国内 RN pipeline 的开 发者”,这两群人有交集但不必绑定。
详见 keel/docs/architecture.md §
“跟 Mortar 的关系”。
Key design decisions(回顾)
1. Postgres from day 1(不是 SQLite)
SQLite 适合单 binary 本地 app。Mortar 是 service,最终走 managed Postgres(阿里云 RDS / Neon / Supabase postgres)。SQLite 起步 = 之后 schema + driver rewrite 痛苦。前期多扛一点 dev-loop 开销。
2. RLS 是唯一多租户安全网
API 层 SET LOCAL mortar.tenant_id = '<uuid>',RLS 策略在每个
tenant-scoped 表上强制 tenant_id = current_setting('mortar.tenant_id')。
忘 SET LOCAL → 零行返回,单测覆盖、噪声明显,不会静默泄露跨租户
数据。
3. JSONB 行存,不是用户驱动 DDL
app_tables + app_columns 是元数据;行真实数据在 app_rows.data JSONB + GIN 索引。AI 能 host().db.from('todos').insert({...})
不需要 ops 介入。tradeoff 表见旧 architecture.md 第 78-82 行。
4. SSE realtime,不是 WebSocket
SSE 单向(server → client)over plain HTTP。Mortar realtime 需求 就是”行变更推送”,SSE 完美匹配 + 0 额外 infra(HTTP/1.1 long poll)+ 过任何代理 / CDN / 公司防火墙(只要允许 HTTP)。
broadcast + presence 也照样走 SSE,不引入 WebSocket。 看似需要双向
的协作(光标 / presence),我们拆成「收走 SSE + 发走 HTTP POST」:
客户端在 GET /v1/{tenant}/realtime/channel/{channel} 上收 broadcast /
presence diff,发广播走 POST .../broadcast、上报 presence 走
POST .../presence。WebSocket 买的”同一条连接双向”对我们是负担(要 sticky
session / 自建心跳 / 过不了某些代理),而 POST+SSE 天然无状态——任何副本
都能服务任何请求,fan-out 走 cache pub/sub。写已经走 POST,不需要第二个
写 channel。
5. ObjectStorage interface,LocalDisk default
storage.ObjectStorage 4 个方法(Put / Get / Sign /
Delete)。当前 LocalDisk 写 MORTAR_STORAGE_LOCAL_ROOT + nginx
serve;规划中 AliyunOSS / TencentCOS / S3 impl 同接口。换 backend
= 改 ENV。
6. Notify-driven realtime
Postgres trigger on app_rows INSERT/UPDATE/DELETE →
pg_notify('mortar_row_change', payload)。Mortar Go listener
LISTEN mortar_row_change → 解 payload → fan out 到 SSE
subscribers。Redis pub/sub 仅 用于跨副本(>1 个 Mortar 进程)
fan out。详见 realtime-multi-region.md。
暂不做的(已明确)
Edge Functions(用户任意代码)→ 规划中通过primitive/compute- 阿里云 FC 加
Vector / embeddings / pgvector→ 规划中rag-cookbook.mdOAuth providers→ email + password only;规划中加微信 / Apple IDMulti-region→ 规划中multi-region.mdAuto-scaling / k8s→ 规划中Web admin dashboard→ 永久不做;控制平面入口是mortarCLI (mortar/cli/)。面向 AI 的产品优先 CLI 先,浏览器 UI 不 是必需路径Stripe / Alipay billing→ 已有,详见pricing.md
参考
- 里程碑 + ship schedule:
roadmap.md - API 合同(authoritative routes):
openapi.yaml - Auth 细节:
auth.md - 计费 + 套餐:
pricing.md - 多 region 准备:
multi-region.md+realtime-multi-region.md - 性能 SLO:
performance-slo.md - 部署 / 运维:
deployment.md+oncall-playbooks.md