Files
five/rag/04-idioms.md
2026-06-15 21:31:30 +09:00

11 KiB

doc, title, keywords, summary
doc title keywords summary
five-idioms Five web/worker app idioms (HTTP, Postgres, queue, LLM, build)
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
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.

// 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

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:

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:

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:

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 }.

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.

# 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:

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.prgoQR: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.