Using manggaleh — guide for AI agents
A practical reference for another Claude Code session to build on / integrate with a manggaleh backend. manggaleh is a multitenant backend-as-a-service: each project gets an isolated Postgres database with end-user auth, an auto-generated REST Data API, file storage, server-side functions, scheduled jobs, realtime, webhooks and email — all behind one SDK (
@manggaleh/sdk) and one CLI (mg/@manggaleh/cli).
If you (the agent) just need to use an existing manggaleh service, you mostly need: the
API origin (e.g. https://api.manggaleh.com), the project slug (the tenant), the
environment (dev/staging/prod/…), and an API key. Get the rest from this doc.
0. Capabilities — what manggaleh can do, and how to reach it
| Capability | What you can do | SDK (@manggaleh/sdk) |
CLI (mg) |
|---|---|---|---|
| End-user auth | password sign up/in/out + sessions; passwordless: email-OTP; on-demand email verification (OTP), forgot/reset & change password; optional signup codes; per-environment | client.auth.signUp/signIn/signOut/getSession + sendOtp/signInWithOtp + verifyEmail/sendPasswordResetOtp/resetPasswordWithOtp/changePassword |
(app-side; CLI is owner-side) |
| Admin user-management | list / look-up by email / set-password / disable (revoke sessions) / delete end-users (service key only) | client.admin.users.list/findByEmail/setPassword/disable/delete |
— |
| Auth config (per env, owner) | allowed auth redirect origins (for app-hosted password-reset redirectTo); branded auth emails (sender name + body = your app, not manggaleh) |
— (dashboard Sign-up tab / API) | mg env auth-origins · mg env email |
| Data API (CRUD) | create / read / update / delete rows, typed; atomic $inc + $guard (race-safe writes) |
client.data.from<T>(c).insert/get/update/remove/list |
— |
| Query | filter (eq + operators), sort, cursor pagination, count, column projection, embed (belongs-to + one-to-many), aggregate | list/page({…, embed }), aggregate({ groupBy, count, sum, avg, min, max }) |
— |
| Transactions | atomic multi-op (all or nothing) + interactive read-then-write w/ row lock & serializable (in Functions) | client.tx([ … ]) · ctx.db.transaction(async t => …, { isolation }) |
— |
| Storage | upload / list / download / remove; owner/ABAC-scoped; on-the-fly image transforms + signed URLs | client.storage.upload/list/download/getSignedUrl/remove |
— |
| Functions | server-side JS (trusted logic); invoke from app/server | client.functions.invoke(name, input) |
mg functions push/list/delete |
| Scheduled jobs (cron) | run a function on a schedule (admin mode) | — (dashboard) | — |
| Realtime | subscribe to insert/update/delete over WebSocket | client.realtime.subscribe(c, handler) |
— |
| Live data (optimistic) | self-syncing store: optimistic insert/update/remove + rollback + realtime reconcile; useSyncExternalStore-ready |
client.data.from<T>(c).live(opts) |
— |
| Webhooks (outbound) | HMAC-signed POST to your URL when data changes | — (dashboard) | — |
| transactional email (needs a service key); auth emails (OTP/reset) auto-branded per env as your app | client.notifications.email.send / ctx.email.send |
mg env email (branding) |
|
| Projects & envs | create projects, environments, clone/reset, production flag | — | mg projects … / mg env … |
| Collections (schema) | define tables/columns/relations, set RLS owner/ABAC | — (dashboard) | mg collections create --columns … --owner-column … |
| API keys | mint publishable / service / function-scoped, revoke | — | mg keys create/list/revoke |
| Types codegen | generate TS interfaces from the live schema | use the output with data.from<T>() |
mg types --out db.d.ts |
| RLS & ABAC | per-owner or tag-based row security, enforced server-side | automatic once configured | --owner-column / --permission-column |
Division of labour: the CLI is for the owner/admin (provision projects, schema, keys, push functions, gen types — see §2). The SDK is for the application you build (auth, data, storage, functions, realtime — see §3). End-users authenticate through the SDK, not the CLI.
1. Mental model (learn these 5 words)
- Project — an isolated "database" (a better-auth organization). Its slug (e.g.
acme) is thetenantyou pass to the SDK. - Environment —
dev/staging/prodor custom (e.g.qa). These are just labels for fully isolated Postgres schemas: separate data and separate end-user accounts. The names carry no special meaning — see the box below. You may flag one as production, which only protects it from delete/reset (nothing else). - Collection — a table (with columns + relations). Defining one instantly exposes the REST Data API for it (list/get/insert/update/delete).
- API Key — every tenant request needs one:
publishable(mgpk_…) — safe in a browser/frontend.service(mgsk_…) — secret, full admin (bypasses RLS). Server-side only.function(mgfk_…) — scoped: may only invoke an allowlist of functions, nothing else. Safe to give to a 3rd party.
- RLS / ABAC — row-level security enforced on the server. A collection with an
owner column scopes rows per end-user; a permission column (
text[]) does tag-based (ABAC) access. The frontend physically cannot read rows it isn't allowed to.
Auto-managed system columns (don't define these, they're added for you):
id (uuid), created_at, created_by, updated_at, updated_by.
Auth model for requests: a request usually carries the API key and (for end-user
context) the signed-in user's Bearer token. A service key runs as admin (no user
needed). RLS is keyed on the user, not the key.
⚠️ There is no “dev” vs “production” mode
manggaleh has no application-wide dev/prod switch (no
NODE_ENVthat changes behavior). Two things people mistake for one:
- Environments (
dev/staging/prod/…) are just isolated schemas — separate data and users. Naming oneproddoesn’t change how auth, email, or anything else behaves. The only effect of the production flag is protection from delete/reset.- Behavioral config is global to the server, set via env vars — the same for every environment. The big one is email:
EMAIL_PROVIDER=resendsends real email everywhere;EMAIL_PROVIDER=log(default) sends nowhere and logs instead. So “does email work?” depends on the server config, never on the environment you call or its production flag.When this doc (or anyone) says “in dev,” read it as “when no real email provider is configured” — that’s the actual condition, not a mode.
2. Fast path: provision a backend from the CLI
npm install -g @manggaleh/cli
# 1. Account (or `mg login` if it exists). Session saved at ~/.manggaleh/config.json.
mg signup --url https://api.manggaleh.com --email you@acme.com
# (non-interactive: set MANGGALEH_EMAIL / MANGGALEH_PASSWORD, or pass --email/--password)
# 2. Project (also provisions a "dev" environment)
mg projects create --name "Acme" --slug acme
# 3. A collection. On a column type: "!" = NOT NULL, "^" = UNIQUE.
# --owner-column turns on per-user RLS.
mg collections create --project acme --env dev --name todos \
--columns "title:text!,code:text^,done:boolean,priority:integer" \
--owner-column owner_id
# 4. API keys
mg keys create --project acme --env dev --type publishable # mgpk_… (browser)
mg keys create --project acme --env dev --type service --name server # mgsk_… (shown once)
# 5. Generate TS types for the SDK
mg types --project acme --env dev --out src/db.d.ts
Column types: text, integer, bigint, numeric, boolean, timestamptz, date, uuid, jsonb,
plus reference (a FK — set its target collection). Identifiers are lowercase / digits /
underscore (my_table); project slugs are kebab-case (my-app).
CLI reference
Account & projects
mg signup --url <baseUrl> [--name <n>] [--email <e>] [--password <p>]
mg login --url <baseUrl> [--email <e>] [--password <p>]
mg projects
mg projects create --name <name> --slug <slug>
mg projects delete --slug <slug> --yes (hapus SEMUA env & data, permanen — owner)
mg env list --project <slug>
mg env create --project <slug> --name <env> [--production]
mg env clone --project <slug> --from <env> --to <env>
(default "promote": structure + functions/secrets/webhooks/jobs/signup, NO data/users)
[--full] | pick: [--structure] [--data] [--end-users] [--functions]
[--secrets] [--webhooks] [--scheduled-jobs] [--signup-code]
mg env delete --project <slug> --name <env> --yes
mg env reset --project <slug> --env <env> --yes (empty ALL data; prod protected)
mg env signup-code --project <slug> --env <env> [--set <code> | --open]
mg env auth-origins --project <slug> --env <env> [--set "https://app.example.com,…" | --clear]
mg env email --project <slug> --env <env> (auth email brand/sender name)
[--set-brand "<Your App>"] [--clear] (default = project slug; sender address fixed)
mg env usage --project <slug> --env <env> [--since <ISO date>]
Schema & keys
mg collections list --project <slug> --env <env>
mg collections create --project <slug> --env <env> --name <name>
[--columns "title:text,done:boolean!,code:text^"] (! = NOT NULL, ^ = UNIQUE)
[--owner-column <col>] [--permission-column <col>]
mg collections add-column --project <slug> --env <env> --name <c> --columns "note:text,qty:integer!"
mg collections delete --project <slug> --env <env> --name <name> --yes
mg keys list --project <slug> --env <env>
mg keys create --project <slug> --env <env> --type <publishable|service|function> [--name <n>]
[--functions "fnA,fnB"] (required for --type function)
mg keys revoke --project <slug> --env <env> --id <id> --yes
End-users, webhooks, data & billing (full dashboard parity)
mg users list|create|delete|permissions --project <slug> --env <env>
create: --email <e> --password <p> --name <n> delete: --id <id> --yes
permissions: --id <id> --permissions "team:sales,tier:pro" (ABAC tags)
mg webhooks list|create|update|delete|test --project <slug> --env <env>
create: --url <https://…> [--collections "a,b"] [--events "insert,update,delete"]
update: --id <id> [--enable|--disable] [--url <u>] [--collections …] [--events …]
mg data list|insert|update|delete --project <slug> --env <env> --collection <c>
insert/update: --input '{"title":"x"}' (update/delete need --id; delete needs --yes)
list: [--limit <n>] [--order "col.desc"]
mg storage list|rm --project <slug> --env <env> [--id <id> --yes] (upload/download: use the SDK)
mg billing --project <slug> [--set-plan <free|pro|business>]
Functions & types
mg functions list --project <slug> --env <env>
mg functions push --project <slug> --env <env> --file <path> [--name <name>]
[--allow-domain "api.stripe.com,api.tabby.ai"] (egress allowlist for ctx.fetch)
[--runtime vm|deno] [--timeout <ms>] [--memory <mb>] (per-function config)
mg functions invoke --project <slug> --env <env> --name <fn> [--input '{…}' | --file <f.json>]
mg functions delete --project <slug> --env <env> --name <name> --yes
mg types --project <slug> --env <env> [--out <file.d.ts>]
Function secrets (injected as ctx.secrets; needs MASTER_ENCRYPTION_KEY on server)
mg secrets list --project <slug> --env <env>
mg secrets set --project <slug> --env <env> --name STRIPE_KEY --value <value>
mg secrets rm --project <slug> --env <env> --name STRIPE_KEY --yes
Personal access tokens (owner-level, non-interactive auth for CLI/CI; revocable)
mg tokens list
mg tokens create --name <n> [--expires-days <N>] (value shown ONCE)
mg tokens rm --id <id> --yes
- The CLI covers every owner scenario the dashboard does (projects, environments, collections, keys, end-users + ABAC, webhooks, data browser, storage, functions, secrets, signup code, auth redirect origins, billing/usage). Binary storage upload/download is the one data-plane action left to the SDK.
- Deletes require
--yes. Production environments/collections are protected server-side. - Config lives at
~/.manggaleh/config.json(override withMANGGALEH_CONFIG). mg functions pushupserts by name.mg typesemits oneinterfaceper collection plus aCollectionsmap.
Authenticating the CLI (interactive vs CI)
The CLI is owner / control-plane tooling (manages projects, schema, keys, functions) —
it does not use tenant API keys (mgsk_/mgpk_; those are for the Data API at runtime).
Two ways to authenticate:
Interactive —
mg login(email + password) saves a session cookie to the config file.Non-interactive (CI) — use a Personal Access Token (
mgpat_…): owner-level, revocable, and shown only once. Mint it in the dashboard (Account → Personal access tokens) or withmg tokens create, then:export MANGGALEH_TOKEN=mgpat_xxx # owner access token export MANGGALEH_URL=https://api.manggaleh.com mg collections create --project acme --env dev --name todos --columns "title:text!" # no `mg login` needed; revoke anytime from the dashboard / `mg tokens rm`A PAT is full-owner scope (same power as your login) but separate from your password and revocable — prefer it over putting
MANGGALEH_PASSWORDin CI. Change your password anytime in the dashboard (Account → Change password, needs the current one).
2b. Environments: create, clone/promote, and keys
Who does this: managing environments is an owner / control-plane action — done in the dashboard, the CLI (
mg env …), or the control-plane API with an owner PAT. The app SDK cannot and must not do it: the SDK talks to one environment's Data API with a tenant key (mgpk_/mgsk_) and has no project/environment/clone operations by design (an app holding a publishable key must never be able to create or wipe environments).
An environment (dev/staging/prod/custom) is an isolated schema — its own data, its
own end-users, and its own API keys. Typical "set up a new environment" flow:
# 1. Create an empty environment (gets a fresh publishable key automatically)
mg env create --project acme --name staging # add --production to flag it protected
# 2. Promote your structure + config into it WITHOUT dragging dev's test data/users.
# Default preset = "promote": structure + functions + secrets + webhooks +
# scheduled jobs + signup code; NO data, NO end-users → a clean target.
mg env clone --project acme --from dev --to staging
# …or pick exactly what to copy:
mg env clone --project acme --from dev --to prod --structure --functions
mg env clone --project acme --from dev --to staging --full # everything incl. data + users
# 3. Mint the keys THIS environment needs (see below — keys are NOT cloned).
mg keys create --project acme --env staging --type service --name server
Clone components (structure, data, endUsers, functions, secrets, webhooks,
scheduledJobs, signupCode):
- Additive / safe —
structure(creates missing collections, never drops),functions/secrets/scheduledJobs(upsert by name),webhooks(append),signupCode. Allowed into a production target (it never touches existing data). - Destructive —
data&endUsersTRUNCATE+overwrite the target, so they are blocked when the target is production.endUsersrequiresdata(replacing users cascades to rows).
⚠️ API keys are per-environment and are NOT cloned. A
service/publishablekey created fordevonly works fordev— using it againststaging/prodreturns401. After creating/cloning an environment, mint new keys in that environment (dashboard API Keys tab, ormg keys create --env <env> --type service). This is intentional: a leaked dev key can't touch prod.
(Control-plane API equivalents, owner-authenticated: POST …/environments,
POST …/environments/clone with {source,target,include}, POST …/environments/:env/keys.)
3. SDK (@manggaleh/sdk)
Framework-agnostic (browser or Node 18+; needs global fetch + WebSocket).
npm install @manggaleh/sdk
Create one client per project + environment
import { createClient } from "@manggaleh/sdk";
export const client = createClient({
baseUrl: "https://api.manggaleh.com", // API origin (no trailing slash needed)
tenant: "acme", // project slug
env: "dev", // "dev" | "staging" | "prod" (default "prod")
apiKey: "mgpk_xxx", // publishable for browser, service for server
// storage: tokenStorage, // persist the session across reloads (see Gotchas)
});
End-user auth
const { user } = await client.auth.signUp({ email, password, name, /* code? */ });
await client.auth.signIn({ email, password });
await client.auth.signOut();
const session = await client.auth.getSession(); // SessionResult | null
The session token is captured & attached automatically. Auth is per environment.
Passwordless sign-in (per environment) — via the SDK:
await client.auth.sendOtp(email); // emails a one-time code
const { user } = await client.auth.signInWithOtp({ email, otp }); // verify → signed in
Whether the code is actually emailed depends only on the server's
EMAIL_PROVIDER (see “There is no dev/prod mode” below) — not on which
environment you call:
EMAIL_PROVIDER=resend(real provider configured) → emailed for real.EMAIL_PROVIDER=log(default, no provider) → not emailed; the code is printed to the server log instead ([auth:otp] … -> 481923). To skip the inbox entirely, setDEV_OTP_CODE=1234and every code becomes that fixed value. LeaveDEV_OTP_CODEempty once a real provider is configured (otherwise anyone can sign in with it).
(Raw endpoints, if not using the SDK: POST /auth/email-otp/send-verification-otp →
/auth/sign-in/email-otp.)
If the environment has a signup code, passwordless is gated like sign-up: OTP
won't register new users (existing users still sign in) — new accounts must come
through the gated /sign-up.
Email verification (on-demand OTP — no link). Sign-up does not auto-send a
verification email, and verification is not required to sign in — a new user is
signed in immediately, just with emailVerified=false. Verify on demand: when the
user taps "verify email" in your app, send an email-verification OTP and confirm it
with verifyEmail, which (unlike signInWithOtp) does not create an account
or mint a session:
await client.auth.sendOtp(email, "email-verification"); // emails a code when the user asks
await client.auth.verifyEmail({ email, otp }); // sets emailVerified=true only
Use
verifyEmailto verify andsignInWithOtponly to actually log a user in passwordlessly — don't usesignInWithOtpto "verify": for an unknown email it creates an orphan passwordless account and issues a session. Because nothing is auto-sent at sign-up, there's no double-send footgun — you send exactly one code, when the user asks.
Password reset (forgot password) — OTP. The standard reset flow: manggaleh emails the user a one-time code that they type back into your app; you set the new password in one call. App-side (publishable key, no service key), and it always resolves (200) regardless of whether the email exists — no account-enumeration:
await client.auth.sendPasswordResetOtp({ email }); // emails a 6-digit code
await client.auth.resetPasswordWithOtp({ email, otp, newPassword }); // verify code + set the new password
Same UX as sign-in OTP — no email link to click, no reset page to host — so it works
identically on web and mobile/native, with no redirect origins to allowlist. The code
respects DEV_OTP_CODE in dev and expires in 5 minutes.
Change password (while signed in):
await client.auth.changePassword({ currentPassword, newPassword, revokeOtherSessions: true });
Auth email branding. All auth emails to end-users (sign-in codes, email verification,
password reset) are sent from your app's brand, not manggaleh, using a professional
template. There is one setting — brandName — used as the sender display name (email
header) and in the body (e.g. "Sent by <brand>"). It defaults to the project slug,
so it's already your project's name out of the box. Configure per environment (owner) in the
dashboard (Sign-up tab → "Auth email branding"), via mg env email --set-brand "<Your App>",
or PUT /api/projects/:slug/environments/:env/email { brandName }.
The From header becomes e.g. <Your App> <no-reply@updates.manggaleh.com> — your brand shows
in the inbox while the sending address stays on manggaleh's verified domain (this address is
fixed and can't be changed, which is what keeps deliverability working).
Admin user-management (service key only)
Server-side only — requires a service key (publishable/function → 403). Manages
the environment's end-user directory (e.g. to clean up an orphan account or rotate a
password):
const admin = createClient({ tenant, env, apiKey: SERVICE_KEY, baseUrl });
await admin.admin.users.list({ limit: 50 });
const u = await admin.admin.users.findByEmail("alice@cust.com");
await admin.admin.users.setPassword(u.id, "newPassword123");
await admin.admin.users.disable(u.id); // revokes sessions (soft — not a persistent ban)
await admin.admin.users.delete(u.id); // permanent; cascades sessions + credentials
disablerevokes all sessions (immediate logout) but is not a persistent ban — the end-user schema has nobannedcolumn. To truly lock someone out,deletethem orsetPasswordto a value they don't know.
Data API (CRUD)
interface Todo { id: string; title: string; done: boolean; created_at: string }
const todos = client.data.from<Todo>("todos");
const created = await todos.insert({ title: "Buy milk", done: false }); // returns full row
const one = await todos.get(created.id); // null (not error) when missing
const updated = await todos.update(created.id, { done: true }); // partial patch
await todos.remove(created.id); // -> void
const rows = await todos.list(); // one page (default 50, server max 200)
Atomic updates (counters, balances, stock) — avoid lost updates. A normal
update is a blind set: if you read a value, change it in JS, and write it
back, two concurrent requests can clobber each other (lost update). Instead use
the atomic $inc operator (evaluated in the DB under the row lock) and the
optional $guard (compare-and-set) to enforce an invariant:
await posts.update(id, { views: { $inc: 1 } }); // exact even under heavy concurrency
await posts.update(id, { score: { $inc: -2 } }); // negative delta = decrement
// Oversell-proof decrement: only applies while stock >= 1, else throws 409 and
// leaves the row UNCHANGED (distinct from 404 "not found / not yours").
await products.update(id, { stock: { $inc: -1 }, $guard: { stock: "gte.1" } });
$guard takes PostgREST-style predicates ({ col: "op.value" },
eq/neq/gt/gte/lt/lte/like/ilike/in/is). It also powers optimistic concurrency —
guard on a version/updated_at column ($guard: { version: "eq.3" }) and bump
it in the same patch. $inc requires a numeric column. Works the same inside
client.tx([...]) / ctx.db.tx([...]).
Query: filter / sort / paginate / embed
const open = await todos.list({
filters: { done: false, title: "ilike.%milk%", priority: "gte.3" }, // plain value = eq
order: "created_at.desc", // add ".desc" for descending
limit: 50, // capped at 200
});
// Cursor pagination + optional total
const p1 = await todos.page({ limit: 20, count: true }); // { data, nextCursor, count }
if (p1.nextCursor) await todos.page({ limit: 20, cursor: p1.nextCursor });
// Projection + embed relations (nested via dotted paths)
await client.data.from("orders").list({
select: ["id", "total", "customer_id"], // id always included
embed: ["customer(name,phone)"], // belongs-to: FK value → related row
});
// belongs-to (object) + one-to-many (array) + nesting, in one request:
await client.data.from("properties").get(id, {
embed: ["owner(name)", "photos(url)", "reviews.author"],
// owner → object (this property's FK)
// photos → array (rows that FK back to this property)
// reviews.author → array of reviews, each with its author object
});
// Aggregation — count/sum/avg/min/max + group by, computed in the DB (RLS-respected):
await client.data.from("reservations").aggregate({
groupBy: "status", // omit for a single summary row
count: true, // → count
sum: ["amount"], avg: ["amount"], // → sum_amount, avg_amount
filters: { created_at: "gte.2026-01-01" },
});
// → [ { status: "confirmed", count: 320, sum_amount: 145000, avg_amount: 453.1 }, … ]
Filter operators: plain value = eq; gt/gte/lt/lte; like/ilike (ilike.%x%);
in.a,b,c / nin.x,y; is.null / is.notnull. Filtering & sorting run on the server over
the whole dataset.
Transactions (ACID — all or nothing)
const results = await client.tx([
// Atomic + guarded: concurrent transfers can't lose an update or overdraw.
{ op: "update", collection: "accounts", id: a, patch: { balance: { $inc: -25 }, $guard: { balance: "gte.25" } } },
{ op: "update", collection: "accounts", id: b, patch: { balance: { $inc: 25 } } },
{ op: "insert", collection: "ledger", values: { from: a, to: b, amount: 25 } },
// ops: "insert" | "update" | "delete" | "get"
]);
// results[i].data (the row, for insert/update/get) or results[i].deleted (for delete)
Semantics & limits
- Atomic: all ops run in one DB transaction on one connection (
BEGIN…COMMIT); if any op fails, the whole thing rolls back and nothing persists. - Order: ops run top-to-bottom; results come back in the same order.
- Max 50 ops per transaction.
- Same security context as a normal request: end-user → RLS/owner-scope applies
to every op; service key → admin; with
actAsUserthe whole tx runs as that user. (So a user's tx can only touch rows they're allowed to.) - Available both from the app/server (
client.tx([...])) and inside a Function (ctx.db.tx([...])).
A tx is a fixed list — you can't read a value mid-transaction and branch on it,
and there's no upsert op. For simple conditional writes (counters, balances,
stock, optimistic-concurrency version bumps), reach for atomic $inc + $guard
above — they're race-safe without a read step, so they beat any read-then-write
pattern under load. For richer read → decide → write logic, do it inside a
Function (it runs server-side; read with ctx.db, then commit the writes with
ctx.db.tx — all under the caller's RLS):
⚠️ Don't read a value, compute a new total in JS, and write it back as a literal (
patch: { balance: prev - 25 }) — two concurrent requests will clobber each other (lost update). Use{ balance: { $inc: -25 }, $guard: { balance: "gte.25" } }instead. Note a barectx.db.get+ctx.db.updateare two separate transactions, so they have the same race — for a true locked read-then-write, usectx.db.transaction(async t => …)witht.get(coll, id, { forUpdate: true })(see §4).
// Function: confirm a payment atomically (read → decide → write in one unit)
module.exports = async (input, ctx) => {
const order = await ctx.db.get("orders", input.orderId);
if (!order || order.paid) throw new Error("not payable");
await ctx.db.tx([
{ op: "update", collection: "orders", id: order.id, patch: { paid: true } },
{ op: "update", collection: "availability", id: order.slotId, patch: { held: false } },
{ op: "insert", collection: "fee_breakdown", values: { order_id: order.id, fee: input.fee } },
]);
return { ok: true };
};
Server-first tip: combine actAsUser + tx to run an atomic multi-table flow
as a specific user with RLS enforced — no service-key bypass needed.
Storage (files)
const obj = await client.storage.upload(file, { name: "receipt.pdf" }); // file: Blob/File
const list = await client.storage.list();
const blob = await client.storage.download(obj.id);
await client.storage.remove(obj.id);
// Images: transform on the fly (server-side). Great for thumbnails / LQIP blur.
const thumb = await client.storage.download(obj.id, { width: 400, fit: "cover", format: "webp" });
const lqip = await client.storage.download(obj.id, { width: 24, blur: 8 }); // tiny blurred placeholder
// params: width, height, fit (cover|contain|fill|inside|outside), quality (1-100),
// format (webp|jpeg|png|avif), blur. Non-images are returned unchanged.
// Signed URL — usable directly in <img src> with NO api-key/token, until it expires.
// (expiresIn seconds, default 1h, max 1y; image transform params allowed.)
const url = await client.storage.getSignedUrl(obj.id, { expiresIn: 3600, width: 400, format: "webp" });
// <img src={url} /> — works without the SDK (great for emails, CDNs, plain <img>).
Owner/ABAC-scoped by RLS — safe from the browser with a publishable key. (permissions tags
on upload only apply with a service key.)
Functions (call server-side logic)
const { top } = await client.functions.invoke("topProducts", { n: 10 });
- From the browser: publishable key + signed-in user → runs as that user (RLS applies).
- From a server: service key → admin (bypasses RLS).
- A function-scoped key (
mgfk_) may only invoke its allowlisted functions.
Act-as-user (server-first). A trusted server with a service key can run calls
as a specific end-user so RLS still applies (instead of the admin bypass) — ideal
for Next.js RSC / server actions. Pass actAsUser to the SDK (sends the
x-act-as-user: <userId> header):
// per request, on your server:
const db = createClient({ baseUrl, tenant, env, apiKey: SERVICE_KEY, actAsUser: session.userId });
await db.data.from("orders").list(); // only this user's rows; inserts owned by them
Only service keys honor it (a publishable/function key can't impersonate). The user
must exist.
Realtime
const unsub = client.realtime.subscribe("todos", async (e) => {
// e = { type: "change", schema, collection, op, id } — NO row data!
if (e.op === "delete") { removeFromUI(e.id); return; }
const row = await todos.get(String(e.id)); // refetch (also runs through RLS)
if (row) upsertInUI(row);
});
// later: unsub();
Auto-reconnects. e.id may be a number → String(e.id) before get.
Live data + optimistic updates (.live())
Don't want to hand-write the subscribe→refetch→merge loop above? from<T>(c).live()
returns an opt-in store that does it for you and applies mutations optimistically
(instant UI, automatic rollback on failure). subscribe/getSnapshot match React's
built-in useSyncExternalStore — no extra dependency; Zustand is an optional wrapper.
const todos = client.data.from<Todo>("todos").live({ order: "created_at.desc" });
// React:
const list = useSyncExternalStore(todos.subscribe, todos.getSnapshot);
useEffect(() => () => todos.close(), []); // stop realtime on unmount
await todos.insert({ title }); // appears instantly → swapped for the server row
await todos.update(id, { done: true }); // toggles instantly → reverts if the server rejects
await todos.remove(id); // disappears instantly → comes back on failure
live() is additive: it reuses list()/get()/realtime under the hood and changes
nothing about the existing APIs. Also exported: createLiveCollection,
LiveCollection, LiveOptions.
Email (server-side, service key required)
const admin = createClient({ baseUrl, tenant, env, apiKey: process.env.MG_SERVICE_KEY });
await admin.notifications.email.send({
to: "customer@example.com", subject: "Receipt", html: "<p>Thanks!</p>",
});
Error handling
import { ManggalehError } from "@manggaleh/sdk";
try { await todos.insert({ title: "x" }); }
catch (err) {
if (err instanceof ManggalehError) {
// err.status: 401 (sign in), 403 (RLS / wrong key / scope), 404, 429 (quota), 400…
// err.message, err.body
} else throw err;
}
Note: get(id) on a missing row resolves to null; everything else throws.
4. Writing Functions (server-side JS, in the dashboard "Functions" tab)
// Function "topProducts"
module.exports = async (input, ctx) => {
// ctx.db.list returns { data, nextCursor, count? } — note the .data
const { data: orders } = await ctx.db.list("orders", { order: "total.desc", limit: input.n ?? 5 });
ctx.log("rows", orders.length); // appears in run logs (console.log works too)
await ctx.email.send({ to: "ops@acme.com", subject: "Report", text: "..." });
return { top: orders }; // must be JSON-serializable
};
ctx: input (the invoke payload), db (list/get/insert/update/remove/tx/transaction,
RLS-aware), email.send(...), fetch(url, init?) (outbound HTTP — allowlisted, below),
secrets (your encrypted env secrets), request (raw HTTP request — for webhook receivers),
log(...).
Interactive transactions — ctx.db.transaction. When you must read a row, decide,
then write atomically (and a plain $inc/$guard isn't enough), open an interactive
transaction. It holds one DB transaction across the callback, so t.get(coll, id, { forUpdate: true })
locks the row until you commit — concurrent invocations serialize on the lock instead of
losing updates. The callback's return value is the result; throw to roll back (and it auto-rolls
back if the function ends without committing). Inside: t.get/insert/update/remove (no nested tx).
// Safe money transfer: lock the source row, check funds, then move money.
module.exports = async (input, ctx) =>
ctx.db.transaction(async (t) => {
const from = await t.get("accounts", input.from, { forUpdate: true });
if (!from || from.balance < input.amount) throw new Error("insufficient funds"); // → rollback
await t.update("accounts", input.from, { balance: from.balance - input.amount });
await t.update("accounts", input.to, { balance: { $inc: input.amount } });
return "ok";
});
Multi-row invariants → { isolation: "serializable" }. A row lock (forUpdate) protects
one row. When your rule spans different rows — "at least one admin must remain", "no
double-booking a slot" — two transactions can each read the same rows, decide independently, and
each write a different row, breaking the invariant (a write-skew). Pass
{ isolation: "serializable" } as the 2nd arg: Postgres detects the conflict and aborts one, and
the runtime automatically retries the whole callback (up to 5×) so the loser re-reads the
committed state and does the right thing.
module.exports = async (input, ctx) =>
ctx.db.transaction(async (t) => {
const a = await t.get("doctors", input.a);
const b = await t.get("doctors", input.b);
if ((a.on_call ? 1 : 0) + (b.on_call ? 1 : 0) <= 1) throw new Error("one must stay on call");
await t.update("doctors", input.self, { on_call: false });
return "ok";
}, { isolation: "serializable" }); // ← also: "repeatable read"
⚠️ Because a serializable transaction may run more than once, keep external side effects (
ctx.email,ctx.fetch, charging a card) outside the callback — do them after it commits. Only DB work belongs inside.
Each open transaction holds a pooled connection for the function's duration, so keep them short
(the function timeout + server idle-transaction timeout are the backstops); max 4 open at once.
For a fixed list of writes with no read step, use ctx.db.tx([...]) instead (lighter).
Caller identity — ctx.user. The invoking end-user is resolved server-side and exposed as
ctx.user = { id, email } — no need to forward a token in the input or call /auth/get-session
yourself. It is null when the caller is a service key (trusted server, not an end-user) or a
non-HTTP invoke (cron). For act-as-user calls (x-act-as-user) it is { id, email: null }.
module.exports = async (input, ctx) => {
if (!ctx.user) throw new Error("must be signed in");
return ctx.db.list("orders", { filters: { user_id: ctx.user.id } });
};
Inbound webhooks — ctx.request. A function can receive a gateway's webhook: POST to its
invoke URL (use a function-scoped key, e.g. …/functions/paymentWebhook?apikey=mgfk_…) and read
ctx.request = { method, headers, rawBody, query }. rawBody is the exact bytes — verify the
provider's HMAC signature over it (use crypto.subtle on the deno runtime). Your api-key /
auth / cookie headers are redacted from ctx.request.headers (use ctx.user for caller identity).
(Non-HTTP invokes like cron get ctx.request === null.)
Runtimes (set per function).
vm(default) — Nodevmworker. No network, no imports. Smallest/fastest.deno— Deno subprocess. Enablesctx.fetchand dynamicimport()ofnpm:/jsr:/https:modules. Pick it in the Functions tab or via the API.
Outbound HTTP — ctx.fetch. Runs on the server with a per-function egress allowlist
(the hosts a function may call). Calls to non-allowlisted hosts, or to private/loopback IPs,
are rejected (SSRF-safe). Returns { status, ok, headers, text(), json() }. The global
fetch() is blocked — always use ctx.fetch.
Secrets — ctx.secrets. Per-environment secrets (e.g. STRIPE_KEY), encrypted at rest,
injected as a frozen object: ctx.secrets.STRIPE_KEY. Manage them in the Functions tab or with
mg secrets set. (Requires MASTER_ENCRYPTION_KEY on the server.)
Imports (deno only). Use dynamic import() —
const { default: jsPDF } = await import("npm:jspdf"); static import statements aren't
supported. Modules are cached server-side.
Per-function config: runtime (vm|deno), timeoutMs (default ~5s, clamped server-side),
memoryMb, and the egress allowlist. Return value is JSON-serialized.
Functions can be scheduled (cron) — those runs are admin (RLS bypassed), so guard sensitive logic inside the function body, not just with RLS.
Example — charge a card via a gateway, then update data atomically:
// runtime: deno · egress allowlist: api.stripe.com · secret: STRIPE_KEY
module.exports = async (input, ctx) => {
const res = await ctx.fetch("https://api.stripe.com/v1/charges", {
method: "POST",
headers: { authorization: "Bearer " + ctx.secrets.STRIPE_KEY },
body: "amount=" + input.amount + "¤cy=aed",
});
const charge = await res.json();
await ctx.db.tx([
{ op: "update", collection: "orders", id: input.orderId, patch: { paid: true, charge_id: charge.id } },
]);
return { ok: res.ok, chargeId: charge.id };
};
Example — receive & verify a gateway webhook (runtime: deno):
// secret: WEBHOOK_SECRET · gateway POSTs to …/functions/paymentWebhook?apikey=mgfk_…
module.exports = async (input, ctx) => {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw", enc.encode(ctx.secrets.WEBHOOK_SECRET), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const mac = await crypto.subtle.sign("HMAC", key, enc.encode(ctx.request.rawBody));
const hex = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
if (hex !== ctx.request.headers["x-signature"]) throw new Error("bad signature");
const event = JSON.parse(ctx.request.rawBody);
await ctx.db.update("orders", event.orderId, { paid: true });
return { ok: true };
};
5. Direct HTTP (no SDK — e.g. curl from an agent)
Base for tenant requests: {baseUrl}/api/t/{tenant}/{env}. Auth via header
x-api-key: <key> or query ?apikey=<key>; end-user token via Authorization: Bearer <token>.
B=https://api.manggaleh.com/api/t/acme/dev
# sign in (returns a token; SDK manages this for you)
curl -s $B/auth/sign-in/email -H 'content-type: application/json' \
-H 'x-api-key: mgpk_xxx' -d '{"email":"u@x.com","password":"secret"}'
# data
curl -s "$B/data/todos?done=false&order=created_at.desc&limit=20" -H 'x-api-key: mgpk_xxx' -H "authorization: Bearer $TOKEN"
curl -s -XPOST $B/data/todos -H 'content-type: application/json' -H 'x-api-key: mgsk_xxx' -d '{"title":"hi"}'
curl -s -XPATCH $B/data/todos/$ID -H 'content-type: application/json' -H 'x-api-key: mgsk_xxx' -d '{"done":true}'
curl -s -XDELETE $B/data/todos/$ID -H 'x-api-key: mgsk_xxx'
# invoke a function (service or function-scoped key works without a user)
curl -s -XPOST "$B/functions/topProducts?apikey=mgsk_xxx" -H 'content-type: application/json' -d '{"n":10}'
Responses: data list = { data, nextCursor, count? }; get/insert/update = { data };
function = { result }. Realtime is a WebSocket at {wsBase}/api/t/{tenant}/{env}/realtime?collection=..&token=..&apikey=...
6. API keys & 3rd-party access
| Type | Prefix | Use | Power |
|---|---|---|---|
| publishable | mgpk_ |
frontend / browser | normal (RLS applies via the signed-in user) |
| service | mgsk_ |
your own server | full admin (bypasses RLS) — never ship to a browser |
| function | mgfk_ |
give to a 3rd party / vendor | scoped: only invokes its allowlisted functions; 403 on Data API / storage / email / other functions |
To let an external system (payment gateway, vendor) call one of your functions, create a
function-scoped key (mg keys create --type function --functions "paymentCallback") — never
hand out a service key. The gateway POSTs its payload as the function input.
7. Gotchas (save yourself debugging)
- Realtime carries no row data — only
{ op, id }. Refetch withdata.from(c).get(id), or letdata.from(c).live()handle the refetch + optimistic UI + rollback for you. - Session persistence: by default the token is in-memory (lost on reload). Pass a
storageadapter withget()/set()— notlocalStoragedirectly (it hasgetItem/setItem):storage: { get: () => localStorage.getItem("mg_token"), set: (t) => t ? localStorage.setItem("mg_token", t) : localStorage.removeItem("mg_token"), } - Auth is per-environment. A user in
devdoes not exist inprod. - Inside a function,
ctx.db.listreturns{ data, ... }, not a bare array (unlike the SDK client's.list()). embedsupports nesting (relation-of-relation) via dotted paths, e.g.embed: ["customer.city(name)"](max 4 levels). Embedded rows respect RLS too.embeddoes both directions: belongs-to (a FK column → the related row, as an object) and one-to-many (a child collection that FKs back → an array, e.g.embed: ["photos"]on a property). Empty has-many →[]. Ambiguous has-many (a child with multiple FKs to the parent) is rejected.- Email needs a service key (publishable → 403). The
logdriver only writes to the server log (doesn't actually send). - Email verification is on-demand — sign-up sends nothing. A new user is signed in
immediately with
emailVerified=false; nothing is emailed at sign-up. To verify, send one code when the user asks:sendOtp(email, "email-verification")→verifyEmail. See End-user auth → Email verification. - Column/type discipline:
bigint/integerneed whole numbers;numericallows decimals;date=YYYY-MM-DD;timestamptz= ISO. Sending a mismatched value → 400. list()default 50, max 200. Usepage()+ cursor for feeds.- Identifiers: collections/columns/env names are lowercase + digits +
_; project slugs are kebab-case; function names may keep case (camelCase ok).
8. Recipes (agent intent → exact steps)
"Stand up a backend for a new app" → use the CLI bootstrap in §2 (signup → projects create → collections create → keys create → types). Hand the publishable key + tenant +
env to the frontend; keep the service key on the server.
"Read/write data as the logged-in user" (frontend) →
const c = createClient({ baseUrl, tenant, env, apiKey: "mgpk_…" });
await c.auth.signIn({ email, password });
await c.data.from("todos").insert({ title: "x", done: false }); // RLS owner set automatically
const mine = await c.data.from("todos").list({ order: "created_at.desc" });
"Run trusted multi-step logic the client must not bypass" → write a Function (§4), then
client.functions.invoke("placeOrder", { … }). Put the rules in the function; the client only
sends intent.
"Let an external system (payment gateway / vendor) call one function" → mint a function-scoped key and give them the invoke URL — never a service key:
mg keys create --project acme --env prod --type function --functions "paymentCallback"
# they POST their payload to: {baseUrl}/api/t/acme/prod/functions/paymentCallback?apikey=mgfk_…
"Live-update the UI when data changes" → realtime.subscribe(collection, e => …) then
refetch the row by e.id (the event has no row data).
"Do an admin/server task (cron, migration, bulk import)" → server-side client with a
service key (mgsk_…) → runs as admin, bypasses RLS.
"Send a receipt / notification email" → from a Function: ctx.email.send({ to, subject, html }), or server-side admin.notifications.email.send(...) with a service key.
"Give the SDK full type-safety" → mg types --project … --env … --out src/db.d.ts, then
client.data.from<Todo>("todos").
Decision shortcuts
- Simple CRUD with per-user isolation → collection + owner column + publishable key, no function needed.
- Logic across collections / secrets / must-not-bypass rules → Function.
- 3rd-party access → function-scoped key (never service).
- Your own backend/cron → service key.