Files
zeekay 50a5190bce fix(store): a read-modify-write swaps, so no replica loses another's
Refunds and disputes were recorded under an in-process mutex, and the
renewal guard was a read then a write under another. Two processes over
one store, or a webhook beside the billing cycle, could each read the
invoice and each write it back, the second wiping the first's refund.

A swap writes only while the stored entity's updatedAt is still the one
read (insert-if-absent for a new or deleted entity) and otherwise reads
again: db.Swapper on SQLite and Postgres as one statement,
datastore.Swap, mixin.Model.Swap. Refund and dispute recording swap the
invoice or payment record in, and idempotencykey.Begin swaps its first
sighting and its stale re-claim, so a guarded money move runs once
across replicas. The returns and lockPeriod mutexes are gone.

lockFunds stays: api/transaction mounts only in standalone commerce,
whose store has one writer.

The tests open one store file through two handles, each with its own
write lock: 24 refunds delivered twice to both, with dispute reports
between them, land once each; 32 sightings of one key proceed once. A
blind write in the refund recorder fails the first test every run.
2026-10-09 10:40:59 -07:00

1362 lines
35 KiB
Go

package db
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"sync"
"time"
_ "github.com/lib/pq" // PostgreSQL driver
"github.com/hanzoai/commerce/util/nscontext"
)
// PostgresDBConfig holds configuration for a PostgreSQL database
type PostgresDBConfig struct {
// DSN is the connection string
// Format: postgres://user:pass@host:port/dbname?sslmode=disable
DSN string
// Config for connection pool
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time.Duration
ConnMaxIdleTime time.Duration
// QueryTimeout for queries
QueryTimeout time.Duration
// TenantID for multi-tenant isolation
TenantID string
// TenantType is "user" or "org"
TenantType string
// Schema for tenant isolation (optional)
// If set, uses PostgreSQL schemas for multi-tenancy
Schema string
// EnableVectorSearch enables pgvector extension
EnableVectorSearch bool
// VectorDimensions for embeddings
VectorDimensions int
}
// PostgresDB implements the DB interface using PostgreSQL
type PostgresDB struct {
config *PostgresDBConfig
db *sql.DB
mu sync.RWMutex
closed bool
// Prepared statements for hot-path operations. Every one is tenant-scoped:
// the shared Postgres store is multi-tenant, so a row is addressed by its
// COMPOSITE key (id, kind, tenant_id) and every read/write filters by the
// caller's tenant. There is deliberately NO tenant-less variant — an
// unscoped get/delete would cross tenants (Red CRIT-2).
stmtGet *sql.Stmt // Get: (id, kind, tenant_id)
stmtPut *sql.Stmt // Upsert: ON CONFLICT (id, kind, tenant_id)
stmtDel *sql.Stmt // Soft-delete: (id, kind, tenant_id)
}
// tenantFor resolves the tenant scope for a Postgres operation: the request's
// ctx namespace (per-org isolation — the org == tenant) when present, else the
// DB's configured TenantID (the systemDB default, "system"). This mirrors the
// SQLite store, which scopes every row by the ctx namespace — so the SHARED
// Postgres store can never let org B read, list, or overwrite org A's rows
// (Red CRIT-2). Empty is a valid tenant value (the global/default partition).
func (db *PostgresDB) tenantFor(ctx context.Context) string {
if ns := nscontext.GetNamespace(ctx); ns != "" {
return ns
}
return db.config.TenantID
}
// NewPostgresDB creates a new PostgreSQL database connection
func NewPostgresDB(cfg *PostgresDBConfig) (*PostgresDB, error) {
if cfg == nil || cfg.DSN == "" {
return nil, errors.New("db: PostgresDBConfig with DSN is required")
}
db, err := sql.Open("postgres", cfg.DSN)
if err != nil {
return nil, fmt.Errorf("db: failed to open postgres connection: %w", err)
}
// Configure connection pool
if cfg.MaxOpenConns > 0 {
db.SetMaxOpenConns(cfg.MaxOpenConns)
} else {
db.SetMaxOpenConns(4)
}
if cfg.MaxIdleConns > 0 {
db.SetMaxIdleConns(cfg.MaxIdleConns)
} else {
db.SetMaxIdleConns(2)
}
if cfg.ConnMaxLifetime > 0 {
db.SetConnMaxLifetime(cfg.ConnMaxLifetime)
} else {
db.SetConnMaxLifetime(30 * time.Minute)
}
if cfg.ConnMaxIdleTime > 0 {
db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime)
} else {
db.SetConnMaxIdleTime(5 * time.Minute)
}
// Test connection
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("db: failed to ping postgres: %w", err)
}
pdb := &PostgresDB{
config: cfg,
db: db,
}
// Initialize schema
if err := pdb.initSchema(); err != nil {
db.Close()
return nil, fmt.Errorf("db: failed to initialize schema: %w", err)
}
// Initialize pgvector if enabled
if cfg.EnableVectorSearch {
if err := pdb.initVectorSearch(); err != nil {
// Non-fatal, just log
fmt.Printf("Warning: pgvector not available: %v\n", err)
}
}
// Prepare hot-path statements
if err := pdb.prepareStatements(); err != nil {
db.Close()
return nil, fmt.Errorf("db: failed to prepare statements: %w", err)
}
return pdb, nil
}
// prepareStatements creates prepared statements for frequently-executed queries.
// Every statement is tenant-scoped on the composite key (id, kind, tenant_id).
func (db *PostgresDB) prepareStatements() error {
var err error
db.stmtGet, err = db.db.Prepare(
`SELECT data FROM _entities WHERE id = $1 AND kind = $2 AND tenant_id = $3 AND deleted = FALSE`)
if err != nil {
return fmt.Errorf("prepare stmtGet: %w", err)
}
db.stmtPut, err = db.db.Prepare(`
INSERT INTO _entities (id, kind, tenant_id, parent_id, data, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (id, kind, tenant_id) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
data = EXCLUDED.data,
updated_at = NOW()
`)
if err != nil {
return fmt.Errorf("prepare stmtPut: %w", err)
}
db.stmtDel, err = db.db.Prepare(
`UPDATE _entities SET deleted = TRUE, updated_at = NOW() WHERE id = $1 AND kind = $2 AND tenant_id = $3`)
if err != nil {
return fmt.Errorf("prepare stmtDel: %w", err)
}
return nil
}
// initSchema creates the base tables
func (db *PostgresDB) initSchema() error {
// Set search path if schema specified
if db.config.Schema != "" {
_, err := db.db.Exec(fmt.Sprintf(`CREATE SCHEMA IF NOT EXISTS %s`, db.config.Schema))
if err != nil {
return err
}
_, err = db.db.Exec(fmt.Sprintf(`SET search_path TO %s`, db.config.Schema))
if err != nil {
return err
}
}
// Create metadata table
_, err := db.db.Exec(`
CREATE TABLE IF NOT EXISTS _metadata (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW()
)
`)
if err != nil {
return err
}
// Create generic entity storage table with JSONB.
//
// The PRIMARY KEY is COMPOSITE (id, kind, tenant_id): the same entity id in
// two different tenants is two distinct rows, so org B's write can never
// overwrite org A's row, and the ON CONFLICT upsert only ever collides
// within one tenant. tenant_id is NOT NULL DEFAULT '' so it can participate
// in the key (a NULL column can't be a reliable PK member). This mirrors the
// SQLite store's (id, kind, namespace) key — one canonical multi-tenant
// shape across both backends (Red CRIT-2).
_, err = db.db.Exec(`
CREATE TABLE IF NOT EXISTS _entities (
id TEXT NOT NULL,
kind TEXT NOT NULL,
tenant_id TEXT NOT NULL DEFAULT '',
parent_id TEXT,
data JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted BOOLEAN DEFAULT FALSE,
PRIMARY KEY (id, kind, tenant_id)
)
`)
if err != nil {
return err
}
// Create indexes
_, err = db.db.Exec(`
CREATE INDEX IF NOT EXISTS idx_entities_kind ON _entities(kind);
CREATE INDEX IF NOT EXISTS idx_entities_tenant ON _entities(tenant_id);
CREATE INDEX IF NOT EXISTS idx_entities_parent ON _entities(parent_id);
CREATE INDEX IF NOT EXISTS idx_entities_deleted ON _entities(deleted);
CREATE INDEX IF NOT EXISTS idx_entities_data ON _entities USING GIN (data);
`)
if err != nil {
return err
}
// The named-counter table. See db.Sequencer.
//
// It is deliberately NOT keyed by tenant_id, unlike _entities. A counter's
// scope is whatever its NAME says it is, and the one this was built for —
// the destination tag on a pooled XRPL custody account — is shared by every
// org, because the account is. Adding tenant_id to the key would silently
// hand two orgs the same tag for the same address.
if _, err = db.db.Exec(sequenceDDL); err != nil {
return err
}
// The credential guard travels with the table it protects — see guard.go.
for _, stmt := range postgresGuardDDL() {
if _, err := db.db.Exec(stmt); err != nil {
return fmt.Errorf("credential guard: %w", err)
}
}
return nil
}
// NextSequence atomically increments the named counter and returns its new
// value, starting at 0. See db.Sequencer for why this primitive exists.
//
// It is ONE statement, and that is the whole guarantee. The upsert either
// inserts the row at 0 or increments the row already there; on conflict
// Postgres takes a row lock and re-evaluates DO UPDATE against the newest
// committed version, so concurrent executions queue on that row and each
// returns a distinct value. No value is ever read into Go and written back, so
// there is no window in which two callers can both see N.
//
// That holds at READ COMMITTED, which is what this pool runs — it does NOT
// depend on an isolation level nobody sets, and it holds across replicas
// because they share the row.
func (db *PostgresDB) NextSequence(ctx context.Context, name string) (uint64, error) {
if name == "" {
return 0, fmt.Errorf("db: sequence name is empty")
}
var value int64
err := db.db.QueryRowContext(ctx, `
INSERT INTO _sequences (name, value) VALUES ($1, 0)
ON CONFLICT (name) DO UPDATE SET value = _sequences.value + 1
RETURNING value
`, name).Scan(&value)
if err != nil {
return 0, fmt.Errorf("db: allocate from sequence %q: %w", name, err)
}
if value < 0 {
return 0, fmt.Errorf("db: sequence %q overflowed", name)
}
return uint64(value), nil
}
// Add atomically adds delta to the named total and returns the new total. See
// db.Counter. One statement, as NextSequence is: on conflict Postgres row-locks
// and re-evaluates DO UPDATE against the newest committed value, so concurrent
// additions queue on the row and each returns its own total, at READ COMMITTED
// and across replicas.
func (db *PostgresDB) Add(ctx context.Context, name string, delta int64) (int64, error) {
if name == "" {
return 0, fmt.Errorf("db: counter name is empty")
}
var value int64
err := db.db.QueryRowContext(ctx, `
INSERT INTO _sequences (name, value) VALUES ($1, $2)
ON CONFLICT (name) DO UPDATE SET value = _sequences.value + EXCLUDED.value
RETURNING value
`, name, delta).Scan(&value)
if err != nil {
return 0, fmt.Errorf("db: add to counter %q: %w", name, err)
}
return value, nil
}
// initVectorSearch initializes pgvector extension
func (db *PostgresDB) initVectorSearch() error {
// Enable pgvector extension
_, err := db.db.Exec(`CREATE EXTENSION IF NOT EXISTS vector`)
if err != nil {
return fmt.Errorf("failed to create vector extension: %w", err)
}
dims := db.config.VectorDimensions
if dims == 0 {
dims = 1536 // Default to OpenAI ada-002
}
// Create vectors table
_, err = db.db.Exec(fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS _vectors (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
tenant_id TEXT,
embedding vector(%d),
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`, dims))
if err != nil {
return fmt.Errorf("failed to create vectors table: %w", err)
}
// Create index for approximate nearest neighbor search
_, err = db.db.Exec(`
CREATE INDEX IF NOT EXISTS idx_vectors_embedding ON _vectors
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)
`)
if err != nil {
// IVFFlat requires data, try HNSW instead
_, err = db.db.Exec(`
CREATE INDEX IF NOT EXISTS idx_vectors_embedding ON _vectors
USING hnsw (embedding vector_cosine_ops)
`)
if err != nil {
// Non-fatal, just slower searches
fmt.Printf("Warning: could not create vector index: %v\n", err)
}
}
return nil
}
// TenantID returns the tenant identifier
func (db *PostgresDB) TenantID() string {
return db.config.TenantID
}
// TenantType returns "user" or "org"
func (db *PostgresDB) TenantType() string {
return db.config.TenantType
}
// Close closes the database connection and prepared statements
func (db *PostgresDB) Close() error {
db.mu.Lock()
defer db.mu.Unlock()
if db.closed {
return nil
}
db.closed = true
// Close prepared statements
for _, s := range []*sql.Stmt{
db.stmtGet,
db.stmtPut,
db.stmtDel,
} {
if s != nil {
s.Close()
}
}
return db.db.Close()
}
// Get retrieves an entity by key, scoped to the ctx tenant. A row written by
// another tenant with the SAME id+kind is invisible here (Red CRIT-2).
func (db *PostgresDB) Get(ctx context.Context, key Key, dst interface{}) error {
if key == nil {
return ErrInvalidKey
}
row := db.stmtGet.QueryRowContext(ctx, key.Encode(), key.Kind(), db.tenantFor(ctx))
var data []byte
if err := row.Scan(&data); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNoSuchEntity
}
return err
}
return json.Unmarshal(data, dst)
}
// Put stores an entity
func (db *PostgresDB) Put(ctx context.Context, key Key, src interface{}) (Key, error) {
if key == nil {
return nil, ErrInvalidKey
}
data, err := json.Marshal(src)
if err != nil {
return nil, fmt.Errorf("db: failed to marshal entity: %w", err)
}
var parentID *string
if p := key.Parent(); p != nil {
id := p.Encode()
parentID = &id
}
// tenant_id is NOT NULL and part of the composite PK — store the resolved
// tenant string directly (never NULL). ON CONFLICT (id, kind, tenant_id)
// then upserts only WITHIN this tenant.
_, err = db.stmtPut.ExecContext(ctx, key.Encode(), key.Kind(), db.tenantFor(ctx), parentID, data)
if err != nil {
return nil, err
}
return key, nil
}
// Swap writes src under key only while the stored entity is the one the caller
// read (db.Swapper): its updatedAt still prev, or nothing stored when prev is "" —
// a deleted entity is nothing stored, so a swap writes it anew.
// One statement, so READ COMMITTED is enough: the row's update is the check.
func (db *PostgresDB) Swap(ctx context.Context, key Key, prev string, src interface{}) (bool, error) {
if key == nil {
return false, ErrInvalidKey
}
data, err := json.Marshal(src)
if err != nil {
return false, fmt.Errorf("db: failed to marshal entity: %w", err)
}
var parentID *string
if p := key.Parent(); p != nil {
id := p.Encode()
parentID = &id
}
var res sql.Result
if prev == "" {
res, err = db.db.ExecContext(ctx, `
INSERT INTO _entities (id, kind, tenant_id, parent_id, data, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (id, kind, tenant_id) DO UPDATE SET
parent_id = EXCLUDED.parent_id, data = EXCLUDED.data,
updated_at = NOW(), deleted = FALSE
WHERE _entities.deleted
`, key.Encode(), key.Kind(), db.tenantFor(ctx), parentID, data)
} else {
res, err = db.db.ExecContext(ctx, `
UPDATE _entities SET parent_id = $4, data = $5, updated_at = NOW()
WHERE id = $1 AND kind = $2 AND tenant_id = $3 AND NOT deleted
AND data->>'updatedAt' = $6
`, key.Encode(), key.Kind(), db.tenantFor(ctx), parentID, data, prev)
}
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n == 1, err
}
// Delete removes an entity (soft delete), scoped to the ctx tenant.
func (db *PostgresDB) Delete(ctx context.Context, key Key) error {
if key == nil {
return ErrInvalidKey
}
_, err := db.stmtDel.ExecContext(ctx, key.Encode(), key.Kind(), db.tenantFor(ctx))
return err
}
// GetMulti retrieves multiple entities
func (db *PostgresDB) GetMulti(ctx context.Context, keys []Key, dst interface{}) error {
if len(keys) == 0 {
return nil
}
// Build query with ANY
ids := make([]string, len(keys))
for i, k := range keys {
ids[i] = k.Encode()
}
// Always tenant-scoped — a multi-get must not reach across tenants.
query := `SELECT id, data FROM _entities WHERE id = ANY($1) AND tenant_id = $2 AND deleted = FALSE`
args := []interface{}{ids, db.tenantFor(ctx)}
rows, err := db.db.QueryContext(ctx, query, args...)
if err != nil {
return err
}
defer rows.Close()
// Build result map
results := make(map[string][]byte)
for rows.Next() {
var id string
var data []byte
if err := rows.Scan(&id, &data); err != nil {
return err
}
results[id] = data
}
// Unmarshal into destination slice
dstVal := reflect.ValueOf(dst)
if dstVal.Kind() != reflect.Ptr || dstVal.Elem().Kind() != reflect.Slice {
return errors.New("db: dst must be a pointer to a slice")
}
sliceVal := dstVal.Elem()
elemType := sliceVal.Type().Elem()
for _, k := range keys {
data, ok := results[k.Encode()]
if !ok {
sliceVal = reflect.Append(sliceVal, reflect.Zero(elemType))
continue
}
elem := reflect.New(elemType.Elem())
if err := json.Unmarshal(data, elem.Interface()); err != nil {
return err
}
sliceVal = reflect.Append(sliceVal, elem)
}
dstVal.Elem().Set(sliceVal)
return nil
}
// PutMulti stores multiple entities
func (db *PostgresDB) PutMulti(ctx context.Context, keys []Key, src interface{}) ([]Key, error) {
if len(keys) == 0 {
return keys, nil
}
srcVal := reflect.ValueOf(src)
if srcVal.Kind() != reflect.Slice {
return nil, errors.New("db: src must be a slice")
}
if srcVal.Len() != len(keys) {
return nil, errors.New("db: keys and src must have same length")
}
tx, err := db.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO _entities (id, kind, tenant_id, parent_id, data, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (id, kind, tenant_id) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
data = EXCLUDED.data,
updated_at = NOW()
`)
if err != nil {
return nil, err
}
defer stmt.Close()
tenantID := db.tenantFor(ctx)
for i, key := range keys {
data, err := json.Marshal(srcVal.Index(i).Interface())
if err != nil {
return nil, err
}
var parentID *string
if p := key.Parent(); p != nil {
id := p.Encode()
parentID = &id
}
_, err = stmt.ExecContext(ctx, key.Encode(), key.Kind(), tenantID, parentID, data)
if err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return keys, nil
}
// DeleteMulti removes multiple entities
func (db *PostgresDB) DeleteMulti(ctx context.Context, keys []Key) error {
if len(keys) == 0 {
return nil
}
ids := make([]string, len(keys))
for i, k := range keys {
ids[i] = k.Encode()
}
// Always tenant-scoped — a multi-delete must not reach across tenants.
query := `UPDATE _entities SET deleted = TRUE, updated_at = NOW() WHERE id = ANY($1) AND tenant_id = $2`
args := []interface{}{ids, db.tenantFor(ctx)}
_, err := db.db.ExecContext(ctx, query, args...)
return err
}
// Query returns a new query for the given kind
func (db *PostgresDB) Query(kind string) Query {
return &postgresQuery{
db: db,
kind: kind,
tenantID: db.config.TenantID,
}
}
// VectorSearch performs similarity search using pgvector
func (db *PostgresDB) VectorSearch(ctx context.Context, opts *VectorSearchOptions) ([]VectorResult, error) {
if opts == nil || len(opts.Vector) == 0 {
return nil, errors.New("db: VectorSearchOptions with Vector is required")
}
// Convert vector to PostgreSQL array format
vectorStr := fmt.Sprintf("[%s]", floatsToString(opts.Vector))
limit := opts.Limit
if limit == 0 {
limit = 10
}
// Build query using cosine distance
query := `
SELECT id, 1 - (embedding <=> $1::vector) as score, metadata
FROM _vectors
WHERE 1=1
`
args := []interface{}{vectorStr}
argNum := 2
if opts.Kind != "" {
query += fmt.Sprintf(" AND kind = $%d", argNum)
args = append(args, opts.Kind)
argNum++
}
// tenantFor(ctx) so a namespaced request scopes vectors to ITS OWN tenant
// (Red LOW-2), falling back to the instance's configured tenant. Vector search
// is unreachable today (no handler wires it), but fixing it now means a future
// wiring can never enumerate another tenant's vectors.
if tenant := db.tenantFor(ctx); tenant != "" {
query += fmt.Sprintf(" AND tenant_id = $%d", argNum)
args = append(args, tenant)
argNum++
}
if opts.MinScore > 0 {
query += fmt.Sprintf(" AND 1 - (embedding <=> $1::vector) >= $%d", argNum)
args = append(args, opts.MinScore)
argNum++
}
query += fmt.Sprintf(" ORDER BY embedding <=> $1::vector LIMIT %d", limit)
rows, err := db.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []VectorResult
for rows.Next() {
var r VectorResult
var metadataJSON []byte
if err := rows.Scan(&r.ID, &r.Score, &metadataJSON); err != nil {
return nil, err
}
if len(metadataJSON) > 0 {
json.Unmarshal(metadataJSON, &r.Metadata)
}
results = append(results, r)
}
return results, rows.Err()
}
// PutVector stores a vector embedding
func (db *PostgresDB) PutVector(ctx context.Context, kind string, id string, vector []float32, metadata map[string]interface{}) error {
vectorStr := fmt.Sprintf("[%s]", floatsToString(vector))
var metadataJSON []byte
var err error
if metadata != nil {
metadataJSON, err = json.Marshal(metadata)
if err != nil {
return err
}
}
// Tag the stored vector with the REQUEST tenant (Red LOW-2), not the static
// config tenant, so a shared PostgresDB scoping by ctx stores it under the
// right tenant.
var tenantID *string
if tenant := db.tenantFor(ctx); tenant != "" {
tenantID = &tenant
}
_, err = db.db.ExecContext(ctx, `
INSERT INTO _vectors (id, kind, tenant_id, embedding, metadata, created_at)
VALUES ($1, $2, $3, $4::vector, $5, NOW())
ON CONFLICT (id) DO UPDATE SET
embedding = EXCLUDED.embedding,
metadata = EXCLUDED.metadata
`, id, kind, tenantID, vectorStr, metadataJSON)
return err
}
// NewKey creates a new key
func (db *PostgresDB) NewKey(kind string, stringID string, intID int64, parent Key) Key {
return &postgresKey{
kind: kind,
stringID: stringID,
intID: intID,
parent: parent,
namespace: db.config.TenantID,
}
}
// NewIncompleteKey creates a key that will be assigned an ID on Put
func (db *PostgresDB) NewIncompleteKey(kind string, parent Key) Key {
return &postgresKey{
kind: kind,
parent: parent,
namespace: db.config.TenantID,
incomplete: true,
}
}
// AllocateIDs pre-allocates entity IDs
func (db *PostgresDB) AllocateIDs(kind string, parent Key, n int) ([]Key, error) {
keys := make([]Key, n)
for i := 0; i < n; i++ {
keys[i] = &postgresKey{
kind: kind,
stringID: generateID(),
parent: parent,
namespace: db.config.TenantID,
}
}
return keys, nil
}
// RunInTransaction executes a function within a transaction
func (db *PostgresDB) RunInTransaction(ctx context.Context, fn func(tx Transaction) error, opts *TransactionOptions) error {
sqlTx, err := db.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer sqlTx.Rollback()
tx := &postgresTransaction{
db: db,
tx: sqlTx,
tenant: db.tenantFor(ctx),
}
if err := fn(tx); err != nil {
return err
}
return sqlTx.Commit()
}
// postgresKey implements the Key interface
type postgresKey struct {
kind string
stringID string
intID int64
parent Key
namespace string
incomplete bool
}
func (k *postgresKey) Kind() string { return k.kind }
func (k *postgresKey) StringID() string { return k.stringID }
func (k *postgresKey) IntID() int64 { return k.intID }
func (k *postgresKey) Parent() Key { return k.parent }
func (k *postgresKey) Namespace() string { return k.namespace }
func (k *postgresKey) Incomplete() bool { return k.incomplete }
func (k *postgresKey) Encode() string {
if k.stringID != "" {
return k.stringID
}
if k.intID != 0 {
return fmt.Sprintf("%d", k.intID)
}
if k.incomplete {
k.stringID = generateID()
k.incomplete = false
}
return k.stringID
}
func (k *postgresKey) Equal(other Key) bool {
if other == nil {
return false
}
return k.Kind() == other.Kind() && k.Encode() == other.Encode()
}
// postgresTransaction implements Transaction. tenant is resolved ONCE from the
// RunInTransaction ctx (the caller org) and scopes every op in the tx, so a
// transaction can never read or write across tenants (Red CRIT-2).
type postgresTransaction struct {
db *PostgresDB
tx *sql.Tx
tenant string
}
func (t *postgresTransaction) Get(key Key, dst interface{}) error {
query := `SELECT data FROM _entities WHERE id = $1 AND kind = $2 AND tenant_id = $3 AND deleted = FALSE`
args := []interface{}{key.Encode(), key.Kind(), t.tenant}
row := t.tx.QueryRow(query, args...)
var data []byte
if err := row.Scan(&data); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNoSuchEntity
}
return err
}
return json.Unmarshal(data, dst)
}
func (t *postgresTransaction) Put(key Key, src interface{}) (Key, error) {
data, err := json.Marshal(src)
if err != nil {
return nil, err
}
var parentID *string
if p := key.Parent(); p != nil {
id := p.Encode()
parentID = &id
}
_, err = t.tx.Exec(`
INSERT INTO _entities (id, kind, tenant_id, parent_id, data, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (id, kind, tenant_id) DO UPDATE SET
parent_id = EXCLUDED.parent_id,
data = EXCLUDED.data,
updated_at = NOW()
`, key.Encode(), key.Kind(), t.tenant, parentID, data)
return key, err
}
func (t *postgresTransaction) Delete(key Key) error {
query := `UPDATE _entities SET deleted = TRUE, updated_at = NOW() WHERE id = $1 AND kind = $2 AND tenant_id = $3`
args := []interface{}{key.Encode(), key.Kind(), t.tenant}
_, err := t.tx.Exec(query, args...)
return err
}
func (t *postgresTransaction) Query(kind string) Query {
return &postgresQuery{
db: t.db,
kind: kind,
tenantID: t.tenant,
tx: t.tx,
}
}
// postgresQuery implements Query for PostgreSQL
type postgresQuery struct {
db *PostgresDB
tx *sql.Tx
kind string
tenantID string
filters []queryFilter
orders []queryOrder
projections []string
ancestor Key
limit int
offset int
distinct bool
}
func (q *postgresQuery) Filter(filterStr string, value interface{}) Query {
field, op := parseFilterString(filterStr)
return q.FilterField(field, op, value)
}
func (q *postgresQuery) FilterField(fieldPath string, op string, value interface{}) Query {
newQ := q.clone()
newQ.filters = append(newQ.filters, queryFilter{
field: fieldPath,
op: normalizeOp(op),
value: value,
})
return newQ
}
func (q *postgresQuery) Order(fieldPath string) Query {
newQ := q.clone()
if strings.HasPrefix(fieldPath, "-") {
newQ.orders = append(newQ.orders, queryOrder{
field: strings.TrimPrefix(fieldPath, "-"),
desc: true,
})
} else {
newQ.orders = append(newQ.orders, queryOrder{field: fieldPath})
}
return newQ
}
func (q *postgresQuery) OrderDesc(fieldPath string) Query {
newQ := q.clone()
newQ.orders = append(newQ.orders, queryOrder{field: fieldPath, desc: true})
return newQ
}
func (q *postgresQuery) Limit(limit int) Query {
newQ := q.clone()
newQ.limit = limit
return newQ
}
func (q *postgresQuery) Offset(offset int) Query {
newQ := q.clone()
newQ.offset = offset
return newQ
}
func (q *postgresQuery) Project(fieldNames ...string) Query {
newQ := q.clone()
newQ.projections = append(newQ.projections, fieldNames...)
return newQ
}
func (q *postgresQuery) Distinct() Query {
newQ := q.clone()
newQ.distinct = true
return newQ
}
func (q *postgresQuery) Ancestor(ancestor Key) Query {
newQ := q.clone()
newQ.ancestor = ancestor
return newQ
}
func (q *postgresQuery) Start(cursor Cursor) Query {
// PostgreSQL uses OFFSET for pagination
return q
}
func (q *postgresQuery) End(cursor Cursor) Query {
return q
}
func (q *postgresQuery) GetAll(ctx context.Context, dst interface{}) ([]Key, error) {
query, args := q.buildSQL(q.db.tenantFor(ctx))
var rows *sql.Rows
var err error
if q.tx != nil {
rows, err = q.tx.QueryContext(ctx, query, args...)
} else {
rows, err = q.db.db.QueryContext(ctx, query, args...)
}
if err != nil {
return nil, err
}
defer rows.Close()
dstVal := reflect.ValueOf(dst)
if dstVal.Kind() != reflect.Ptr || dstVal.Elem().Kind() != reflect.Slice {
return nil, errors.New("db: dst must be a pointer to a slice")
}
sliceVal := dstVal.Elem()
elemType := sliceVal.Type().Elem()
isPointer := elemType.Kind() == reflect.Ptr
if isPointer {
elemType = elemType.Elem()
}
var keys []Key
for rows.Next() {
var id string
var data []byte
if err := rows.Scan(&id, &data); err != nil {
return nil, err
}
elem := reflect.New(elemType)
if err := json.Unmarshal(data, elem.Interface()); err != nil {
return nil, err
}
if isPointer {
sliceVal = reflect.Append(sliceVal, elem)
} else {
sliceVal = reflect.Append(sliceVal, elem.Elem())
}
keys = append(keys, &postgresKey{
kind: q.kind,
stringID: id,
namespace: q.tenantID,
})
}
dstVal.Elem().Set(sliceVal)
return keys, rows.Err()
}
func (q *postgresQuery) First(ctx context.Context, dst interface{}) (Key, error) {
limitedQ := q.Limit(1).(*postgresQuery)
query, args := limitedQ.buildSQL(q.db.tenantFor(ctx))
var row *sql.Row
if q.tx != nil {
row = q.tx.QueryRowContext(ctx, query, args...)
} else {
row = q.db.db.QueryRowContext(ctx, query, args...)
}
var id string
var data []byte
if err := row.Scan(&id, &data); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNoSuchEntity
}
return nil, err
}
if err := json.Unmarshal(data, dst); err != nil {
return nil, err
}
return &postgresKey{
kind: q.kind,
stringID: id,
namespace: q.tenantID,
}, nil
}
func (q *postgresQuery) Count(ctx context.Context) (int, error) {
where, args := q.buildWhere(q.db.tenantFor(ctx))
query := fmt.Sprintf(`SELECT COUNT(*) FROM _entities WHERE kind = $1 AND deleted = FALSE%s`, where)
args = append([]interface{}{q.kind}, args...)
var row *sql.Row
if q.tx != nil {
row = q.tx.QueryRowContext(ctx, query, args...)
} else {
row = q.db.db.QueryRowContext(ctx, query, args...)
}
var count int
err := row.Scan(&count)
return count, err
}
func (q *postgresQuery) Keys(ctx context.Context) ([]Key, error) {
where, args := q.buildWhere(q.db.tenantFor(ctx))
query := fmt.Sprintf(`SELECT id FROM _entities WHERE kind = $1 AND deleted = FALSE%s`, where)
args = append([]interface{}{q.kind}, args...)
query += q.buildOrderBy()
query += q.buildLimitOffset()
var rows *sql.Rows
var err error
if q.tx != nil {
rows, err = q.tx.QueryContext(ctx, query, args...)
} else {
rows, err = q.db.db.QueryContext(ctx, query, args...)
}
if err != nil {
return nil, err
}
defer rows.Close()
var keys []Key
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
keys = append(keys, &postgresKey{
kind: q.kind,
stringID: id,
namespace: q.tenantID,
})
}
return keys, rows.Err()
}
func (q *postgresQuery) Run(ctx context.Context) Iterator {
query, args := q.buildSQL(q.db.tenantFor(ctx))
var rows *sql.Rows
var err error
if q.tx != nil {
rows, err = q.tx.QueryContext(ctx, query, args...)
} else {
rows, err = q.db.db.QueryContext(ctx, query, args...)
}
return &postgresIterator{
rows: rows,
err: err,
kind: q.kind,
namespace: q.tenantID,
}
}
func (q *postgresQuery) buildSQL(tenant string) (string, []interface{}) {
where, args := q.buildWhere(tenant)
selectClause := "id, data"
if q.distinct {
selectClause = "DISTINCT " + selectClause
}
query := fmt.Sprintf(`SELECT %s FROM _entities WHERE kind = $1 AND deleted = FALSE%s`, selectClause, where)
args = append([]interface{}{q.kind}, args...)
query += q.buildOrderBy()
query += q.buildLimitOffset()
return query, args
}
// buildWhere scopes the query to `tenant` — the caller org (per-org isolation),
// resolved from the request ctx by the executor. The tenant filter is ALWAYS
// applied (even for the empty/default tenant, which matches tenant_id = ”), so
// a list/count/keys scan can never enumerate another tenant's rows (Red CRIT-2).
func (q *postgresQuery) buildWhere(tenant string) (string, []interface{}) {
var conditions []string
var args []interface{}
argNum := 2 // $1 is kind
// Tenant filter — always present.
conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argNum))
args = append(args, tenant)
argNum++
// Ancestor filter
if q.ancestor != nil {
conditions = append(conditions, fmt.Sprintf("parent_id = $%d", argNum))
args = append(args, q.ancestor.Encode())
argNum++
}
// Field filters using JSONB operators
// Go Filter() calls use PascalCase field names (e.g., "DestinationKind"),
// but JSON tags use camelCase (e.g., "destinationKind"). Convert first char to lower.
for _, f := range q.filters {
// A filter that cannot be expressed matches nothing. Dropping it would
// WIDEN the result set, so an unrepresentable field fails closed.
if !validFieldPath(f.field) {
conditions = append(conditions, "1 = 0")
continue
}
jsonField := f.field
if len(jsonField) > 0 {
jsonField = strings.ToLower(jsonField[:1]) + jsonField[1:]
}
jsonPath := fmt.Sprintf("data->>'%s'", jsonField)
conditions = append(conditions, fmt.Sprintf("%s %s $%d", jsonPath, f.op, argNum))
args = append(args, f.value)
argNum++
}
if len(conditions) == 0 {
return "", args
}
return " AND " + strings.Join(conditions, " AND "), args
}
func (q *postgresQuery) buildOrderBy() string {
if len(q.orders) == 0 {
return ""
}
var parts []string
for _, o := range q.orders {
// A rejected sort field is dropped rather than erroring; see the SQLite
// buildOrderBy for why the sort order is safe to fall back on.
if !validFieldPath(o.field) {
continue
}
orderField := o.field
if len(orderField) > 0 {
orderField = strings.ToLower(orderField[:1]) + orderField[1:]
}
jsonPath := fmt.Sprintf("data->>'%s'", orderField)
if o.desc {
parts = append(parts, jsonPath+" DESC")
} else {
parts = append(parts, jsonPath+" ASC")
}
}
if len(parts) == 0 {
return ""
}
return " ORDER BY " + strings.Join(parts, ", ")
}
func (q *postgresQuery) buildLimitOffset() string {
var result string
if q.limit > 0 {
result += fmt.Sprintf(" LIMIT %d", q.limit)
}
if q.offset > 0 {
result += fmt.Sprintf(" OFFSET %d", q.offset)
}
return result
}
func (q *postgresQuery) clone() *postgresQuery {
newQ := *q
newQ.filters = append([]queryFilter{}, q.filters...)
newQ.orders = append([]queryOrder{}, q.orders...)
newQ.projections = append([]string{}, q.projections...)
return &newQ
}
// postgresIterator implements Iterator
type postgresIterator struct {
rows *sql.Rows
err error
kind string
namespace string
offset int
}
func (it *postgresIterator) Next(dst interface{}) (Key, error) {
if it.err != nil {
return nil, it.err
}
if it.rows == nil || !it.rows.Next() {
if it.rows != nil {
if err := it.rows.Err(); err != nil {
return nil, err
}
}
return nil, errors.New("db: no more results")
}
var id string
var data []byte
if err := it.rows.Scan(&id, &data); err != nil {
return nil, err
}
if err := json.Unmarshal(data, dst); err != nil {
return nil, err
}
it.offset++
return &postgresKey{
kind: it.kind,
stringID: id,
namespace: it.namespace,
}, nil
}
func (it *postgresIterator) Cursor() (Cursor, error) {
return &sqliteCursor{
id: fmt.Sprintf("%d", it.offset),
offset: it.offset,
}, nil
}
// Close releases the underlying *sql.Rows and returns its pooled connection to
// the pool. A caller that stops iterating before exhaustion (e.g. Query.First
// reads a single row on a Limit(1) query and never drains the iterator) MUST
// Close, or database/sql keeps the connection checked out until ConnMaxLifetime
// — a leak that starves MaxOpenConns and makes every subsequent query block in
// (*DB).conn until its context deadline.
func (it *postgresIterator) Close() error {
if it.rows != nil {
return it.rows.Close()
}
return nil
}
// floatsToString converts a float slice to comma-separated string
func floatsToString(v []float32) string {
strs := make([]string, len(v))
for i, f := range v {
strs[i] = fmt.Sprintf("%f", f)
}
return strings.Join(strs, ",")
}