Mortar — AI cookbook
This doc is meant to be fed to AI coding assistants (Cursor / Claude Code / Cline / etc.) as part of their context, so they can generate working Mortar-using code without trial-and-error.
If you’re an AI: when generating code that uses Mortar, copy the patterns below. Don’t invent endpoints — if a pattern isn’t here, ask the user instead of guessing.
If you’re a human: paste this file into your AI tool’s “context” / “rules” / “knowledge base”, whatever it calls the always-on preamble. Refresh occasionally as Mortar evolves.
What Mortar is
Mortar is a Backend-as-a-Service, similar in shape to Supabase / Firebase but optimised for mainland China. Each developer signs up for a Mortar account, then creates one or more projects. Each project’s UUID doubles as its tenant_id — the data-isolation boundary key (project = the tenant).
One TypeScript package, two entry points:
@mortar-ai/client— for app code (mobile / web / server). Uses a project API key + tenant_id. The surface is supabase-js compatible: data / auth / storage calls resolve a{ data, error }envelope and never throw — always destructure{ data, error }and checkerror(do NOT usetry/catch).@mortar-ai/client/account— subpath export of the same package for control-plane code (CI, admin tools). Uses an account JWT instead.
There’s also a CLI, npx @mortar-ai/cli, that wraps the SDK for
human + AI use. Every command supports --json for AI-friendly
output.
Setup (every example assumes this prelude)
import { createClient } from '@mortar-ai/client';
const mortar = createClient({
// tenant 在 host 子域名里(Supabase 的 <ref>.supabase.co 模型)——没有单独的 tenant 选项
url: process.env.MORTAR_URL!, // https://<tenant>.api.mortar.appunvs.com
apiKey: process.env.MORTAR_API_KEY!, // mtr_live_...
});
CLI alternative — once mortar login + mortar use <project-id>
are done:
mortar projects list --json
Pattern 1 — Sign up an end-user, save their session
// supabase-js shape: resolves `{ data: { user, session }, error }` — never throws.
const { data, error } = await mortar.auth.signUp({
email: 'alice@example.com',
password: 'hunter2-strong-please',
});
if (error) {
// e.g. error.code '409' (email taken) / '400' (password too short)
return showError(error.message);
}
// The session is persisted + auto-refreshed by the SDK, and attached to
// every subsequent call — you don't pass a token around by hand.
const { user, session } = data;
CLI version — there’s no mortar auth because end-user auth is
mobile/web app concern, not control-plane. Use the HTTP API
directly when scripting:
curl -X POST https://${MORTAR_TENANT}.api.mortar.appunvs.com/v1/auth/signup \
-H "Authorization: Bearer ${MORTAR_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"hunter2-strong-please"}'
Errors:
409 email_taken— try sign-in instead400 password_too_short— Mortar requires ≥ 8 characters
Pattern 2 — Insert + read back a row
No
tables.createfrom app code. Tables are schemaless JSONB collections, created implicitly on first insert — there is no DDL / migration step in app code. Non-default setup (access level, indexes, required columns) goes in amortar.config.jsonmanifest at the project root, which the platform applies with an admin key (see Mortar’smortar.config.jsondocs).mortar.tables.create()is app/admin scope and 403s on the bundle’s public key — never call it here.
// 1. Insert — the `todos` table is auto-created on this first insert.
// Chain `.select().single()` to get the created row back; resolves
// `{ data, error }` (never throws). Rows come back FLAT: `row.title`,
// `row.id` — there is no `row.data` wrapper.
const { data: row, error } = await mortar.from('todos')
.insert({ title: 'buy milk', done: false })
.select()
.single();
if (error) return showError(error.message);
console.log(row.id, row.title);
// 2. List, newest first
const { data: recent, error: listErr } = await mortar.from('todos')
.select('*')
.order('created_at', { ascending: false })
.limit(10);
if (listErr) return showError(listErr.message);
// 3. One row by id
const { data: one } = await mortar.from('todos').select('*').eq('id', row.id).single();
CLI:
mortar db rows insert todos --data='{"title":"buy milk","done":false}' --json
mortar db rows list todos --limit=10 --json
Pattern 3 — Subscribe to realtime changes
// supabase-js channel shape. `payload.eventType` is "INSERT" | "UPDATE" |
// "DELETE"; `payload.new` / `payload.old` carry the flat row.
const channel = mortar
.channel('todos-room')
.on('postgres_changes', { event: '*', table: 'todos' }, (payload) => {
console.log(payload.eventType, payload.new);
})
.subscribe();
// Tear down on unmount / signout
mortar.removeChannel(channel);
Notes for AI:
- Subscriptions are one-direction (server → client). Writes go
through the normal
from(...).insert/update/delete()calls. - Mortar’s SSE drops events on slow consumers (matches Postgres
NOTIFYsemantics). Treat realtime as “advisory” — refetch authoritative state on reconnect.
Pattern 4 — Upload + serve a file
// supabase-js storage shape — `mortar.storage.from(bucket).upload(path, body)`.
// Every async method resolves `{ data, error }` and never throws.
const key = `users/${userId}.jpg`;
const { data: up, error } = await mortar.storage.from('avatars').upload(key, file);
if (error) return showError(error.message);
console.log('uploaded', up.path);
// A signed URL for temporary access (TTL in seconds):
const { data: signed } = await mortar.storage.from('avatars').createSignedUrl(key, 3600);
// …or a stable public URL (for public buckets) — synchronous, like supabase-js:
const { data: pub } = mortar.storage.from('avatars').getPublicUrl(key);
console.log(signed?.signedUrl, pub.publicUrl);
CLI:
mortar storage upload avatars users/alice.jpg ./alice.jpg --content-type=image/jpeg
mortar storage download avatars users/alice.jpg ./out.jpg
Re-uploading the same (bucket, key) overwrites — same convention
as S3 / OSS.
Pattern 5 — Check current credit balance (UI / scripts)
const u = await mortar.usage.me();
console.log(`tier: ${u.tier}`);
console.log(`balance: ¥${u.credit.balance_yuan} / ¥${u.credit.granted_yuan}`);
console.log(`storage cap: ${u.tier_caps.storage_gb} GB`);
CLI:
mortar usage me
Sample output:
project_id: abc-123
billing_period: 2026-05
tier: tiny
credit_mode: hard_budget
credit:
granted_yuan: 99
used_yuan: 14.5
balance_yuan: 84.5
used_by_resource:
network: 8.2
compute: 3.1
database_baseline: 3.2
tier_caps:
db_ram_gb: 1
db_disk_gb: 10
storage_gb: 10
network_gb_per_month: 25
Pattern 6 — Handle the structured 5xx errors
Mortar returns these high-signal status codes; your code should branch on them:
| Status | error | When |
|---|---|---|
| 401 | unauthorized | Bad / missing API key or end-user JWT |
| 403 | scope_insufficient | API key has the wrong scope (e.g. public-scope trying to write) |
| 403 | tenant_mismatch | API key is for a different project than the URL |
| 409 | tier_change_would_overflow | Downgrade target is smaller than current usage |
| 429 | rate_limit_exceeded | Slow down; honour Retry-After |
| 402 | credit_exhausted | credit + top-up exhausted; mode-dependent path forward |
| 503 | database_busy | Connection pool full; retry after a beat |
Note: per-tier capacity-cap enforcement (
tier_capacity_exhausted, 503/507) is on the roadmap (Phase W7.c). Until shipped, exceeding a tier limit silently overruns and shows up in usage analytics only.
Data / auth / storage calls never throw — they resolve { data, error },
where error is the supabase-js error shape ({ message, code, details, hint }) and error.code carries the HTTP status as a string. Branch on
error.code, don’t wrap in try/catch:
const { data, error } = await mortar.from('todos').insert({ title: 'x' }).select();
if (error) {
if (error.code === '402') { // credit_exhausted
return promptTopUp();
}
if (error.code === '429') { // rate_limit_exceeded — Retry-After in seconds
return scheduleRetry();
}
return showError(error.message);
}
Pattern 7 — Programmatically create a project (CI / control plane)
import { createAccountClient } from '@mortar-ai/client/account';
const account = createAccountClient({ url: 'https://api.mortar.appunvs.com' });
await account.signIn({
email: process.env.MORTAR_ACCOUNT_EMAIL!,
password: process.env.MORTAR_ACCOUNT_PASSWORD!,
});
const proj = await account.projects.create({
name: 'staging',
tier: 'mini',
credit_mode: 'manual_top_up',
});
console.log('project id:', proj.id);
// Now use this id as the tenant_id + mint an API key via
// `mortar keys create` (or POST /v1/{tenant}/_admin/keys with an admin
// scope key created at signup time).
CLI:
mortar login --email me@example.com
mortar projects create --name=staging --tier=mini --credit-mode=manual_top_up --json
# the new project becomes the "current" one automatically;
# subsequent db/storage/usage commands use it
Pattern 8 — Tier upgrade with downgrade-overflow handling
Upgrading is always safe. Downgrading can fail if your usage already exceeds the new tier’s caps:
try {
await account.projects.update(proj.id, { tier: 'tiny' });
} catch (e) {
if (e instanceof MortarApiError && e.status === 409) {
const body = e.body as { error: string; blockers: Array<{ resource: string; current: number; new_limit: number }> };
if (body.error === 'tier_change_would_overflow') {
console.log('Cannot downgrade — clean these up first:');
for (const b of body.blockers) {
console.log(` ${b.resource}: ${b.current} > ${b.new_limit}`);
}
return;
}
}
throw e;
}
Pattern 9 — Use end-user JWT to enforce per-user data isolation
After signInWithPassword / signUp succeeds, the SDK persists the
session and attaches it automatically to every subsequent from() /
storage call — you don’t pass a token around. Mortar logs the user ID so
your future RLS policies can key off it; today it’s logged + auditable but
doesn’t yet gate row access.
const { data, error } = await mortar.auth.signInWithPassword({ email, password });
if (error) return showError(error.message);
// Now scoped to data.user — the session rides along automatically:
const { data: myTodos } = await mortar.from('todos').select('*').eq('user_id', data.user.id);
Pattern 10 — Self-host configuration
Mortar’s binary is open source and runs anywhere. For self-host, your SDK URL just changes; everything else is identical:
// Pointing at a self-hosted Mortar (tenant in the host subdomain):
const mortar = createClient({
url: 'https://project-uuid.mortar.my-company-internal.com',
apiKey: 'mtr_live_...',
});
The Mortar binary supports any Postgres-compatible DB; see
docs/deployment.md for the compatibility matrix
(AWS RDS / Aurora / 阿里云 RDS / 腾讯云 CDB / Google Cloud SQL / 自建).
Things NOT to do (common mistakes AI tools make)
- Don’t invent endpoints. Mortar’s API surface is exactly
what’s in
mortar/internal/api/routes.go. If your code calls something not documented inopenapi.yaml, it’ll 404. - Don’t pass JWT in the
Authorizationheader. That’s the project API key’s slot. End-user JWT goes inX-Mortar-User-Token— and the SDK does this for you: aftermortar.auth.signInWithPassword(...)the session is stored and attached to every later call automatically. - Don’t use Mortar for binary-large-data realtime. SSE streams text; bytes-heavy fanout (video, screen sharing) needs a different transport — out of Mortar’s scope.
- Don’t rely on undocumented response fields. Mortar’s stable
contract is the typed responses in
@mortar-ai/client. Fields not exposed via TypeScript types may change. - Don’t share API keys across environments. Mortar issues
mtr_live_...for prod andmtr_test_...for staging — keep them separate.
Where to ask for help
- Source: https://github.com/appunvs/mortar (public after launch)
- Docs root:
mortar/docs/(this dir) - Pricing model:
mortar/docs/pricing.md - Architecture deep-dive:
mortar/docs/architecture.md - Self-host guide:
mortar/docs/deployment.md - Roadmap:
mortar/docs/roadmap.md