diff --git a/_FiveSql2/src/TSqlSession.prg b/_FiveSql2/src/TSqlSession.prg index bf28c60..4775469 100644 --- a/_FiveSql2/src/TSqlSession.prg +++ b/_FiveSql2/src/TSqlSession.prg @@ -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() diff --git a/go.mod b/go.mod index f598d6b..7b5f539 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9f22e7a --- /dev/null +++ b/go.sum @@ -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= diff --git a/hbrtl/pgserver/auth.go b/hbrtl/pgserver/auth.go new file mode 100644 index 0000000..90ed82f --- /dev/null +++ b/hbrtl/pgserver/auth.go @@ -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") diff --git a/hbrtl/pgserver/dispatch.go b/hbrtl/pgserver/dispatch.go new file mode 100644 index 0000000..42e2512 --- /dev/null +++ b/hbrtl/pgserver/dispatch.go @@ -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" + } +} diff --git a/hbrtl/pgserver/errmap.go b/hbrtl/pgserver/errmap.go new file mode 100644 index 0000000..3306d5a --- /dev/null +++ b/hbrtl/pgserver/errmap.go @@ -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 +} diff --git a/hbrtl/pgserver/pgserver_test.go b/hbrtl/pgserver/pgserver_test.go new file mode 100644 index 0000000..2c49537 --- /dev/null +++ b/hbrtl/pgserver/pgserver_test.go @@ -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 +} diff --git a/hbrtl/pgserver/register.go b/hbrtl/pgserver/register.go new file mode 100644 index 0000000..e2099dd --- /dev/null +++ b/hbrtl/pgserver/register.go @@ -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 +} diff --git a/hbrtl/pgserver/server.go b/hbrtl/pgserver/server.go new file mode 100644 index 0000000..913d49d --- /dev/null +++ b/hbrtl/pgserver/server.go @@ -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) +} diff --git a/hbrtl/pgserver/session.go b/hbrtl/pgserver/session.go new file mode 100644 index 0000000..f2392e3 --- /dev/null +++ b/hbrtl/pgserver/session.go @@ -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) +} diff --git a/hbrtl/pgserver/tls.go b/hbrtl/pgserver/tls.go new file mode 100644 index 0000000..c357f6d --- /dev/null +++ b/hbrtl/pgserver/tls.go @@ -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 +} diff --git a/hbrtl/pgserver/typemap.go b/hbrtl/pgserver/typemap.go new file mode 100644 index 0000000..7e884f3 --- /dev/null +++ b/hbrtl/pgserver/typemap.go @@ -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 diff --git a/hbrtl/register.go b/hbrtl/register.go index c82a79e..954f5ca 100644 --- a/hbrtl/register.go +++ b/hbrtl/register.go @@ -8,6 +8,7 @@ package hbrtl import ( "five/hbrdd" "five/hbrt" + _ "five/hbrtl/pgserver" // registers pg_server_start / pg_server_stop HB_FUNCs "strings" )