Building backends in machin
machin compiles MFL to one static native binary — an HTTP server, a database client,
auth, and a CLI in the same program, no Node, no ORM, no cgo, no client libraries. The
datastore drivers are pure MFL over dial() + the crypto builtins.
Run machin guide first (the version-exact language catalog). For the web/UI side —
SSR, the reactive wasm UI, the router — read machin guide --skill web. This skill is
the server / data / auth how-to.
The HTTP server — framework/machweb.src
A handler is func(Request) Response; serve(port, handler) runs it, one goroutine
per connection (so shared state must be pooled — see below). req.method / req.path
(carries the query string) / req.body / header(req,name) / cookie(req,name) /
query(req,name). Builders: ok_text ok_html ok_json ok_bytes(ctype,b) ok_wasm ·
created bad_request not_found · redirect(url). A map router: new_router(),
route(r,"GET","/x",h), serve_router(port,r).
func handle(req) (res) {
if has_prefix(req.path, "/api/users") { return ok_json(users_json()) }
res = not_found()
}
func main() { serve(8080, func(req) { return handle(req) }) }
Datastores — one uniform shape
Every driver returns rows as a JSON-array-of-rows STRING, so parse(rows, []T{})
decodes them into a typed slice (numeric/bool columns come back unquoted). That single
idiom works across all five. json_get(rows, "[0].field") pulls one value (but returns
the raw token — a string stays quoted; prefer parse).
| Store |
Connect |
Query / exec |
| SQLite (embedded) |
db := sqlite_open(path) / :memory: |
sqlite_query(db, sql[, []string params]) → JSON · sqlite_exec(db, sql[, params]) (?-bind) · sqlite_close |
PostgreSQL (postgres.src) |
pg_connect(host,port,user,db,pass) + SCRAM |
pg_query(sql) (trusted) · pg_exec(sql, []string params) ($1, extended protocol, injection-safe) · pg_disconnect |
MySQL/MariaDB (mysql.src) |
mysql_connect(host,port,user,pass,db) (native_password) |
mysql_query(sql) → JSON · mysql_exec(sql) → affected · mysql_escape(s) · mysql_close |
Redis (redis.src) |
redis_connect(host,port) [+ redis_auth(pw)] |
redis_set/setex(k,secs,v)/get(k)->(v,ok)/del/incr/expire/rpush/lpush/lrange/keys · redis_cmd([]string) |
MongoDB (mongo.src+bson.src) |
mongo_connect(host,port) [+ mongo_auth("admin",user,pw)] |
mongo_insert_one(db,coll,doc) · mongo_find_all/find(db,coll[,filter]) · mongo_find_by_id · mongo_count/drop/delete · mongo_close |
type User struct { id int name string email string }
pg_connect("127.0.0.1", 5432, "postgres", "app", "secret")
rows := pg_exec("SELECT id, name, email FROM users WHERE active = $1", []string{"1"})
users := parse(rows, []User{}) // typed slice, values decoded
Mongo documents are BSON — build with the bson.src builder, decode replies via the
driver (which renders to JSON): bson_new() then bson_str/bson_i32/bson_i64/
bson_double/bson_bool/bson_null/bson_oid(key,hex)/bson_subdoc/bson_subarr,
finalize with bson_finish. _id ObjectIds decode to a hex string; query by it with
mongo_find_by_id. SCRAM-SHA-256 auth, doubles, and cursor pagination (getMore) are all
handled.
Connection pooling (a concurrent server)
machweb runs each request in its own goroutine, so a shared single connection would
interleave. Every networked driver is handle-based + poolable — the pool is an
async channel of authenticated connections (a semaphore, built on machin's unbounded
channels; no new primitive needed):
pg_pool_init(8, "127.0.0.1", 5432, "postgres", "app", "secret") // once, in main
func handle(req) (res) {
c := pg_acquire() // per request -> its own connection
rows := pgx(c, "SELECT ... WHERE id = $1", []string{id})
pg_release(c)
res = ok_json(rows)
}
Handle ops per driver (the global *_connect/*_query API stays for single-connection
scripts): Postgres pg_acquire/pgq/pgx/pg_release; MySQL mysql_acquire/myq/
myx/mysql_release; Redis redis_acquire/rset/rget/…/redis_release; Mongo
mongo_acquire/mins/mfind/mfindall/mfindid/mdel/mcount/mdrop/mongo_release.
SQLite "pools" as several sqlite_open handles (use WAL + PRAGMA busy_timeout).
Auth — sessions + SSO (framework/machweb.src, sso.src)
- Signed sessions (a cookie the client can't forge):
set_session(res, secret, "sid", userID) stores userID + an HMAC-SHA256 tag; get_session(req, secret, "sid") -> (value, ok) returns ok==1 only if it verifies. cookie(req,name), set_cookie /
clear_cookie (safe defaults Path=/; HttpOnly; SameSite=Lax). Keep secret
server-side (env); the value is signed, not encrypted — store an id, not secrets.
- SSO ("log in with Google/Microsoft/…"): fill
OAuthProvider{auth_url, token_url, userinfo_url, client_id, client_secret, redirect_uri, scope}; route GET /login →
sso_begin(p, secret) (302 + signed CSRF state), GET /callback → profile, ok := sso_complete(p, secret, req) (verify state, exchange code, fetch userinfo). Identity
comes from the userinfo endpoint, so no JWT/RSA is needed. Then set_session(...).
- A common pattern is sessions in Redis (
rsetex("sess:"+sid, 3600, email)) keyed by
a signed cookie — see the MachNotes app.
Sending email (framework/smtp.src)
smtp_send(host, port, from, to, subject, body, user, pass) -> (ok, errmsg) sends a
message over plaintext SMTP + AUTH LOGIN (user="" skips auth) — transactional email
(password resets, receipts, alerts) with no library. The receiving side is here too:
smtp_recv(conn) plays the server for one session, so you can build a catcher. Test with
zero external deps against machin-mail's
sink (a local SMTP catcher + web inbox). STARTTLS/implicit TLS isn't here yet — point it
at a relay that accepts plaintext submission, or a local catcher.
Crypto you'll reach for
sha256 / hmac_sha256 (hex), sha256_bytes / hmac_sha256_bytes / sha1_bytes
(binary), rand_bytes(n), base64_encode/decode + _bytes variants, hkdf_sha256,
aes_gcm_encrypt/decrypt, ed25519_* / x25519_*. to_hex(rand_bytes(16)) is the
idiomatic token/id.
CLIs — be agent-first
A backend tool is consumed by agents. Follow the contract (see
[AGENTS_FRIENDLY_TOOLS.md] in your project, and the machin-cms app):
- stdout = the answer as JSON (
{"ok":true,"data":...}); stderr = structured
errors (write(2, json+"\n") — fd 2 is stderr) so logs never pollute the data stream.
- Semantic exit codes:
0 ok · 80–89 input/validation · 90–99 resource
(not found / exists) · 100–109 integration (db/auth down) · 110–119 internal.
exit(code).
- Non-interactive, deterministic, composable subcommands; a
help-json for
introspection. Parse args from args() (args()[0] is the program path).
Daemons (start/stop without PIDs)
system(cmd) -> int runs a shell command (-1 if unlaunchable). daemon start spawns a
detached server and returns: system(args()[0] + " serve " + port + " >log 2>&1 &");
stop POSTs an internal /_shutdown route (the handler calls exit(0)); status probes
/_health. No pidfiles or signals — see machin-cms.
Build & verify
- Compose vendored frameworks + your app, then build:
machin encode framework/machweb.src framework/postgres.src app.src > app.mfl && machin build app.mfl -o app. The drivers
link only what they use (-lsqlite3 for SQLite, OpenSSL for crypto/SCRAM, -lm, …).
machin encode runs the typechecker — most errors surface there (no cc needed).
- Verify against a real server in a container (
docker run -d -p 5432:5432 postgres:16,
redis:7, mongo:7, mariadb:11) and drive your binary with curl. The machin repo's
gated tests (MACHIN_PG_TEST=1, MACHIN_MYSQL_TEST=1, MACHIN_MONGO_TEST=1, …) show the
pattern; pool concurrency is proven with N goroutines over K connections.
Gotchas (hard-won)
- Pool, don't share. A single global connection in a concurrent server corrupts reads.
Use the pool + acquire/release per request, releasing on every return path.
- Wire reads are NUL-binary — the drivers use
read_bytes (not read, which is a
C string and truncates at a NUL) and binary-safe base64_*_bytes (for SCRAM).
json_get returns the raw token — a string field comes back quoted ("Ada").
Prefer parse(rows, []T{}); strip quotes only when pulling one field.
- Parameterize (
? / $1 / Mongo filter docs) for user input; mysql_escape for the
text protocol. Never string-concat user input into SQL.
- A function named like a builtin is a compile error (
len/str/keys/…); and two
structurally-identical structs (e.g. two {fd int buf []bytes} driver handles) can
confuse the checker — give the same-scope variable holding each a distinct name.
Worked apps + direction
- machin-saas-demo (MachNotes) — SSO →
Redis sessions → pooled Postgres → reactive wasm UI, one binary.
- machin-cms — an agent-first, db-agnostic
headless CMS (schemas, RBAC, a REST API daemon over SQLite/MySQL/Mongo, all pooled).
- machin-db-migrate — SQLite ⇄ MongoDB,
both directions.
- Direction + capability/gap matrix:
docs/NORTH-STAR-BACKEND.md.
1---2name: machin-backend3description: Building backends in machin4---56# Building backends in machin78machin compiles MFL to one **static native binary** — an HTTP server, a database client,9auth, and a CLI in the same program, no Node, no ORM, no cgo, no client libraries. The10datastore drivers are **pure MFL** over `dial()` + the crypto builtins.1112> Run `machin guide` first (the version-exact language catalog). For the web/UI side —13> SSR, the reactive wasm UI, the router — read `machin guide --skill web`. This skill is14> the *server / data / auth* how-to.1516## The HTTP server — `framework/machweb.src`1718A handler is `func(Request) Response`; `serve(port, handler)` runs it, **one goroutine19per connection** (so shared state must be pooled — see below). `req.method` / `req.path`20(carries the query string) / `req.body` / `header(req,name)` / `cookie(req,name)` /21`query(req,name)`. Builders: `ok_text` `ok_html` `ok_json` `ok_bytes(ctype,b)` `ok_wasm` ·22`created` `bad_request` `not_found` · `redirect(url)`. A map router: `new_router()`,23`route(r,"GET","/x",h)`, `serve_router(port,r)`.2425```go26func handle(req) (res) {27 if has_prefix(req.path, "/api/users") { return ok_json(users_json()) }28 res = not_found()29}30func main() { serve(8080, func(req) { return handle(req) }) }31```3233## Datastores — one uniform shape3435**Every driver returns rows as a JSON-array-of-rows STRING, so `parse(rows, []T{})`36decodes them into a typed slice** (numeric/bool columns come back unquoted). That single37idiom works across all five. `json_get(rows, "[0].field")` pulls one value (but returns38the *raw* token — a string stays quoted; prefer `parse`).3940| Store | Connect | Query / exec |41|---|---|---|42| **SQLite** (embedded) | `db := sqlite_open(path)` / `:memory:` | `sqlite_query(db, sql[, []string params])` → JSON · `sqlite_exec(db, sql[, params])` (`?`-bind) · `sqlite_close` |43| **PostgreSQL** (`postgres.src`) | `pg_connect(host,port,user,db,pass)` + SCRAM | `pg_query(sql)` (trusted) · `pg_exec(sql, []string params)` (`$1`, extended protocol, injection-safe) · `pg_disconnect` |44| **MySQL/MariaDB** (`mysql.src`) | `mysql_connect(host,port,user,pass,db)` (native_password) | `mysql_query(sql)` → JSON · `mysql_exec(sql)` → affected · `mysql_escape(s)` · `mysql_close` |45| **Redis** (`redis.src`) | `redis_connect(host,port)` [+ `redis_auth(pw)`] | `redis_set/setex(k,secs,v)/get(k)->(v,ok)/del/incr/expire/rpush/lpush/lrange/keys` · `redis_cmd([]string)` |46| **MongoDB** (`mongo.src`+`bson.src`) | `mongo_connect(host,port)` [+ `mongo_auth("admin",user,pw)`] | `mongo_insert_one(db,coll,doc)` · `mongo_find_all/find(db,coll[,filter])` · `mongo_find_by_id` · `mongo_count/drop/delete` · `mongo_close` |4748```go49type User struct { id int name string email string }50pg_connect("127.0.0.1", 5432, "postgres", "app", "secret")51rows := pg_exec("SELECT id, name, email FROM users WHERE active = $1", []string{"1"})52users := parse(rows, []User{}) // typed slice, values decoded53```5455**Mongo documents are BSON** — build with the `bson.src` builder, decode replies via the56driver (which renders to JSON): `bson_new()` then `bson_str`/`bson_i32`/`bson_i64`/57`bson_double`/`bson_bool`/`bson_null`/`bson_oid(key,hex)`/`bson_subdoc`/`bson_subarr`,58finalize with `bson_finish`. `_id` ObjectIds decode to a hex string; query by it with59`mongo_find_by_id`. SCRAM-SHA-256 auth, doubles, and cursor pagination (`getMore`) are all60handled.6162## Connection pooling (a concurrent server)6364machweb runs each request in its own goroutine, so a **shared single connection would65interleave**. Every networked driver is **handle-based + poolable** — the pool is an66async channel of authenticated connections (a semaphore, built on machin's unbounded67channels; no new primitive needed):6869```go70pg_pool_init(8, "127.0.0.1", 5432, "postgres", "app", "secret") // once, in main71func handle(req) (res) {72 c := pg_acquire() // per request -> its own connection73 rows := pgx(c, "SELECT ... WHERE id = $1", []string{id})74 pg_release(c)75 res = ok_json(rows)76}77```7879Handle ops per driver (the global `*_connect`/`*_query` API stays for single-connection80scripts): Postgres `pg_acquire`/`pgq`/`pgx`/`pg_release`; MySQL `mysql_acquire`/`myq`/81`myx`/`mysql_release`; Redis `redis_acquire`/`rset`/`rget`/…/`redis_release`; Mongo82`mongo_acquire`/`mins`/`mfind`/`mfindall`/`mfindid`/`mdel`/`mcount`/`mdrop`/`mongo_release`.83SQLite "pools" as several `sqlite_open` handles (use WAL + `PRAGMA busy_timeout`).8485## Auth — sessions + SSO (`framework/machweb.src`, `sso.src`)8687- **Signed sessions** (a cookie the client can't forge): `set_session(res, secret, "sid",88 userID)` stores `userID` + an HMAC-SHA256 tag; `get_session(req, secret, "sid") ->89 (value, ok)` returns `ok==1` only if it verifies. `cookie(req,name)`, `set_cookie` /90 `clear_cookie` (safe defaults `Path=/; HttpOnly; SameSite=Lax`). Keep `secret`91 server-side (`env`); the value is signed, not encrypted — store an id, not secrets.92- **SSO ("log in with Google/Microsoft/…")**: fill `OAuthProvider{auth_url, token_url,93 userinfo_url, client_id, client_secret, redirect_uri, scope}`; route `GET /login` →94 `sso_begin(p, secret)` (302 + signed CSRF state), `GET /callback` → `profile, ok :=95 sso_complete(p, secret, req)` (verify state, exchange code, fetch userinfo). Identity96 comes from the userinfo endpoint, so no JWT/RSA is needed. Then `set_session(...)`.97- A common pattern is **sessions in Redis** (`rsetex("sess:"+sid, 3600, email)`) keyed by98 a signed cookie — see the MachNotes app.99100## Sending email (`framework/smtp.src`)101102`smtp_send(host, port, from, to, subject, body, user, pass) -> (ok, errmsg)` sends a103message over plaintext SMTP + `AUTH LOGIN` (`user=""` skips auth) — transactional email104(password resets, receipts, alerts) with no library. The receiving side is here too:105`smtp_recv(conn)` plays the server for one session, so you can build a catcher. Test with106**zero external deps** against [machin-mail](https://github.com/javimosch/machin-mail)'s107`sink` (a local SMTP catcher + web inbox). STARTTLS/implicit TLS isn't here yet — point it108at a relay that accepts plaintext submission, or a local catcher.109110## Crypto you'll reach for111112`sha256` / `hmac_sha256` (hex), `sha256_bytes` / `hmac_sha256_bytes` / `sha1_bytes`113(binary), `rand_bytes(n)`, `base64_encode/decode` + `_bytes` variants, `hkdf_sha256`,114`aes_gcm_encrypt/decrypt`, `ed25519_*` / `x25519_*`. `to_hex(rand_bytes(16))` is the115idiomatic token/id.116117## CLIs — be agent-first118119A backend tool is consumed by agents. Follow the contract (see120[`AGENTS_FRIENDLY_TOOLS.md`] in your project, and the machin-cms app):121- **stdout = the answer as JSON** (`{"ok":true,"data":...}`); **stderr = structured122 errors** (`write(2, json+"\n")` — fd 2 is stderr) so logs never pollute the data stream.123- **Semantic exit codes**: `0` ok · `80–89` input/validation · `90–99` resource124 (not found / exists) · `100–109` integration (db/auth down) · `110–119` internal.125 `exit(code)`.126- **Non-interactive**, deterministic, composable subcommands; a `help-json` for127 introspection. Parse args from `args()` (`args()[0]` is the program path).128129## Daemons (start/stop without PIDs)130131`system(cmd) -> int` runs a shell command (`-1` if unlaunchable). `daemon start` spawns a132detached server and returns: `system(args()[0] + " serve " + port + " >log 2>&1 &")`;133`stop` POSTs an internal `/_shutdown` route (the handler calls `exit(0)`); `status` probes134`/_health`. No pidfiles or signals — see machin-cms.135136## Build & verify137138- Compose vendored frameworks + your app, then build: `machin encode framework/machweb.src139 framework/postgres.src app.src > app.mfl && machin build app.mfl -o app`. The drivers140 link only what they use (`-lsqlite3` for SQLite, OpenSSL for crypto/SCRAM, `-lm`, …).141- **`machin encode` runs the typechecker** — most errors surface there (no `cc` needed).142- **Verify against a real server** in a container (`docker run -d -p 5432:5432 postgres:16`,143 `redis:7`, `mongo:7`, `mariadb:11`) and drive your binary with `curl`. The machin repo's144 gated tests (`MACHIN_PG_TEST=1`, `MACHIN_MYSQL_TEST=1`, `MACHIN_MONGO_TEST=1`, …) show the145 pattern; pool concurrency is proven with N goroutines over K connections.146147## Gotchas (hard-won)148149- **Pool, don't share.** A single global connection in a concurrent server corrupts reads.150 Use the pool + acquire/release per request, releasing on **every** return path.151- **Wire reads are NUL-binary** — the drivers use `read_bytes` (not `read`, which is a152 C string and truncates at a NUL) and binary-safe `base64_*_bytes` (for SCRAM).153- **`json_get` returns the raw token** — a string field comes back quoted (`"Ada"`).154 Prefer `parse(rows, []T{})`; strip quotes only when pulling one field.155- **Parameterize** (`?` / `$1` / Mongo filter docs) for user input; `mysql_escape` for the156 text protocol. Never string-concat user input into SQL.157- **A function named like a builtin is a compile error** (`len`/`str`/`keys`/…); and two158 structurally-identical structs (e.g. two `{fd int buf []bytes}` driver handles) can159 confuse the checker — give the same-scope variable holding each a distinct name.160161## Worked apps + direction162163- **[machin-saas-demo](https://github.com/javimosch/machin-saas-demo)** (MachNotes) — SSO →164 Redis sessions → pooled Postgres → reactive wasm UI, one binary.165- **[machin-cms](https://github.com/javimosch/machin-cms)** — an agent-first, db-agnostic166 headless CMS (schemas, RBAC, a REST API daemon over SQLite/MySQL/Mongo, all pooled).167- **[machin-db-migrate](https://github.com/javimosch/machin-db-migrate)** — SQLite ⇄ MongoDB,168 both directions.169- Direction + capability/gap matrix: [`docs/NORTH-STAR-BACKEND.md`](../../docs/NORTH-STAR-BACKEND.md).