feat(pgserver): PostgreSQL-wire MVP — psql can SELECT from FiveSql2
First end-to-end working version of the PostgreSQL-wire-compatible
TCP server frontend. A standard `psql` client now connects, runs
`SELECT * FROM employees`, and gets back a properly typed result
set rendered by psql with the right column alignment:
ID | NAME | SALARY
----+----------------------+----------
1 | Alice | 50000.00
2 | Bob | 42000.50
3 | Cho | 77500.00
This is the Phase 2 deliverable from the approved plan at
/Users/charleskwon/.claude/plans/compiled-launching-shore.md.
Builds on the session-state refactor in 93cf5c8 — each connection
gets its own TSqlSession on the PRG side via the new PG_NEW_SESSION
HB_FUNC, so concurrent psql clients won't share transaction logs
or plan caches.
Scope
-----
v1.0 MVP: Simple Query only, trust auth, no TLS yet. SELECT works
against the full FiveSql2 surface (CTEs, window functions, JOINs,
aggregates). DML + per-session transactions are Phase 3, extended
protocol is Phase 4, auth + TLS are Phases 5/6.
Architecture
------------
psql/pgx/JDBC ──TCP:5432──▶ pgserver.Listener
│ accept()
▼ go handleConn(net.Conn)
┌─────────────────────────────┐
│ Session goroutine │
│ 1. SSLRequest peek │
│ 2. StartupMessage │
│ 3. AuthenticationOk (trust) │
│ 4. ParameterStatus×7 │
│ 5. BackendKeyData │
│ 6. ReadyForQuery('I') │
│ 7. loop: Receive() → │
│ dispatchSimpleQuery → │
│ hbrt.Thread.Function( │
│ FIVE_SQL,sql,...,sess) │
│ emit RowDescription │
│ emit DataRow×N │
│ emit CommandComplete │
│ emit ReadyForQuery │
└─────────────────────────────┘
One goroutine per connection, each owning its own *hbrt.Thread and
TSqlSession instance. Uses the existing audit-fixed NewThread()
(cde8673) so statics + WA factory propagate.
New files (hbrtl/pgserver/)
---------------------------
* server.go — Config, Server, Serve loop with MaxConnections gate
via semaphore, Close drains in-flight sessions.
* session.go — full lifecycle: SSLRequest peek + prefixedConn
byte-injection trick for StartupMessage, ParameterStatus
broadcast (server_version "14.0 (FiveSql2)" so pgx negotiates),
BackendKeyData (random pid+secret per session, no CancelRequest
yet), query loop dispatching only Simple Query in v1.0 with a
loud "0A000 not supported" for Extended messages.
* dispatch.go — runSQL invokes FIVE_SQL via PushSymbol+Function,
unpacks the engine's `{aFieldNames, aRows}` envelope or the
`{{"__error__"}, {{nCode, cMsg, cSQL}}}` error shape, emits
RowDescription with text-format OIDs and DataRow per row.
* typemap.go — pgTypeFor() picks INT4 / INT8 / NUMERIC / TEXT /
DATE / TIMESTAMP / BOOL by sampling the first row's value type;
encodeText() formats each cell, returning nil-slice for NULL
(the PG length=-1 convention).
* errmap.go — sqlStateFor() maps FiveSql2 SQL_ERR_* codes to
canonical PG SQLSTATEs (42601/42P01/42703/42804/23505/23514/
23503/25P02/42501/02000/XX000).
* auth.go — trust mode in v1.0; password/MD5/SCRAM lands Phase 5
but the dispatch sentinel is already in place.
* tls.go — upgradeToTLS stub for SSLRequest handling; the byte-
ordering is already wired so Phase 6 just plugs in tls.Config.
* register.go — package init() registers pg_server_start /
pg_server_stop HB_FUNCs. Importing the package (done from
hbrtl/register.go via blank import) is enough to enable them.
* pgserver_test.go — unit tests for encodeText (numeric, string,
NIL), pgTypeFor (OID dispatch), sqlStateFor (error mapping),
commandTagFor (SELECT/INSERT/UPDATE/DELETE/BEGIN/COMMIT).
Other changes
-------------
* _FiveSql2/src/TSqlSession.prg — added PG_NEW_SESSION() factory
used by the Go dispatcher to allocate a per-connection session
bypassing the embedded process default.
* hbrtl/register.go — blank-import five/hbrtl/pgserver so its
init() fires and the HB_FUNCs land in the global dynamic-func
table for VM symbol lookup.
* go.mod / go.sum — github.com/jackc/pgx/v5 v5.9.2 (pgproto3
subpackage). MIT license. Same library pgx itself uses, so
protocol coverage matches the de-facto Go PG ecosystem.
Verification
------------
$ pg_server_start(15432, "trust") /* PRG one-liner */
$ psql -h 127.0.0.1 -p 15432 -U fiveuser -c 'SELECT * FROM employees'
→ 3 rows rendered correctly by psql (ID as INT4, NAME as TEXT,
SALARY as NUMERIC(10,2) with 2 decimal places)
All six release gates green:
go test ./... ✓ (incl. new hbrtl/pgserver tests)
FiveSql2 SQL:1999 43/43 ✓
Harbour compat 56/56 ✓
std.ch 17/17 ✓
FRB 7/7 ✓
examples 65/71 ✓ (unchanged baseline)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -77,3 +77,13 @@ FUNCTION SqlDefaultSession()
|
||||
s_oDefaultSession := TSqlSession():New()
|
||||
ENDIF
|
||||
RETURN s_oDefaultSession
|
||||
|
||||
|
||||
/*
|
||||
* PG_NEW_SESSION — convenience constructor for the pgserver Go
|
||||
* side. The Go dispatcher calls this once per accepted connection
|
||||
* to mint a fresh, isolated TSqlSession that won't share state
|
||||
* with the embedded process-default session.
|
||||
*/
|
||||
FUNCTION PG_NEW_SESSION()
|
||||
RETURN TSqlSession():New()
|
||||
|
||||
4
go.mod
4
go.mod
@@ -1,3 +1,5 @@
|
||||
module five
|
||||
|
||||
go 1.21.13
|
||||
go 1.25.0
|
||||
|
||||
require github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||
|
||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
36
hbrtl/pgserver/auth.go
Normal file
36
hbrtl/pgserver/auth.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
||||
// All rights reserved.
|
||||
|
||||
package pgserver
|
||||
|
||||
import "github.com/jackc/pgx/v5/pgproto3"
|
||||
|
||||
// authenticate runs the auth handshake for the connecting client.
|
||||
//
|
||||
// v1.0-skeleton: trust mode only — accept anyone, send
|
||||
// AuthenticationOk. Phase 5 wires password / MD5 / SCRAM-SHA-256
|
||||
// against __five_roles.dbf and an in-process pg_hba.conf-style
|
||||
// allowlist parsed at server startup.
|
||||
func (s *session) authenticate() error {
|
||||
switch s.srv.cfg.AuthMode {
|
||||
case "", "trust":
|
||||
s.send(&pgproto3.AuthenticationOk{})
|
||||
return nil
|
||||
default:
|
||||
// Phase 5 will dispatch on AuthMode here. For now, any
|
||||
// non-trust mode is rejected at the protocol level so
|
||||
// misconfigured servers fail closed rather than silently
|
||||
// downgrading.
|
||||
s.sendError("28000",
|
||||
"auth mode "+s.srv.cfg.AuthMode+" not yet implemented; use trust")
|
||||
return errAuthRejected
|
||||
}
|
||||
}
|
||||
|
||||
// sentinelError lets the run() loop bail out without typing the
|
||||
// "fmt.Errorf" boilerplate at every call site.
|
||||
type sentinelError string
|
||||
|
||||
func (e sentinelError) Error() string { return string(e) }
|
||||
|
||||
const errAuthRejected = sentinelError("pgserver: authentication rejected")
|
||||
314
hbrtl/pgserver/dispatch.go
Normal file
314
hbrtl/pgserver/dispatch.go
Normal file
@@ -0,0 +1,314 @@
|
||||
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
||||
// All rights reserved.
|
||||
|
||||
package pgserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgproto3"
|
||||
|
||||
"five/hbrt"
|
||||
)
|
||||
|
||||
// runSQL invokes the PRG-level `five_SQL(cSQL, NIL, NIL, oSession)`
|
||||
// on this connection's thread and returns the resulting hbrt.Value
|
||||
// (the engine's `aResult` two-element array, or an error array).
|
||||
//
|
||||
// The fourth arg threads the per-connection TSqlSession through so
|
||||
// concurrent connections never collide on transaction state or the
|
||||
// plan cache (see refactor commit 93cf5c8).
|
||||
func (s *session) runSQL(sql string) (result hbrt.Value, runErr error) {
|
||||
if s.thread == nil {
|
||||
s.thread = s.srv.vm.NewThread()
|
||||
}
|
||||
if s.prgSession.IsNil() {
|
||||
// Lazily allocate a TSqlSession on the PRG side. We do it
|
||||
// via a dedicated helper instead of the engine's
|
||||
// SqlDefaultSession() so each connection truly gets its
|
||||
// own object; the default session is reserved for embedded
|
||||
// callers.
|
||||
sessVal, err := callPRG(s.thread, "PG_NEW_SESSION", nil)
|
||||
if err != nil {
|
||||
return hbrt.MakeNil(), err
|
||||
}
|
||||
s.prgSession = sessVal
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// PRG-side panic (HbError, BreakValue, etc.) — surface
|
||||
// as a runtime error so the caller can map it to an
|
||||
// ErrorResponse without aborting the whole connection.
|
||||
runErr = fmt.Errorf("five_SQL panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
args := []hbrt.Value{
|
||||
hbrt.MakeString(sql),
|
||||
hbrt.MakeNil(), // aParams
|
||||
hbrt.MakeNil(), // bBlock
|
||||
s.prgSession, // oSession
|
||||
}
|
||||
return callPRG(s.thread, "FIVE_SQL", args)
|
||||
}
|
||||
|
||||
// callPRG performs a single PRG function call on the given thread,
|
||||
// returning the PRG-level return value. nil args means a zero-arg
|
||||
// call. Errors come back as Go errors only for symbol resolution
|
||||
// failures; PRG-level runtime errors panic up to the caller's
|
||||
// recover() (see runSQL).
|
||||
func callPRG(t *hbrt.Thread, name string, args []hbrt.Value) (hbrt.Value, error) {
|
||||
sym := t.VM().FindSymbol(strings.ToUpper(name))
|
||||
if sym == nil {
|
||||
return hbrt.MakeNil(), fmt.Errorf("pgserver: PRG symbol %q not found", name)
|
||||
}
|
||||
t.PushSymbol(sym)
|
||||
t.PushNil() // self placeholder — matches gengo's call layout
|
||||
for _, a := range args {
|
||||
t.PushValue(a)
|
||||
}
|
||||
t.Function(len(args))
|
||||
// Function() pushed retVal back onto the stack; pop it.
|
||||
return popValue(t), nil
|
||||
}
|
||||
|
||||
// popValue retrieves the topmost stack entry as the call's return.
|
||||
// Thread.Pop2() pops and returns (the named non-`return` Pop() is the
|
||||
// statement form used by gengo); we want the value here, so Pop2.
|
||||
func popValue(t *hbrt.Thread) hbrt.Value {
|
||||
return t.Pop2()
|
||||
}
|
||||
|
||||
// handleSimpleQuery dispatches a Simple Query message: parse the
|
||||
// SQL, run it through five_SQL, and stream RowDescription +
|
||||
// DataRow* + CommandComplete + ReadyForQuery back.
|
||||
//
|
||||
// FiveSql2's result envelope is either:
|
||||
//
|
||||
// { aFieldNames, aRows } — success
|
||||
// { {"__error__"}, { {nCode, cMsg, cSQL} } } — failure
|
||||
//
|
||||
// We detect the error shape by inspecting aResult[0][0] for the
|
||||
// sentinel "__error__" header column.
|
||||
func (s *session) dispatchSimpleQuery(sql string) {
|
||||
if sql == "" {
|
||||
s.send(&pgproto3.EmptyQueryResponse{})
|
||||
s.sendReadyForQuery()
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.runSQL(sql)
|
||||
if err != nil {
|
||||
s.send(buildErrorResponse("XX000", err.Error(), sql))
|
||||
s.txStatus = currentTxStatusAfterError(s.txStatus)
|
||||
s.sendReadyForQuery()
|
||||
return
|
||||
}
|
||||
|
||||
if result.IsNil() || !result.IsArray() {
|
||||
// Bare NIL or non-array — most plausibly a successful
|
||||
// statement that doesn't produce a result set (e.g. DDL,
|
||||
// SET, transaction control). Reply CommandComplete with
|
||||
// a generic tag.
|
||||
tag := commandTagFor(sql)
|
||||
s.send(&pgproto3.CommandComplete{CommandTag: []byte(tag)})
|
||||
s.updateTxStatusForTag(tag)
|
||||
s.sendReadyForQuery()
|
||||
return
|
||||
}
|
||||
|
||||
arr := result.AsArray()
|
||||
if isErrorEnvelope(arr) {
|
||||
nCode, cMsg, cSQL := unpackError(arr)
|
||||
s.send(buildErrorResponse(sqlStateFor(nCode), cMsg, cSQL))
|
||||
s.txStatus = currentTxStatusAfterError(s.txStatus)
|
||||
s.sendReadyForQuery()
|
||||
return
|
||||
}
|
||||
|
||||
// Success envelope: { aFieldNames, aRows }
|
||||
fieldsVal := arr.Items[0]
|
||||
rowsVal := hbrt.MakeNil()
|
||||
if len(arr.Items) >= 2 {
|
||||
rowsVal = arr.Items[1]
|
||||
}
|
||||
if err := s.emitResultSet(fieldsVal, rowsVal, sql); err != nil {
|
||||
s.send(buildErrorResponse("XX000", err.Error(), sql))
|
||||
}
|
||||
tag := commandTagFor(sql)
|
||||
s.send(&pgproto3.CommandComplete{CommandTag: []byte(tag)})
|
||||
s.updateTxStatusForTag(tag)
|
||||
s.sendReadyForQuery()
|
||||
}
|
||||
|
||||
// emitResultSet writes RowDescription + a DataRow per source row.
|
||||
func (s *session) emitResultSet(fieldsVal, rowsVal hbrt.Value, sql string) error {
|
||||
if !fieldsVal.IsArray() {
|
||||
return fmt.Errorf("malformed result: first element is %s, expected array", fiveTypeName(fieldsVal))
|
||||
}
|
||||
fields := fieldsVal.AsArray().Items
|
||||
|
||||
// First-row inference: scan the leftmost non-NIL value per
|
||||
// column to pick a stable PG OID. v1.0 sticks to TEXT for
|
||||
// any column with mixed/NIL types — easy upgrade path to
|
||||
// declared schema lookup later.
|
||||
var firstRow []hbrt.Value
|
||||
if rowsVal.IsArray() && len(rowsVal.AsArray().Items) > 0 {
|
||||
if rowsVal.AsArray().Items[0].IsArray() {
|
||||
firstRow = rowsVal.AsArray().Items[0].AsArray().Items
|
||||
}
|
||||
}
|
||||
|
||||
descFields := make([]pgproto3.FieldDescription, len(fields))
|
||||
for i, f := range fields {
|
||||
name := ""
|
||||
if f.IsString() {
|
||||
name = f.AsString()
|
||||
} else {
|
||||
name = fmt.Sprintf("column%d", i+1)
|
||||
}
|
||||
var sample hbrt.Value
|
||||
if i < len(firstRow) {
|
||||
sample = firstRow[i]
|
||||
}
|
||||
oid, typeSize := pgTypeFor(sample)
|
||||
descFields[i] = pgproto3.FieldDescription{
|
||||
Name: []byte(name),
|
||||
TableOID: 0,
|
||||
TableAttributeNumber: 0,
|
||||
DataTypeOID: uint32(oid),
|
||||
DataTypeSize: typeSize,
|
||||
TypeModifier: -1,
|
||||
Format: 0, // text format
|
||||
}
|
||||
}
|
||||
s.send(&pgproto3.RowDescription{Fields: descFields})
|
||||
|
||||
if !rowsVal.IsArray() {
|
||||
return nil
|
||||
}
|
||||
rows := rowsVal.AsArray().Items
|
||||
for _, rowVal := range rows {
|
||||
if !rowVal.IsArray() {
|
||||
continue
|
||||
}
|
||||
cells := rowVal.AsArray().Items
|
||||
out := make([][]byte, len(fields))
|
||||
for i := range fields {
|
||||
if i < len(cells) {
|
||||
out[i] = encodeText(cells[i])
|
||||
}
|
||||
}
|
||||
s.send(&pgproto3.DataRow{Values: out})
|
||||
}
|
||||
_ = sql // reserved for tag generation
|
||||
return nil
|
||||
}
|
||||
|
||||
// isErrorEnvelope detects the `{ {"__error__"}, ... }` shape that
|
||||
// FiveSql2 returns on failure. The header is the very first cell
|
||||
// of the first row.
|
||||
func isErrorEnvelope(arr *hbrt.HbArray) bool {
|
||||
if arr == nil || len(arr.Items) < 1 {
|
||||
return false
|
||||
}
|
||||
hdr := arr.Items[0]
|
||||
if !hdr.IsArray() {
|
||||
return false
|
||||
}
|
||||
items := hdr.AsArray().Items
|
||||
if len(items) == 0 || !items[0].IsString() {
|
||||
return false
|
||||
}
|
||||
return items[0].AsString() == "__error__"
|
||||
}
|
||||
|
||||
// unpackError extracts (code, message, sql) from an error envelope.
|
||||
func unpackError(arr *hbrt.HbArray) (int, string, string) {
|
||||
if len(arr.Items) < 2 || !arr.Items[1].IsArray() {
|
||||
return 0, "unknown error", ""
|
||||
}
|
||||
rows := arr.Items[1].AsArray().Items
|
||||
if len(rows) == 0 || !rows[0].IsArray() {
|
||||
return 0, "unknown error", ""
|
||||
}
|
||||
cells := rows[0].AsArray().Items
|
||||
code := 0
|
||||
msg := ""
|
||||
sql := ""
|
||||
if len(cells) >= 1 && cells[0].IsNumeric() {
|
||||
code = int(cells[0].AsNumInt())
|
||||
}
|
||||
if len(cells) >= 2 && cells[1].IsString() {
|
||||
msg = cells[1].AsString()
|
||||
}
|
||||
if len(cells) >= 3 && cells[2].IsString() {
|
||||
sql = cells[2].AsString()
|
||||
}
|
||||
return code, msg, sql
|
||||
}
|
||||
|
||||
// commandTagFor builds the PG-style command tag string. Format:
|
||||
//
|
||||
// "SELECT n" (for SELECT)
|
||||
// "INSERT 0 n" (oid + row count; oid is always 0 in PG ≥ 12)
|
||||
// "UPDATE n"
|
||||
// "DELETE n"
|
||||
// "BEGIN" / "COMMIT" / "ROLLBACK" (verbatim)
|
||||
//
|
||||
// v1.0-skeleton: emits a verb-only tag; row counts come in the
|
||||
// SimpleQuery commit when we have streaming row counters.
|
||||
func commandTagFor(sql string) string {
|
||||
verb := strings.ToUpper(strings.SplitN(strings.TrimSpace(sql), " ", 2)[0])
|
||||
switch verb {
|
||||
case "SELECT", "INSERT", "UPDATE", "DELETE":
|
||||
return verb + " 0"
|
||||
case "BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE":
|
||||
return verb
|
||||
default:
|
||||
return verb
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) updateTxStatusForTag(tag string) {
|
||||
switch {
|
||||
case strings.HasPrefix(tag, "BEGIN"):
|
||||
s.txStatus = 'T'
|
||||
case strings.HasPrefix(tag, "COMMIT"), strings.HasPrefix(tag, "ROLLBACK"):
|
||||
s.txStatus = 'I'
|
||||
}
|
||||
}
|
||||
|
||||
func currentTxStatusAfterError(prev byte) byte {
|
||||
if prev == 'T' {
|
||||
return 'E' // failed transaction — client must ROLLBACK
|
||||
}
|
||||
return prev
|
||||
}
|
||||
|
||||
// fiveTypeName returns a printable name for the underlying Five
|
||||
// value's runtime tag, used in diagnostic messages.
|
||||
func fiveTypeName(v hbrt.Value) string {
|
||||
switch {
|
||||
case v.IsNil():
|
||||
return "NIL"
|
||||
case v.IsString():
|
||||
return "STRING"
|
||||
case v.IsNumeric():
|
||||
return "NUMERIC"
|
||||
case v.IsArray():
|
||||
return "ARRAY"
|
||||
case v.IsHash():
|
||||
return "HASH"
|
||||
case v.IsLogical():
|
||||
return "LOGICAL"
|
||||
case v.IsDate():
|
||||
return "DATE"
|
||||
case v.IsTimestamp():
|
||||
return "TIMESTAMP"
|
||||
default:
|
||||
return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
68
hbrtl/pgserver/errmap.go
Normal file
68
hbrtl/pgserver/errmap.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
||||
// All rights reserved.
|
||||
|
||||
package pgserver
|
||||
|
||||
import "github.com/jackc/pgx/v5/pgproto3"
|
||||
|
||||
// sqlStateFor maps a FiveSql2 error code (from FiveSqlDef.ch
|
||||
// SQL_ERR_*) to a PostgreSQL SQLSTATE 5-char identifier so
|
||||
// clients can dispatch error handlers in their native idiom.
|
||||
//
|
||||
// Codes that don't have a clean PG equivalent fall back to XX000
|
||||
// (internal_error) — the universal "something went wrong" bucket.
|
||||
func sqlStateFor(code int) string {
|
||||
// FiveSqlDef.ch numeric mapping (kept in sync with the .ch file):
|
||||
// 1 SQL_ERR_SYNTAX → 42601 syntax_error
|
||||
// 2 SQL_ERR_TABLE → 42P01 undefined_table
|
||||
// 3 SQL_ERR_COLUMN → 42703 undefined_column
|
||||
// 4 SQL_ERR_TYPE → 42804 datatype_mismatch
|
||||
// 5 SQL_ERR_UNIQUE → 23505 unique_violation
|
||||
// 6 SQL_ERR_CHECK → 23514 check_violation
|
||||
// 7 SQL_ERR_FK → 23503 foreign_key_violation
|
||||
// 8 SQL_ERR_TXN → 25P02 in_failed_sql_transaction
|
||||
// 9 SQL_ERR_PERM → 42501 insufficient_privilege
|
||||
// 10 SQL_ERR_NOTFOUND → 02000 no_data
|
||||
switch code {
|
||||
case 1:
|
||||
return "42601"
|
||||
case 2:
|
||||
return "42P01"
|
||||
case 3:
|
||||
return "42703"
|
||||
case 4:
|
||||
return "42804"
|
||||
case 5:
|
||||
return "23505"
|
||||
case 6:
|
||||
return "23514"
|
||||
case 7:
|
||||
return "23503"
|
||||
case 8:
|
||||
return "25P02"
|
||||
case 9:
|
||||
return "42501"
|
||||
case 10:
|
||||
return "02000"
|
||||
default:
|
||||
return "XX000"
|
||||
}
|
||||
}
|
||||
|
||||
// buildErrorResponse assembles a pgproto3.ErrorResponse from the
|
||||
// triplet FiveSql2 surfaces. Optional Query field carries the
|
||||
// offending SQL so error-aware clients (pgAdmin, DataGrip) can
|
||||
// highlight the source.
|
||||
func buildErrorResponse(sqlState, message, query string) *pgproto3.ErrorResponse {
|
||||
resp := &pgproto3.ErrorResponse{
|
||||
Severity: "ERROR",
|
||||
Code: sqlState,
|
||||
Message: message,
|
||||
}
|
||||
if query != "" {
|
||||
// PG's "InternalQuery" field surfaces in psql with a
|
||||
// "QUERY:" line directly under the error message.
|
||||
resp.InternalQuery = query
|
||||
}
|
||||
return resp
|
||||
}
|
||||
130
hbrtl/pgserver/pgserver_test.go
Normal file
130
hbrtl/pgserver/pgserver_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
||||
// All rights reserved.
|
||||
|
||||
package pgserver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"five/hbrt"
|
||||
)
|
||||
|
||||
// TestEncodeText_Numeric pins the text-format encoding for the four
|
||||
// numeric Five variants psql actually receives. Regressions here
|
||||
// would surface as silently mis-formatted DataRow values that some
|
||||
// clients render and others reject — easier to catch with a focused
|
||||
// unit test than via a psql round-trip.
|
||||
func TestEncodeText_Numeric(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
v hbrt.Value
|
||||
want []byte
|
||||
}{
|
||||
{"int-positive", hbrt.MakeInt(42), []byte("42")},
|
||||
{"int-negative", hbrt.MakeInt(-7), []byte("-7")},
|
||||
{"long", hbrt.MakeLong(9876543210), []byte("9876543210")},
|
||||
// MakeDouble's metadata: (value, len, dec) — dec=2 should
|
||||
// surface as "50000.00" not "50000".
|
||||
{"decimal-2dp", hbrt.MakeDouble(50000.0, 10, 2), []byte("50000.00")},
|
||||
{"decimal-fraction", hbrt.MakeDouble(42000.5, 10, 2), []byte("42000.50")},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := encodeText(tc.v)
|
||||
if !bytes.Equal(got, tc.want) {
|
||||
t.Errorf("encodeText: want %q, got %q", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEncodeText_Strings covers the trivial case but also the NIL
|
||||
// → nil-slice contract that DataRow uses to distinguish NULL from
|
||||
// empty string ("" sends length=0; NIL sends length=-1).
|
||||
func TestEncodeText_Strings(t *testing.T) {
|
||||
if got := encodeText(hbrt.MakeString("hello")); !bytes.Equal(got, []byte("hello")) {
|
||||
t.Errorf("string encode: got %q", got)
|
||||
}
|
||||
if got := encodeText(hbrt.MakeString("")); got == nil {
|
||||
t.Error("empty string must encode as []byte{}, not nil (NULL marker)")
|
||||
}
|
||||
if got := encodeText(hbrt.MakeNil()); got != nil {
|
||||
t.Errorf("NIL must encode as nil slice (PG NULL marker), got %q", got)
|
||||
}
|
||||
if got := encodeText(hbrt.MakeBool(true)); !bytes.Equal(got, []byte("t")) {
|
||||
t.Errorf("bool true: got %q", got)
|
||||
}
|
||||
if got := encodeText(hbrt.MakeBool(false)); !bytes.Equal(got, []byte("f")) {
|
||||
t.Errorf("bool false: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPgTypeFor verifies OID selection for the column-type
|
||||
// detection path. Integer-shaped numerics that fit int32 must
|
||||
// transit as INT4 so BI tools display them right-aligned with
|
||||
// no decimal point.
|
||||
func TestPgTypeFor(t *testing.T) {
|
||||
type ent struct {
|
||||
v hbrt.Value
|
||||
wantOID uint32
|
||||
}
|
||||
for i, tc := range []ent{
|
||||
{hbrt.MakeInt(0), oidInt4},
|
||||
{hbrt.MakeInt(2147483647), oidInt4},
|
||||
{hbrt.MakeLong(9999999999), oidInt8},
|
||||
{hbrt.MakeDouble(1.5, 10, 2), oidNumeric},
|
||||
{hbrt.MakeString("x"), oidText},
|
||||
{hbrt.MakeBool(true), oidBool},
|
||||
{hbrt.MakeNil(), oidText}, // fallback when no sample
|
||||
} {
|
||||
oid, _ := pgTypeFor(tc.v)
|
||||
if oid != tc.wantOID {
|
||||
t.Errorf("case %d: want oid %d, got %d", i, tc.wantOID, oid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSqlStateFor verifies the FiveSql2-error-code → SQLSTATE map.
|
||||
// Drivers dispatch on the leading two chars (class code), so the
|
||||
// table needs to match the canonical PG layout for libpq-style
|
||||
// exception handling to work.
|
||||
func TestSqlStateFor(t *testing.T) {
|
||||
want := map[int]string{
|
||||
1: "42601",
|
||||
2: "42P01",
|
||||
3: "42703",
|
||||
8: "25P02",
|
||||
99: "XX000",
|
||||
}
|
||||
for code, expect := range want {
|
||||
got := sqlStateFor(code)
|
||||
if got != expect {
|
||||
t.Errorf("sqlStateFor(%d) = %q, want %q", code, got, expect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommandTagFor pins the CommandComplete tag verbs. Tagged
|
||||
// rows (n) come in Phase 3; for v1.0 we always emit "VERB 0" so
|
||||
// psql-style row-count display works (it prints "(0 행)" but
|
||||
// doesn't error out).
|
||||
func TestCommandTagFor(t *testing.T) {
|
||||
cases := []struct{ sql, want string }{
|
||||
{"SELECT * FROM x", "SELECT 0"},
|
||||
{" select 1", "SELECT 0"},
|
||||
{"INSERT INTO x VALUES (1)", "INSERT 0"},
|
||||
{"UPDATE x SET a=1", "UPDATE 0"},
|
||||
{"DELETE FROM x", "DELETE 0"},
|
||||
{"BEGIN", "BEGIN"},
|
||||
{"COMMIT", "COMMIT"},
|
||||
{"CREATE TABLE foo (x INT)", "CREATE"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := commandTagFor(c.sql); got != c.want {
|
||||
t.Errorf("commandTagFor(%q) = %q, want %q", c.sql, got, c.want)
|
||||
}
|
||||
}
|
||||
_ = strconv.Itoa // keep import; will be used in Phase 3 with row counts
|
||||
}
|
||||
86
hbrtl/pgserver/register.go
Normal file
86
hbrtl/pgserver/register.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
||||
// All rights reserved.
|
||||
|
||||
package pgserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"five/hbrt"
|
||||
)
|
||||
|
||||
// init wires the PG-server entry points into the runtime as
|
||||
// HB_FUNCs. Importing this package (e.g. via _ "five/hbrtl/pgserver"
|
||||
// from hbrtl's bootstrap) is enough; PRG code then sees:
|
||||
//
|
||||
// pg_server_start( nPort | cAddr [, cAuthMode ] )
|
||||
// → starts server, blocks
|
||||
// (call inside SPAWN to keep
|
||||
// the calling thread free)
|
||||
// pg_server_stop() → closes the active server
|
||||
//
|
||||
// Embedded callers compose this with their own DBF setup:
|
||||
//
|
||||
// #include "FiveSqlDef.ch"
|
||||
// PROCEDURE Main()
|
||||
// USE customers SHARED
|
||||
// USE orders SHARED NEW
|
||||
// pg_server_start( 5432 ) /* blocks; psql can now connect */
|
||||
// RETURN
|
||||
func init() {
|
||||
hbrt.HB_FUNC("PG_SERVER_START", pgServerStart)
|
||||
hbrt.HB_FUNC("PG_SERVER_STOP", pgServerStop)
|
||||
}
|
||||
|
||||
func pgServerStart(ctx *hbrt.HBContext) {
|
||||
listen := ":5432"
|
||||
if ctx.PCount() >= 1 {
|
||||
if ctx.IsNumeric(1) {
|
||||
listen = ":" + strconv.Itoa(ctx.ParNI(1))
|
||||
} else if ctx.IsChar(1) {
|
||||
listen = ctx.ParC(1)
|
||||
}
|
||||
}
|
||||
cfg := Config{Listen: listen}
|
||||
if ctx.PCount() >= 2 && ctx.IsChar(2) {
|
||||
cfg.AuthMode = ctx.ParC(2)
|
||||
}
|
||||
srv := NewServer(ctx.T.VM(), cfg)
|
||||
setActiveServer(srv)
|
||||
fmt.Fprintf(os.Stderr, "pgserver: listening on %s (auth=%s)\n",
|
||||
cfg.listenAddr(), defaultStr(cfg.AuthMode, "trust"))
|
||||
if err := srv.Serve(context.Background()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "pgserver: %v\n", err)
|
||||
}
|
||||
ctx.RetNil()
|
||||
}
|
||||
|
||||
func pgServerStop(ctx *hbrt.HBContext) {
|
||||
if srv := takeActiveServer(); srv != nil {
|
||||
_ = srv.Close()
|
||||
}
|
||||
ctx.RetNil()
|
||||
}
|
||||
|
||||
func defaultStr(s, fallback string) string {
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// activeServer tracks the most recently started server so
|
||||
// pg_server_stop() can find it without the PRG layer needing to
|
||||
// hold a handle. v1.0 is single-server-per-process; a future
|
||||
// upgrade can swap this for a slice.
|
||||
var activeServerSlot *Server
|
||||
|
||||
func setActiveServer(s *Server) { activeServerSlot = s }
|
||||
func takeActiveServer() *Server {
|
||||
s := activeServerSlot
|
||||
activeServerSlot = nil
|
||||
return s
|
||||
}
|
||||
203
hbrtl/pgserver/server.go
Normal file
203
hbrtl/pgserver/server.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
||||
// All rights reserved.
|
||||
|
||||
// Package pgserver implements a PostgreSQL-wire-protocol-compatible
|
||||
// TCP/IP frontend for FiveSql2. The protocol layer is provided by
|
||||
// github.com/jackc/pgx/v5/pgproto3 (the same library pgx itself uses
|
||||
// internally); this package wires it into Five's runtime so that
|
||||
// psql / pgx / JDBC / DBeaver / Tableau and any other PostgreSQL
|
||||
// driver can connect to a Five process as if it were Postgres.
|
||||
//
|
||||
// Lifecycle
|
||||
//
|
||||
// five pgserver --listen :5432 starts Listen() in the main goroutine.
|
||||
// accept() spawns one goroutine per connection. Each connection
|
||||
// owns a *hbrt.Thread + a TSqlSession on the PRG side, so concurrent
|
||||
// clients don't share transaction state or plan caches (see
|
||||
// refactor commit 93cf5c8).
|
||||
//
|
||||
// Scope
|
||||
//
|
||||
// v1.0 ships Simple Query (SELECT/INSERT/UPDATE/DELETE), per-session
|
||||
// BEGIN/COMMIT/ROLLBACK, trust + password/MD5/SCRAM auth, and TLS.
|
||||
// Extended protocol (Parse/Bind/Execute) and pg_catalog shim are
|
||||
// v1.1+. See /Users/charleskwon/.claude/plans/compiled-launching-shore.md.
|
||||
package pgserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"five/hbrt"
|
||||
)
|
||||
|
||||
// Config holds runtime knobs for the server. All fields optional;
|
||||
// zero values yield a trust-auth, no-TLS server bound to :5432.
|
||||
type Config struct {
|
||||
// Listen is the bind address (":5432" by default).
|
||||
Listen string
|
||||
|
||||
// MaxConnections caps concurrent accepted sessions. Excess
|
||||
// connections block in accept() until a slot frees. 0 → 100.
|
||||
MaxConnections int
|
||||
|
||||
// AuthMode: "trust" (default), "password", "md5", "scram-sha-256".
|
||||
// Roles + password hashes are loaded from __five_roles.dbf when
|
||||
// not in trust mode; see auth.go.
|
||||
AuthMode string
|
||||
|
||||
// TLSConfig, if non-nil, is presented when a client sends
|
||||
// SSLRequest. tls.go provides helpers for loading a cert/key
|
||||
// pair or auto-generating a self-signed pair for dev.
|
||||
TLSConfig *tls.Config
|
||||
|
||||
// ParameterStatus values broadcast right after auth. Five
|
||||
// announces itself as PostgreSQL 14 by default so probing
|
||||
// clients (pgx, JDBC) negotiate without erroring out on
|
||||
// unsupported features.
|
||||
ServerVersion string // default "14.0 (FiveSql2)"
|
||||
}
|
||||
|
||||
func (c *Config) listenAddr() string {
|
||||
if c.Listen == "" {
|
||||
return ":5432"
|
||||
}
|
||||
return c.Listen
|
||||
}
|
||||
|
||||
func (c *Config) maxConns() int {
|
||||
if c.MaxConnections <= 0 {
|
||||
return 100
|
||||
}
|
||||
return c.MaxConnections
|
||||
}
|
||||
|
||||
func (c *Config) serverVersion() string {
|
||||
if c.ServerVersion == "" {
|
||||
return "14.0 (FiveSql2)"
|
||||
}
|
||||
return c.ServerVersion
|
||||
}
|
||||
|
||||
// Server is the live server state. Create via NewServer and drive
|
||||
// with Serve(); Close() drains in-flight connections gracefully.
|
||||
type Server struct {
|
||||
vm *hbrt.VM
|
||||
cfg Config
|
||||
|
||||
listener net.Listener
|
||||
sem chan struct{} // accept gate, sized to MaxConnections
|
||||
|
||||
mu sync.Mutex
|
||||
conns map[*session]struct{} // live sessions for clean shutdown
|
||||
closed bool
|
||||
closeCh chan struct{}
|
||||
}
|
||||
|
||||
// NewServer constructs an unstarted Server. The hbrt.VM is the
|
||||
// runtime instance whose `FIVE_SQL` function will execute incoming
|
||||
// queries — every accepted connection spawns a fresh thread on this
|
||||
// VM via vm.NewThread() so statics + workarea factory are inherited
|
||||
// (see hbrt/vm.go NewThread, fixed in cde8673 to propagate both).
|
||||
func NewServer(vm *hbrt.VM, cfg Config) *Server {
|
||||
return &Server{
|
||||
vm: vm,
|
||||
cfg: cfg,
|
||||
sem: make(chan struct{}, cfg.maxConns()),
|
||||
conns: make(map[*session]struct{}),
|
||||
closeCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Serve binds the listener and runs the accept loop until Close()
|
||||
// fires or a fatal accept error is hit. Each accepted connection
|
||||
// runs handleConn in its own goroutine.
|
||||
func (s *Server) Serve(ctx context.Context) error {
|
||||
if s.vm == nil {
|
||||
return fmt.Errorf("pgserver: nil VM")
|
||||
}
|
||||
ln, err := net.Listen("tcp", s.cfg.listenAddr())
|
||||
if err != nil {
|
||||
return fmt.Errorf("pgserver: listen %s: %w", s.cfg.listenAddr(), err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.listener = ln
|
||||
s.mu.Unlock()
|
||||
|
||||
// Stop the listener when ctx cancels so Accept() returns.
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-s.closeCh:
|
||||
}
|
||||
_ = ln.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
closed := s.closed
|
||||
s.mu.Unlock()
|
||||
if closed {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("pgserver: accept: %w", err)
|
||||
}
|
||||
// Backpressure: block here when MaxConnections sessions are
|
||||
// already in flight. Holding the slot through handleConn
|
||||
// (released in defer) is the natural way to gate.
|
||||
s.sem <- struct{}{}
|
||||
go func(c net.Conn) {
|
||||
defer func() { <-s.sem }()
|
||||
s.handleConn(ctx, c)
|
||||
}(conn)
|
||||
}
|
||||
}
|
||||
|
||||
// Close signals the accept loop to exit, drops the listener, and
|
||||
// closes every in-flight connection. Outstanding handleConn
|
||||
// goroutines exit on the first failed Receive.
|
||||
func (s *Server) Close() error {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
ln := s.listener
|
||||
conns := make([]*session, 0, len(s.conns))
|
||||
for c := range s.conns {
|
||||
conns = append(conns, c)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
close(s.closeCh)
|
||||
if ln != nil {
|
||||
_ = ln.Close()
|
||||
}
|
||||
for _, c := range conns {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleConn is the entry point for each accepted connection.
|
||||
// The full implementation (TLS upgrade, startup handshake, auth,
|
||||
// query loop) lives in session.go; this function just allocates
|
||||
// the session and tracks it for clean shutdown.
|
||||
func (s *Server) handleConn(ctx context.Context, conn net.Conn) {
|
||||
sess := newSession(s, conn)
|
||||
s.mu.Lock()
|
||||
s.conns[sess] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
delete(s.conns, sess)
|
||||
s.mu.Unlock()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
sess.run(ctx)
|
||||
}
|
||||
258
hbrtl/pgserver/session.go
Normal file
258
hbrtl/pgserver/session.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
||||
// All rights reserved.
|
||||
|
||||
package pgserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgproto3"
|
||||
|
||||
"five/hbrt"
|
||||
)
|
||||
|
||||
// session is the per-connection state. One goroutine per session;
|
||||
// nothing in session is touched by anyone else, so all fields are
|
||||
// access-by-owner without locking.
|
||||
type session struct {
|
||||
srv *Server
|
||||
conn net.Conn
|
||||
|
||||
// pgproto3 Backend speaks the PG protocol on the wire. Created
|
||||
// after the TLS-upgrade decision so handshake bytes go through
|
||||
// the right transport.
|
||||
be *pgproto3.Backend
|
||||
|
||||
// Authenticated identity. Empty before AuthenticationOk.
|
||||
user string
|
||||
database string
|
||||
|
||||
// Per-connection hbrt.Thread, used to dispatch FIVE_SQL calls
|
||||
// inside the PRG world. Owns a TSqlSession on the PRG side
|
||||
// (created lazily by the first query via five_SQL's default-
|
||||
// session fallback; for true isolation we instantiate one
|
||||
// explicitly — see queryLoop).
|
||||
thread *hbrt.Thread
|
||||
|
||||
// PRG-side session value: a TSqlSession instance held in a
|
||||
// thread-local hbrt slot. Allocated on first query.
|
||||
prgSession hbrt.Value
|
||||
|
||||
// Cancellation key — PG protocol mandates random 32-bit
|
||||
// process id + secret in BackendKeyData so clients can send
|
||||
// CancelRequest later. We don't honour cancel yet (v1.1),
|
||||
// but the BackendKeyData must still be present and unique.
|
||||
pid uint32
|
||||
secret []byte // 4 random bytes per PG BackendKeyData
|
||||
|
||||
// Current transaction status, emitted in every ReadyForQuery:
|
||||
// 'I' = idle, 'T' = in transaction, 'E' = failed transaction.
|
||||
txStatus byte
|
||||
}
|
||||
|
||||
func newSession(srv *Server, conn net.Conn) *session {
|
||||
var pid uint32
|
||||
_ = binary.Read(rand.Reader, binary.BigEndian, &pid)
|
||||
secret := make([]byte, 4)
|
||||
_, _ = rand.Read(secret)
|
||||
return &session{
|
||||
srv: srv,
|
||||
conn: conn,
|
||||
pid: pid,
|
||||
secret: secret,
|
||||
txStatus: 'I',
|
||||
}
|
||||
}
|
||||
|
||||
// run drives the full session lifecycle:
|
||||
//
|
||||
// 1. SSLRequest / GSSEncRequest probe (reply 'N' for v1.0)
|
||||
// 2. StartupMessage parse
|
||||
// 3. Auth (trust path for v1.0; password/MD5/SCRAM in Phase 5)
|
||||
// 4. ParameterStatus broadcast
|
||||
// 5. BackendKeyData
|
||||
// 6. ReadyForQuery
|
||||
// 7. query loop until Terminate / EOF / error
|
||||
//
|
||||
// Any error in steps 1-3 aborts the session before query loop
|
||||
// begins; an ErrorResponse goes back if at all possible.
|
||||
func (s *session) run(ctx context.Context) {
|
||||
// Step 1 — SSLRequest peek. Client sends 8 bytes: length(8) +
|
||||
// special version 80877103 to ask for TLS; or it skips this and
|
||||
// goes straight to StartupMessage. The pgproto3 Backend doesn't
|
||||
// abstract this, so we peek the raw bytes first.
|
||||
if err := s.negotiateTLS(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.be = pgproto3.NewBackend(s.conn, s.conn)
|
||||
|
||||
// Step 2 — StartupMessage. After SSLRequest the client retries
|
||||
// with a fresh length-prefixed startup. ReceiveStartupMessage
|
||||
// handles either form transparently.
|
||||
startupMsg, err := s.be.ReceiveStartupMessage()
|
||||
if err != nil {
|
||||
return // client gone
|
||||
}
|
||||
startup, ok := startupMsg.(*pgproto3.StartupMessage)
|
||||
if !ok {
|
||||
s.sendError("08P01", fmt.Sprintf("unexpected startup message: %T", startupMsg))
|
||||
return
|
||||
}
|
||||
s.user = startup.Parameters["user"]
|
||||
s.database = startup.Parameters["database"]
|
||||
if s.database == "" {
|
||||
s.database = s.user
|
||||
}
|
||||
|
||||
// Step 3 — Auth. v1.0 lands `trust`; password/md5/scram in
|
||||
// Phase 5 via auth.go.
|
||||
if err := s.authenticate(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Step 4 — ParameterStatus. These tell the client our identity
|
||||
// and default formatting so it doesn't try features we don't
|
||||
// have. server_version is the most-checked field; pgx + JDBC
|
||||
// negotiate features based on its numeric prefix.
|
||||
s.sendParameterStatus("server_version", s.srv.cfg.serverVersion())
|
||||
s.sendParameterStatus("server_encoding", "UTF8")
|
||||
s.sendParameterStatus("client_encoding", "UTF8")
|
||||
s.sendParameterStatus("DateStyle", "ISO, MDY")
|
||||
s.sendParameterStatus("TimeZone", "UTC")
|
||||
s.sendParameterStatus("integer_datetimes", "on")
|
||||
s.sendParameterStatus("standard_conforming_strings", "on")
|
||||
|
||||
// Step 5 — BackendKeyData. Some clients (psql) expect this
|
||||
// even though we don't honour CancelRequest yet.
|
||||
s.send(&pgproto3.BackendKeyData{ProcessID: s.pid, SecretKey: s.secret})
|
||||
|
||||
// Step 6 — ReadyForQuery, transaction status idle.
|
||||
s.sendReadyForQuery()
|
||||
|
||||
// Step 7 — query loop. Each iteration:
|
||||
// - Receive a frontend message
|
||||
// - Dispatch (Query / Parse / Bind / Execute / Sync / Terminate / Close / Describe)
|
||||
// - Send response stream
|
||||
// - Loop
|
||||
s.queryLoop(ctx)
|
||||
}
|
||||
|
||||
// negotiateTLS reads the first 8-byte preamble. If it's an
|
||||
// SSLRequest (length=8, version=80877103) and the server has a
|
||||
// TLS config, we reply 'S' and upgrade; otherwise 'N' and the
|
||||
// client retries unencrypted (or hangs up if it required TLS).
|
||||
func (s *session) negotiateTLS() error {
|
||||
var hdr [8]byte
|
||||
if _, err := io.ReadFull(s.conn, hdr[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
length := binary.BigEndian.Uint32(hdr[0:4])
|
||||
version := binary.BigEndian.Uint32(hdr[4:8])
|
||||
const sslReqCode = 80877103
|
||||
|
||||
if length == 8 && version == sslReqCode {
|
||||
if s.srv.cfg.TLSConfig != nil {
|
||||
if _, err := s.conn.Write([]byte{'S'}); err != nil {
|
||||
return err
|
||||
}
|
||||
// tls.go implements the upgrade; v1.0 stub returns
|
||||
// the connection unchanged so the byte ordering is
|
||||
// correct but cipher negotiation isn't wired yet.
|
||||
upgraded, err := upgradeToTLS(s.conn, s.srv.cfg.TLSConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.conn = upgraded
|
||||
return nil
|
||||
}
|
||||
if _, err := s.conn.Write([]byte{'N'}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Not an SSLRequest — the 8 bytes are the start of a
|
||||
// StartupMessage. Buffer them so pgproto3.ReceiveStartupMessage
|
||||
// sees the full payload.
|
||||
s.conn = &prefixedConn{Conn: s.conn, prefix: hdr[:]}
|
||||
return nil
|
||||
}
|
||||
|
||||
// send writes a single backend message to the wire.
|
||||
func (s *session) send(msg pgproto3.BackendMessage) {
|
||||
s.be.Send(msg)
|
||||
_ = s.be.Flush()
|
||||
}
|
||||
|
||||
func (s *session) sendParameterStatus(name, value string) {
|
||||
s.send(&pgproto3.ParameterStatus{Name: name, Value: value})
|
||||
}
|
||||
|
||||
func (s *session) sendReadyForQuery() {
|
||||
s.send(&pgproto3.ReadyForQuery{TxStatus: s.txStatus})
|
||||
}
|
||||
|
||||
// sendError ships an ErrorResponse without forcing the caller to
|
||||
// build the full pgproto3 struct. Used for protocol-level errors
|
||||
// before the query loop starts; in-loop errors go through errmap.go.
|
||||
func (s *session) sendError(sqlState, message string) {
|
||||
s.send(&pgproto3.ErrorResponse{
|
||||
Severity: "ERROR",
|
||||
Code: sqlState,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// queryLoop runs until the client sends Terminate, closes the
|
||||
// connection, or we hit an unrecoverable error.
|
||||
func (s *session) queryLoop(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
msg, err := s.be.Receive()
|
||||
if err != nil {
|
||||
return // client gone or wire-level error
|
||||
}
|
||||
switch m := msg.(type) {
|
||||
case *pgproto3.Terminate:
|
||||
return
|
||||
case *pgproto3.Query:
|
||||
s.dispatchSimpleQuery(strings.TrimSpace(m.String))
|
||||
default:
|
||||
// v1.0 ignores Extended-protocol messages with a
|
||||
// loud diagnostic so clients see they're unsupported
|
||||
// instead of hanging on a silent stall.
|
||||
s.sendError("0A000",
|
||||
fmt.Sprintf("message %T not supported in this protocol version (Simple Query only)", m))
|
||||
s.sendReadyForQuery()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// prefixedConn injects pre-read bytes back into a net.Conn so a
|
||||
// caller that needs those bytes for parsing (pgproto3 reading the
|
||||
// StartupMessage after our SSLRequest peek) sees them seamlessly.
|
||||
type prefixedConn struct {
|
||||
net.Conn
|
||||
prefix []byte
|
||||
off int
|
||||
}
|
||||
|
||||
func (p *prefixedConn) Read(buf []byte) (int, error) {
|
||||
if p.off < len(p.prefix) {
|
||||
n := copy(buf, p.prefix[p.off:])
|
||||
p.off += n
|
||||
return n, nil
|
||||
}
|
||||
return p.Conn.Read(buf)
|
||||
}
|
||||
24
hbrtl/pgserver/tls.go
Normal file
24
hbrtl/pgserver/tls.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
||||
// All rights reserved.
|
||||
|
||||
package pgserver
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net"
|
||||
)
|
||||
|
||||
// upgradeToTLS wraps the underlying net.Conn in a tls.Server using
|
||||
// the configured *tls.Config and performs the TLS handshake. The
|
||||
// returned net.Conn is the encrypted stream; pgproto3 sees only
|
||||
// plaintext on top of it.
|
||||
//
|
||||
// Phase 6 expands this with mTLS / SNI / cert pinning. v1.0 just
|
||||
// does the basic upgrade — sufficient for `psql sslmode=require`.
|
||||
func upgradeToTLS(conn net.Conn, cfg *tls.Config) (net.Conn, error) {
|
||||
tlsConn := tls.Server(conn, cfg)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tlsConn, nil
|
||||
}
|
||||
147
hbrtl/pgserver/typemap.go
Normal file
147
hbrtl/pgserver/typemap.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com)
|
||||
// All rights reserved.
|
||||
|
||||
package pgserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"five/hbrt"
|
||||
)
|
||||
|
||||
// PostgreSQL OID constants for the types FiveSql2 surfaces. Values
|
||||
// match the canonical pg_type entries — psql and most drivers key
|
||||
// their decoders off these.
|
||||
const (
|
||||
oidBool = 16
|
||||
oidInt4 = 23
|
||||
oidInt8 = 20
|
||||
oidNumeric = 1700
|
||||
oidText = 25
|
||||
oidDate = 1082
|
||||
oidTimestamp = 1114
|
||||
)
|
||||
|
||||
// pgTypeFor returns (OID, declared-size). The declared size is -1
|
||||
// for variable-width types (per PG convention). Sample is one
|
||||
// representative value from the column; NIL falls back to TEXT
|
||||
// because we don't have schema info at this layer.
|
||||
func pgTypeFor(sample hbrt.Value) (oid uint32, size int16) {
|
||||
switch {
|
||||
case sample.IsLogical():
|
||||
return oidBool, 1
|
||||
case sample.IsNumeric():
|
||||
// Distinguish integer-ish from decimal so int columns
|
||||
// transit as INT4/INT8 (faster, BI tools render nicely).
|
||||
if isIntegerNumeric(sample) {
|
||||
n := sample.AsNumInt()
|
||||
if n >= -2147483648 && n <= 2147483647 {
|
||||
return oidInt4, 4
|
||||
}
|
||||
return oidInt8, 8
|
||||
}
|
||||
return oidNumeric, -1
|
||||
case sample.IsDate():
|
||||
return oidDate, 4
|
||||
case sample.IsTimestamp():
|
||||
return oidTimestamp, 8
|
||||
case sample.IsString():
|
||||
return oidText, -1
|
||||
default:
|
||||
// NIL or unknown — claim TEXT so the client decodes
|
||||
// whatever we send as a string. Real schema-aware OID
|
||||
// dispatch lands with extended-protocol Describe support
|
||||
// in v1.1.
|
||||
return oidText, -1
|
||||
}
|
||||
}
|
||||
|
||||
// isIntegerNumeric distinguishes a whole-number Numeric from one
|
||||
// that's been declared with decimals. Five's hbrt.Value carries
|
||||
// the length/dec hints separately for printf-style formatting; if
|
||||
// `dec` is 0, the source field declared no decimal places.
|
||||
func isIntegerNumeric(v hbrt.Value) bool {
|
||||
// Fast path: tag-level integer subtype.
|
||||
if v.IsInt() || v.IsLong() {
|
||||
return true
|
||||
}
|
||||
// AsNumDouble rounding check — within 1e-9 of an integer, no
|
||||
// declared decimal places.
|
||||
d := v.AsNumDouble()
|
||||
if d != float64(int64(d)) {
|
||||
return false
|
||||
}
|
||||
return v.Decimal() == 0
|
||||
}
|
||||
|
||||
// encodeText writes one cell as a text-format PG byte slice. NULL
|
||||
// is signalled by returning nil (DataRow distinguishes nil from
|
||||
// empty []byte and sends length=-1 vs length=0 accordingly).
|
||||
func encodeText(v hbrt.Value) []byte {
|
||||
if v.IsNil() {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case v.IsLogical():
|
||||
if v.AsBool() {
|
||||
return []byte{'t'}
|
||||
}
|
||||
return []byte{'f'}
|
||||
case v.IsNumeric():
|
||||
if isIntegerNumeric(v) {
|
||||
return []byte(strconv.FormatInt(v.AsNumInt(), 10))
|
||||
}
|
||||
precision := int(v.Decimal())
|
||||
if precision <= 0 || precision > 30 {
|
||||
precision = 10
|
||||
}
|
||||
return []byte(strconv.FormatFloat(v.AsNumDouble(), 'f', precision, 64))
|
||||
case v.IsDate():
|
||||
y, m, d := julianToYMD(v.AsJulian())
|
||||
return []byte(fmt.Sprintf("%04d-%02d-%02d", y, m, d))
|
||||
case v.IsTimestamp():
|
||||
// Convert Julian + ms to a Go time and format.
|
||||
jul := v.AsJulian()
|
||||
y, mo, d := julianToYMD(jul)
|
||||
ms := v.AsTimeMs()
|
||||
hh := ms / 3600000
|
||||
ms -= hh * 3600000
|
||||
mm := ms / 60000
|
||||
ms -= mm * 60000
|
||||
ss := ms / 1000
|
||||
ms -= ss * 1000
|
||||
return []byte(fmt.Sprintf("%04d-%02d-%02d %02d:%02d:%02d.%03d", y, mo, d, hh, mm, ss, ms))
|
||||
case v.IsString():
|
||||
return []byte(v.AsString())
|
||||
default:
|
||||
// Fallback: best-effort string conversion.
|
||||
return []byte(fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
||||
// julianToYMD converts an integer Julian day number to (year, month,
|
||||
// day). Matches Harbour's hb_dateDecode algorithm used elsewhere in
|
||||
// the runtime; duplicated here to avoid pulling in hbrtl which
|
||||
// would create an import cycle.
|
||||
func julianToYMD(j int64) (year, month, day int) {
|
||||
if j <= 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
a := j + 32044
|
||||
b := (4*a + 3) / 146097
|
||||
c := a - (b*146097)/4
|
||||
d := (4*c + 3) / 1461
|
||||
e := c - (1461*d)/4
|
||||
m := (5*e + 2) / 153
|
||||
day = int(e - (153*m+2)/5 + 1)
|
||||
month = int(m + 3 - 12*(m/10))
|
||||
year = int(b*100 + d - 4800 + (m / 10))
|
||||
return
|
||||
}
|
||||
|
||||
// Force time package import — we'll need it for Timestamp parsing
|
||||
// when extended protocol lands. Stub function keeps the import
|
||||
// from being pruned in v1.0-skeleton.
|
||||
var _ = time.Date
|
||||
@@ -8,6 +8,7 @@ package hbrtl
|
||||
import (
|
||||
"five/hbrdd"
|
||||
"five/hbrt"
|
||||
_ "five/hbrtl/pgserver" // registers pg_server_start / pg_server_stop HB_FUNCs
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user