Database Connector Component
A complete example of a database component with connection pooling, modeled on the postgres_query component from database-module.
Overview
This component executes SQL SELECT queries against Postgres. It demonstrates:
- Process-wide connection pool caching keyed by DSN
- Parameterized queries
- A settings-gated error port with the canonical error shape
- Retryability marking for transient database failures
Complete Implementation
Shared Pool Package
Pools live outside the component so every invocation (and every component in the module) reuses the same connection pool per DSN:
// Package pool caches database connections across component invocations.
package pool
import (
"context"
"errors"
"strings"
"sync"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
var pgPools sync.Map // map[string]*pgxpool.Pool
// Postgres returns a cached pgx pool for the given DSN, creating one on first use.
func Postgres(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
if v, ok := pgPools.Load(dsn); ok {
return v.(*pgxpool.Pool), nil
}
p, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, err
}
if actual, loaded := pgPools.LoadOrStore(dsn, p); loaded {
p.Close()
return actual.(*pgxpool.Pool), nil
}
return p, nil
}
// IsTransientPostgres reports whether a Postgres failure could clear on a
// backoff retry. A SQLSTATE reply means the server rejected the statement —
// permanent — except classes describing the server's condition: connection
// exception (08), insufficient resources (53), shutdown in progress
// (57P01-03), serialization/deadlock aborts (40001, 40P01). Anything that is
// not a server reply (dial failure, dropped socket) never ran — transient.
func IsTransientPostgres(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch {
case strings.HasPrefix(pgErr.Code, "08"), strings.HasPrefix(pgErr.Code, "53"),
pgErr.Code == "57P01", pgErr.Code == "57P02", pgErr.Code == "57P03",
pgErr.Code == "40001", pgErr.Code == "40P01":
return true
}
return false
}
return true
}
Component
package postgresquery
import (
"context"
"fmt"
"github.com/myorg/database-module/components/pool"
"github.com/tiny-systems/module/api/v1alpha1"
"github.com/tiny-systems/module/module"
"github.com/tiny-systems/module/registry"
)
const (
ComponentName = "postgres_query"
RequestPort = "request"
ResponsePort = "response"
ErrorPort = "error"
)
type Context any
// Row is a result row keyed by column name, so downstream edges can
// navigate $.rows[0].columnName.
type Row map[string]any
type Settings struct {
EnableErrorPort bool `json:"enableErrorPort" required:"true" title:"Enable Error Port"`
}
type Request struct {
Context Context `json:"context,omitempty" configurable:"true" title:"Context"`
DSN string `json:"dsn" required:"true" title:"DSN" description:"Postgres connection string"`
SQL string `json:"sql" required:"true" minLength:"1" title:"SQL" description:"SELECT with $1, $2, ... placeholders" format:"textarea"`
Params []any `json:"params,omitempty" title:"Params"`
}
type Response struct {
Context Context `json:"context,omitempty" configurable:"true" title:"Context"`
Rows []Row `json:"rows" title:"Rows"`
Count int `json:"count" title:"Count"`
}
type Component struct {
module.Base
settings Settings
}
func (c *Component) Instance() module.Component {
return &Component{}
}
func (c *Component) GetInfo() module.ComponentInfo {
return module.ComponentInfo{
Name: ComponentName,
Description: "Postgres Query",
Info: "Runs SELECT against Postgres and returns rows as a list of objects keyed by column name. Connection pool is cached per DSN.",
Tags: []string{"Postgres", "SQL", "DB"},
}
}
// OnSettings stores the component settings.
func (c *Component) OnSettings(_ context.Context, msg any) error {
in, ok := msg.(Settings)
if !ok {
return fmt.Errorf("invalid settings")
}
c.settings = in
return nil
}
// Handle dispatches the RequestPort. System ports go through capabilities.
func (c *Component) Handle(ctx context.Context, handler module.Handler, port string, msg any) module.Result {
if port != RequestPort {
return module.Fail(fmt.Errorf("unknown port: %s", port))
}
in, ok := msg.(Request)
if !ok {
return module.Fail(fmt.Errorf("invalid request"))
}
return c.query(ctx, handler, in)
}
func (c *Component) query(ctx context.Context, handler module.Handler, in Request) module.Result {
p, err := pool.Postgres(ctx, in.DSN)
if err != nil {
return c.fail(ctx, handler, in.Context, err)
}
rows, err := p.Query(ctx, in.SQL, in.Params...)
if err != nil {
return c.fail(ctx, handler, in.Context, c.retryable(err))
}
defer rows.Close()
cols := rows.FieldDescriptions()
out := make([]Row, 0)
for rows.Next() {
values, err := rows.Values()
if err != nil {
return c.fail(ctx, handler, in.Context, c.retryable(err))
}
row := make(Row, len(cols))
for i, col := range cols {
row[string(col.Name)] = values[i]
}
out = append(out, row)
}
if err := rows.Err(); err != nil {
return c.fail(ctx, handler, in.Context, c.retryable(err))
}
return handler(ctx, ResponsePort, Response{
Context: in.Context,
Rows: out,
Count: len(out),
})
}
// retryable marks a SELECT failure the server or network could recover from.
// Safe here in a way it isn't for writes: this component only reads, so
// re-running the whole handler cannot double-apply anything. A SQL error
// (bad syntax, unknown column, permission denied) is left unmarked — the
// same statement would just fail again.
func (c *Component) retryable(err error) error {
if pool.IsTransientPostgres(err) {
return module.Retryable(err)
}
return err
}
func (c *Component) fail(ctx context.Context, handler module.Handler, reqCtx Context, err error) module.Result {
if !c.settings.EnableErrorPort {
// Bubble unchanged so retryability marked at the call site survives.
return module.Fail(err)
}
// Canonical {context, error, retryable} payload
return handler(ctx, ErrorPort, module.NewError(reqCtx, err))
}
func (c *Component) Ports() []module.Port {
ports := []module.Port{
{Name: v1alpha1.SettingsPort, Label: "Settings", Configuration: c.settings},
{Name: RequestPort, Label: "Request", Configuration: Request{}, Position: module.Left},
{Name: ResponsePort, Label: "Response", Source: true, Configuration: Response{}, Position: module.Right},
}
if !c.settings.EnableErrorPort {
return ports
}
return append(ports, module.Port{
Name: ErrorPort, Label: "Error", Source: true, Configuration: module.ErrorMessage{}, Position: module.Bottom,
})
}
var (
_ module.Component = (*Component)(nil)
_ module.SettingsHandler = (*Component)(nil)
)
func init() {
registry.Register(&Component{})
}
For INSERT/UPDATE/DELETE, database-module ships a separate postgres_exec component — deliberately, because retry semantics differ: re-running a read is always safe, re-running a write is not, so exec must never blanket-mark failures retryable.
Usage Examples
Requests are configured on the incoming edge. Credentials come from secret placeholders, never literals:
SELECT Query
{
"context": "{{$.context}}",
"dsn": "postgres://app:[[secret:db/password]]@db.internal:5432/myapp",
"sql": "SELECT id, name, email FROM users WHERE status = $1 LIMIT $2",
"params": ["active", 100]
}
Parameter Mapping from Upstream Data
{
"dsn": "postgres://app:[[secret:db/password]]@db.internal:5432/myapp",
"sql": "SELECT * FROM orders WHERE user_id = $1",
"params": ["{{$.context.userId}}"]
}
Response Examples
Query Result (on response)
{
"context": { "userId": "42" },
"rows": [
{ "id": 1, "name": "Alice", "email": "alice@example.com" },
{ "id": 2, "name": "Bob", "email": "bob@example.com" }
],
"count": 2
}
Downstream edges navigate $.rows[0].name, $.count, and the passthrough $.context....
Error Result (on error, when enabled)
{
"context": { "userId": "42" },
"error": "FATAL: the database system is starting up (SQLSTATE 57P03)",
"retryable": true
}
Key Patterns Demonstrated
1. Pool Caching Outside the Component
Component instances come and go; the sync.Map of pools lives for the process. LoadOrStore closes the loser of a racing create.
2. Retryability Is a Property of the Error
if pool.IsTransientPostgres(err) {
return module.Retryable(err)
}
The classification lives where the knowledge lives (SQLSTATE class), the marking travels with the error through module.NewError, and the retry component downstream honors it. Unmarked errors are never retried.
3. Error Port Uses module.ErrorMessage
return handler(ctx, ErrorPort, module.NewError(reqCtx, err))
Using the SDK type (rather than a hand-rolled struct) guarantees the {context, error, retryable} shape the retry component and the platform expect.
4. Parameterized Queries Only
rows, err := p.Query(ctx, in.SQL, in.Params...)
Always $1, $2, ... placeholders with params — never interpolate user data into SQL strings on the edge.
Security Best Practices
1. Use Secret Placeholders for Credentials
"dsn": "postgres://app:[[secret:db/password]]@db.internal:5432/myapp"
2. Always Use Parameterized Queries
// GOOD
{ "sql": "SELECT * FROM users WHERE id = $1", "params": ["{{$.context.userId}}"] }
// BAD - SQL injection vulnerable!
{ "sql": "SELECT * FROM users WHERE id = {{$.context.userId}}" }
3. Read/Write Separation
Keep query (read) and exec (write) as separate components so retry policies can differ safely.
Extension Ideas
- Row Shape Settings: let users declare the expected row schema in settings so downstream edges get typed autocompletion (the shipped
postgres_querydoes this) - Transaction Support: a
postgres_txcomponent scoping BEGIN/COMMIT around a subtree - Redis Components: the same pool pattern applies — see
redis_get,redis_set,redis_dedupin database-module - Vector Search:
vector_search/vector_upsertbuild on the same pool package for pgvector