255 lines
11 KiB
Markdown
255 lines
11 KiB
Markdown
---
|
|
doc: five-idioms
|
|
title: Five web/worker app idioms (HTTP, Postgres, queue, LLM, build)
|
|
keywords: [idioms, cookbook, http, endpoint, routing, AP_JSONRESPONSE, AP_BODY, AP_GETPAIRS, ctx_set, ctx_get, PG_QUERY, PG_EXEC, LABDB_GET_PG, job queue, FOR UPDATE SKIP LOCKED, LLM_CHAT, fnode, build, deploy]
|
|
summary: Battle-tested patterns from the production solmade app for building HTTP APIs and background workers in Five — endpoint skeleton, file-name routing, Postgres access, the DB job queue, LLM calls, and build/deploy.
|
|
---
|
|
|
|
# Five idioms / cookbook
|
|
|
|
All snippets are from the real `solmade` app (`/Users/charleskwon/solmade`).
|
|
|
|
## 1. HTTP endpoint skeleton (use the shared helpers)
|
|
|
|
One `FUNCTION Main()` per `.prg` file under `app/api/`. Use the helpers in
|
|
`app/lib/api.prg` instead of repeating PG/JSON/error boilerplate:
|
|
|
|
- `REQUIRE_PG() → nPG` — PG handle; on failure emits a 500 JSON error and returns -1.
|
|
- `REQUIRE_JSON_BODY() → hBody | NIL` — parsed JSON body; on failure emits 400, returns NIL.
|
|
- `API_OK( [hPayload] ) → NIL` — sends `{"ok":true, ...payload}`.
|
|
- `API_ERR( nStatus, cMsg [, hExtra] ) → NIL` — sends `{"ok":false,"error":cMsg, ...}`.
|
|
|
|
xBase can't force the caller to return, so the convention is "helper sets the response,
|
|
caller returns immediately." `API_OK`/`API_ERR` return NIL so you can write
|
|
`RETURN API_ERR( 400, "…" )` on one line.
|
|
|
|
```five
|
|
// POST /api/press-save.prg
|
|
FUNCTION Main()
|
|
LOCAL nPG := REQUIRE_PG()
|
|
LOCAL hBody, nUser
|
|
|
|
IF nPG < 0
|
|
RETURN NIL
|
|
ENDIF
|
|
|
|
hBody := REQUIRE_JSON_BODY()
|
|
IF hBody == NIL
|
|
RETURN NIL
|
|
ENDIF
|
|
|
|
nUser := Val( ctx_get( "auth_user_id", "0" ) ) // authed user (string → int)
|
|
IF Empty( nUser )
|
|
RETURN API_ERR( 401, "login required" )
|
|
ENDIF
|
|
|
|
RETURN API_OK( { "id" => 123 } )
|
|
```
|
|
|
|
> STYLE: always write `IF`/`FOR`/`WHILE` bodies on their own indented lines. Do NOT use
|
|
> inline `;` to pack multiple statements on one line (`IF nPG < 0 ; RETURN NIL ; ENDIF`
|
|
> is banned — it hurts visual review). The trailing-`;` line *continuation* (for long
|
|
> strings/SQL/args) is a different, allowed use.
|
|
|
|
Core verbs (used by/inside the helpers):
|
|
- `AP_BODY()` → raw request body (decode with `hb_jsonDecode`).
|
|
- `AP_GETPAIRS( .t. )` → hash of query-string params (GET).
|
|
- `AP_JSONRESPONSE( hHash )` → serialize + send JSON response.
|
|
- `ctx_set( "status", N )` → set HTTP status code.
|
|
- `ctx_get( "auth_user_id", "0" )` → authed user id (always a STRING; wrap in `Val()`).
|
|
Also `auth_email`, `auth_role`, `auth_display_name` set by the auth middleware.
|
|
|
|
## 2. File-name routing
|
|
|
|
A URL path maps deterministically to a function (`app/bridge_server.prg`):
|
|
strip `/api/`, drop `.prg`, replace `-`→`_` and `/`→`_`, uppercase, append `__MAIN`.
|
|
|
|
```
|
|
/api/press-submit.prg → PRESS_SUBMIT__MAIN (Main() in app/api/press_submit.prg)
|
|
/api/auth/login.prg → AUTH_LOGIN__MAIN
|
|
```
|
|
|
|
So: name the file with underscores, put a `FUNCTION Main()` in it, call it with hyphens.
|
|
|
|
## 3. PostgreSQL access
|
|
|
|
```five
|
|
LOCAL nPG := LABDB_GET_PG() // pooled connection handle (opened at startup)
|
|
|
|
// SELECT → array of hashes. *** Column values come back as STRINGS. ***
|
|
LOCAL aRows := PG_QUERY( nPG, ;
|
|
"SELECT id, title FROM articles WHERE status = $1 ORDER BY id DESC LIMIT 200", ;
|
|
{ "draft" } )
|
|
IF aRows == NIL ; aRows := {} ; ENDIF
|
|
LOCAL nId := Val( hb_CStr( aRows[1]["id"] ) ) // convert numeric columns explicitly
|
|
|
|
// non-SELECT
|
|
PG_EXEC( nPG, "UPDATE articles SET title=$1 WHERE id=$2", { "New", hb_NToS( nId ) } )
|
|
|
|
// INSERT ... RETURNING
|
|
aRows := PG_QUERY( nPG, ;
|
|
"INSERT INTO articles (title) VALUES ($1) RETURNING id", { "Draft" } )
|
|
LOCAL nNew := aRows[1]["id"]
|
|
|
|
// error string
|
|
LOCAL cErr := PG_LAST_ERROR( nPG )
|
|
```
|
|
|
|
Idempotent schema, safe to call inside an endpoint:
|
|
|
|
```five
|
|
FUNCTION ARTICLES_ENSURE( nPG )
|
|
PG_EXEC( nPG, ;
|
|
"CREATE TABLE IF NOT EXISTS articles ( " + ;
|
|
" id SERIAL PRIMARY KEY, " + ;
|
|
" title TEXT NOT NULL DEFAULT '(제목 없음)', " + ;
|
|
" created_at TIMESTAMPTZ NOT NULL DEFAULT now() " + ;
|
|
")" )
|
|
RETURN NIL
|
|
```
|
|
|
|
## 4. DB job queue (avoid proxy timeout on long work)
|
|
|
|
Long LLM calls would blow the HTTP/proxy timeout. Pattern: submit → poll.
|
|
|
|
**Submit (web)** — insert `queued`, return id immediately:
|
|
|
|
```five
|
|
aRows := PG_QUERY( nPG, ;
|
|
"INSERT INTO text_tasks (kind, input, status, progress_msg, requested_by) " + ;
|
|
"VALUES ('press', $1, 'queued', '대기 중', $2) RETURNING id", ;
|
|
{ cText, hb_NToS( nUser ) } )
|
|
AP_JSONRESPONSE( { "ok" => .t., "task_id" => aRows[1]["id"] } )
|
|
```
|
|
|
|
**Worker (separate binary)** — claim one row atomically; no two workers collide:
|
|
|
|
```five
|
|
aClaim := PG_QUERY( nPG, ;
|
|
"WITH n AS ( SELECT id FROM text_tasks WHERE status='queued' " + ;
|
|
" ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1 ) " + ;
|
|
"UPDATE text_tasks SET status='running', started_at=now() " + ;
|
|
"WHERE id=(SELECT id FROM n) RETURNING id, kind, input" )
|
|
IF aClaim == NIL .OR. Len( aClaim ) == 0
|
|
RETURN .F. // nothing queued; caller sleeps
|
|
ENDIF
|
|
// ... do work, periodically UPDATE progress_pct/progress_msg ...
|
|
PG_EXEC( nPG, "UPDATE text_tasks SET status='done', progress_pct=100, " + ;
|
|
"result=$1, finished_at=now() WHERE id=$2", { cOut, hb_NToS( nId ) } )
|
|
```
|
|
|
|
**Status (web)** — client polls `SELECT status, progress_pct, progress_msg, result`.
|
|
Run multiple worker processes for concurrency; `SKIP LOCKED` keeps them race-free.
|
|
|
|
## 5. LLM calls
|
|
|
|
`LLM_CHAT(cSystem, cUser, hOpts)` → `{ "ok"=>.T./.F., "text"=>cResult, "error"=>cMsg }`.
|
|
|
|
```five
|
|
LOCAL hRes := LLM_CHAT( PRESSER_SYSTEM_PROMPT(), cUser, ;
|
|
{ "temperature" => 0.5, "max_tokens" => 4000 } )
|
|
IF hb_HGetDef( hRes, "ok", .f. )
|
|
cOut := hb_HGetDef( hRes, "text", "" )
|
|
ELSE
|
|
// hRes["error"] — endpoint unreachable / HTTP != 200 / empty response
|
|
ENDIF
|
|
```
|
|
|
|
Endpoint resolves from `SOLMADE_LLM_URL` env (OpenAI-compatible `/v1`); model name is
|
|
auto-resolved (a literal `"local"` is replaced by the actually-loaded model id, because
|
|
mlx/llama servers reject unknown model names). `<think>...</think>` is stripped.
|
|
|
|
## 6. Build & deploy (fnode)
|
|
|
|
Web and worker are **separate binaries** built from explicit file lists.
|
|
|
|
```bash
|
|
# build.sh — web
|
|
"$FNODE" build \
|
|
app/bridge_server.prg app/auth/*.prg app/lib/*.prg app/api/*.prg "$BRIDGE"/*.prg \
|
|
--rtl fivenode_go/hbrtl_ext/pgrtl \
|
|
--rtl fivenode_go/hbrtl_ext/httpserver \
|
|
--go-replace gitea.fivego.org/kwon_ai/solmade="$SOLMADE_ROOT" \
|
|
--module gitea.fivego.org/kwon_ai/solmade/_solmade_web \
|
|
-o solmade-web
|
|
```
|
|
|
|
- `--rtl <pkg>` blank-imports a Go package whose `init()` registers RTL functions
|
|
(e.g. `pgrtl` provides `PG_QUERY`/`PG_EXEC`; `httpserver` the web bridge).
|
|
- `--go-replace pkg=path` resolves a private module without a proxy.
|
|
- `--module <name>` sets the temp module path (must sit under the app's module so RTL
|
|
packages can import the app's internal packages).
|
|
|
|
### Quality gates (lint + smoke)
|
|
|
|
Two committed gates keep a dynamically-typed PRG codebase honest (solmade):
|
|
|
|
- **`lint.sh`** — static checks: bans inline `;` statement separators
|
|
(`IF x ; y ; ENDIF` — hurts visual review; trailing-`;` continuation is fine)
|
|
and flags user input concatenated into SQL instead of `$1` binds. Exit 1 on
|
|
any ERROR. Fast, no server/DB needed.
|
|
- **`smoke_test.sh`** — runtime endpoint contract test (below).
|
|
|
|
Wiring: `build.sh` runs `./lint.sh` first and aborts the build on violation; a
|
|
`.githooks/pre-commit` runs it on every commit (enable once with
|
|
`git config core.hooksPath .githooks`; bypass with `git commit --no-verify`).
|
|
Run `smoke_test.sh` at deploy time (it needs the server up), not in the hook.
|
|
|
|
### Safety net: endpoint smoke test
|
|
|
|
Dynamic typing has no compile-time guarantee, so a smoke test is the practical safety net
|
|
(buy it back at runtime, like Python+pytest). solmade ships `smoke_test.sh`: it mints a
|
|
temp superadmin session in PG, curls each endpoint, and asserts HTTP status + the JSON
|
|
`ok`/shape contract (`./smoke_test.sh` fast; `--full` adds the queue→worker→LLM E2E),
|
|
exiting non-zero on any failure. Run it after any refactor.
|
|
|
|
Deploy (launchd): `launchctl kickstart -k gui/$(id -u)/kr.solmade.web` (and
|
|
`...worker1/2/3`). The worker build (`build_worker.sh`) links `cmd_prg/job_worker.prg`
|
|
plus the shared `app/lib/*.prg` (so `LLM_CHAT` and prompts are available to it too).
|
|
|
|
## 7. Reaching the ecosystem (no C — Five is the Go reimplementation)
|
|
|
|
Five replaced Harbour's C with Go, so reach external code **without C/CGO/Harbour
|
|
native**. Two production-clean paths (see [[five-overview]]):
|
|
|
|
**(a) Go package via RTL — first choice.** Wrap a Go library in a thin `hbrtl_ext`
|
|
module, blank-import with `--rtl`, call the registered function. Single static binary,
|
|
no runtime deps. Use whenever a good Go library exists (Postgres, PDF, XLSX,
|
|
crypto/rand, argon2, bluemonday).
|
|
|
|
**(b) npm package via the handle-based Node bridge (`nodebridge`).** The original
|
|
C++ fivenode was `Harbour ⇄ C glue ⇄ Node(npm)` — JS always ran in real Node, the C was
|
|
only a JSON glue. The faithful Go port replaces the *glue* (not Node) with Go:
|
|
`Five(Go) ⇄ nodebridge(Go) ⇄ persistent node ⇄ npm`. Same handle protocol as the C++
|
|
version (`require` → handle, `call` → method with auto-await, `end`), **no C/CGO**, and
|
|
**not** `node -e` string eval. RTL lives in `fivenode_go/hbrtl_ext/nodebridge`:
|
|
|
|
Same object+method API as Node / the original C++ fivenode `Require()` —
|
|
`FN_REQUIRE` returns an **object**, and you call npm methods on it directly:
|
|
|
|
```five
|
|
LOCAL oQR := FN_REQUIRE( "qrcode" ) // = Require(); returns a module object
|
|
IF ! HB_ISOBJECT( oQR ) // .F. on failure → FN_LASTERROR()
|
|
// handle error
|
|
ENDIF
|
|
LOCAL cSvg := oQR:toString( cUrl, { "type" => "svg", "width" => 280 } ) // async auto-awaited
|
|
oQR:__end__() // release the handle
|
|
```
|
|
How it works: at `FN_REQUIRE` the bridge enumerates the module's method names and
|
|
registers each as a class method whose closure proxies `oObj:<name>(args)` to Node
|
|
(mirrors the C++ `TFNModule` `hb_clsAdd(clsH, NAME, DISPATCH)`). Args are native PRG
|
|
values (string/number/hash/array → JS); the result comes back as string/number/
|
|
boolean/buffer(base64)/json. `__end__()` frees the node-side handle.
|
|
- Setup: a `node/` dir with `package.json` + `npm install <pkg>`; the bridge points
|
|
`NODE_PATH` there (default via env `SOLMADE_NODE_DIR`).
|
|
- One persistent `node` per process; calls serialized; pure Go, node spawned on first use.
|
|
- Real example + live demo: solmade `app/api/qr.prg` → `oQR:toString()` → SVG, at
|
|
`solmade.kr/qrcode`.
|
|
|
|
**Legacy (do NOT build new on it):** the original `fivenode` used a **C** N-API/koffi
|
|
addon (`libfivenode`) for the same `Require()`/`aWait()` protocol — against the C→Go
|
|
goal. `nodebridge` reproduces that protocol with pure-Go glue instead.
|
|
|
|
**Choose (b) over (a)** only when the npm package has no good Go equivalent or would be
|
|
heavy to reimplement; the cost is a Node runtime dependency for that feature.
|