| 1 |
|
// Package db is the PostgreSQL persistence layer for spec.sr.ht. It maps the |
| 2 |
|
// tables of schema.sql — space, document_id, proposal, index_stamp, |
| 3 |
|
// digest_mark, project, project_space and comment — to core value types with |
| 4 |
|
// plain database/sql and $n placeholders (no ORM). |
| 5 |
|
// |
| 6 |
|
// It holds no credential of any kind. agent_token lived here until agent |
| 7 |
|
// issuance moved to tokens.sr.ht; a working token is signed rather than stored, |
| 8 |
|
// so authenticating one is a signature check in authn/ and this package has |
| 9 |
|
// nothing to look up. |
| 10 |
|
// |
| 11 |
|
// The layering rule from the design is what shapes this package: **git refs are |
| 12 |
|
// the source of truth for whether a proposal exists and whether it merged; |
| 13 |
|
// Postgres holds metadata that is reconstructable from git; the index and the |
| 14 |
|
// render cache are pure caches.** So nothing here stores a document body, and |
| 15 |
|
// every row is something the reconciler could rebuild from refs. The queries |
| 16 |
|
// are correspondingly small: registry lookups, one state machine, and two |
| 17 |
|
// key/value stamps. |
| 18 |
|
// |
| 19 |
|
// Design: a Store wraps a Querier — an interface satisfied by *sql.DB, *sql.Tx |
| 20 |
|
// and *sql.Conn alike. This gives us two things at once: |
| 21 |
|
// |
| 22 |
|
// - Context-first, middleware-compatible signatures. Production callers build |
| 23 |
|
// a Store from the connection that core-go's database middleware injects |
| 24 |
|
// into the request context (see FromContext, which reads the same *sql.DB |
| 25 |
|
// that database.Middleware installed). Every method takes ctx first and |
| 26 |
|
// threads it into the query for cancellation. |
| 27 |
|
// |
| 28 |
|
// - Trivial test injection. Tests call NewStore(db) with a plain *sql.DB, no |
| 29 |
|
// HTTP context required. |
| 30 |
|
// |
| 31 |
|
// Because the wrapped value is an interface, a Store can be re-bound to an open |
| 32 |
|
// transaction with WithTx(tx) or InTx(ctx, fn). That is what MergeProposal |
| 33 |
|
// needs: recording the merge and re-pointing the affected document_id rows are |
| 34 |
|
// one invariant, not two writes that may half-happen. |
| 35 |
|
package db |
| 36 |
|
|
| 37 |
|
import ( |
| 38 |
|
"context" |
| 39 |
|
"database/sql" |
| 40 |
|
"errors" |
| 41 |
|
"fmt" |
| 42 |
|
|
| 43 |
|
"sourcecraft.dev/bigbes/sr-ht-core/database" |
| 44 |
|
) |
| 45 |
|
|
| 46 |
|
// Querier is the common subset of *sql.DB, *sql.Tx and *sql.Conn used by this |
| 47 |
|
// package. Binding a Store to any of them keeps every method identical whether |
| 48 |
|
// it runs standalone (autocommit) or inside a caller-managed transaction. |
| 49 |
|
type Querier interface { |
| 50 |
|
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) |
| 51 |
|
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) |
| 52 |
|
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row |
| 53 |
|
} |
| 54 |
|
|
| 55 |
|
// beginner is the subset of *sql.DB that can open a transaction. A Store bound |
| 56 |
|
// to a *sql.Tx does not satisfy it, which is how InTx refuses to nest instead of |
| 57 |
|
// silently running the body outside a transaction. |
| 58 |
|
type beginner interface { |
| 59 |
|
BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) |
| 60 |
|
} |
| 61 |
|
|
| 62 |
|
// Store is the entry point for all queries in this package. |
| 63 |
|
type Store struct { |
| 64 |
|
q Querier |
| 65 |
|
} |
| 66 |
|
|
| 67 |
|
// NewStore builds a Store over a database handle (or any Querier). Tests pass a |
| 68 |
|
// plain *sql.DB; production wiring passes the shared pool. |
| 69 |
28 |
func NewStore(q Querier) *Store { |
| 70 |
28 |
return &Store{q: q} |
| 71 |
28 |
} |
| 72 |
|
|
| 73 |
|
// FromContext builds a Store over the *sql.DB that core-go's database.Middleware |
| 74 |
|
// installed in ctx. It panics (via database.DBForContext) if no database is |
| 75 |
|
// present in the context — a programming error, never a runtime condition to |
| 76 |
|
// recover from. We wrap the pooled *sql.DB rather than checking out a *sql.Conn |
| 77 |
|
// (database.ForContext) so the Store has no connection to leak; the pool manages |
| 78 |
|
// connection lifetime and ctx still bounds each query. |
| 79 |
0 |
func FromContext(ctx context.Context) *Store { |
| 80 |
0 |
return &Store{q: database.DBForContext(ctx)} |
| 81 |
0 |
} |
| 82 |
|
|
| 83 |
|
// WithTx returns a Store that runs every query on tx instead of the pool. Used |
| 84 |
|
// where the caller already owns a transaction and wants these queries inside it. |
| 85 |
0 |
func (s *Store) WithTx(tx *sql.Tx) *Store { |
| 86 |
0 |
return &Store{q: tx} |
| 87 |
0 |
} |
| 88 |
|
|
| 89 |
|
// InTx runs fn inside a transaction, on a Store bound to it. fn returning an |
| 90 |
|
// error rolls back and the error is returned unwrapped, so callers can still |
| 91 |
|
// match sentinels with errors.Is. A panic in fn rolls back and re-panics rather |
| 92 |
|
// than leaving the transaction open. |
| 93 |
|
// |
| 94 |
|
// It requires the Store to wrap something that can begin a transaction (the |
| 95 |
|
// pool). A Store already bound to a *sql.Tx returns ErrNoTransaction: nesting is |
| 96 |
|
// a caller bug, and quietly running the body without transactional isolation |
| 97 |
|
// would defeat the only reason this method exists. |
| 98 |
9 |
func (s *Store) InTx(ctx context.Context, fn func(*Store) error) error { |
| 99 |
9 |
b, ok := s.q.(beginner) |
| 100 |
9 |
if !ok { |
| 101 |
1 |
return ErrNoTransaction |
| 102 |
1 |
} |
| 103 |
8 |
tx, err := b.BeginTx(ctx, nil) |
| 104 |
8 |
if err != nil { |
| 105 |
0 |
return fmt.Errorf("begin transaction: %w", err) |
| 106 |
0 |
} |
| 107 |
8 |
committed := false |
| 108 |
8 |
defer func() { |
| 109 |
8 |
if !committed { |
| 110 |
3 |
tx.Rollback() |
| 111 |
3 |
} |
| 112 |
|
}() |
| 113 |
8 |
if err := fn(&Store{q: tx}); err != nil { |
| 114 |
3 |
return err |
| 115 |
3 |
} |
| 116 |
5 |
if err := tx.Commit(); err != nil { |
| 117 |
0 |
return fmt.Errorf("commit transaction: %w", err) |
| 118 |
0 |
} |
| 119 |
5 |
committed = true |
| 120 |
5 |
return nil |
| 121 |
|
} |
| 122 |
|
|
| 123 |
|
// Typed errors returned by this package. Callers match them with errors.Is. |
| 124 |
|
var ( |
| 125 |
|
// ErrNotFound is returned when a lookup, update or delete matched no row. |
| 126 |
|
ErrNotFound = errors.New("db: not found") |
| 127 |
|
|
| 128 |
|
// ErrSpaceExists is returned by CreateSpace when the owner already has a |
| 129 |
|
// space with that name (uq_space_owner_name violation). |
| 130 |
|
ErrSpaceExists = errors.New("db: space already exists") |
| 131 |
|
|
| 132 |
|
// ErrProjectExists is returned by CreateProject when the owner already has |
| 133 |
|
// a project with that name (uq_project_owner_name violation). |
| 134 |
|
ErrProjectExists = errors.New("db: project already exists") |
| 135 |
|
|
| 136 |
|
// ErrDocIDTaken is returned when a document ID is already registered. |
| 137 |
|
// Document IDs are globally unique, so this is the registry refusing a |
| 138 |
|
// collision — the invariant that lets [[SPEC-0007]] resolve the same way |
| 139 |
|
// everywhere and lets a later import not clash with what is already here. |
| 140 |
|
ErrDocIDTaken = errors.New("db: document id already registered") |
| 141 |
|
|
| 142 |
|
// ErrDocIDDuplicate is returned when one batch of documents carries the |
| 143 |
|
// same ID twice. Distinct from ErrDocIDTaken: the collision is inside the |
| 144 |
|
// push itself, not against the registry. |
| 145 |
|
ErrDocIDDuplicate = errors.New("db: document id appears twice in one batch") |
| 146 |
|
|
| 147 |
|
// ErrProposalNotOpen is returned by DeleteOpenProposal for a proposal that |
| 148 |
|
// exists but has already been resolved. Kept distinct from ErrNotFound so |
| 149 |
|
// the reconciler can tell "the row is gone", which is the state it wanted, |
| 150 |
|
// from "the proposal merged under us", which means the repair it planned no |
| 151 |
|
// longer applies. |
| 152 |
|
ErrProposalNotOpen = errors.New("db: proposal is not open") |
| 153 |
|
|
| 154 |
|
// ErrNoTransaction is returned by InTx when the Store is not bound to |
| 155 |
|
// something that can begin one (i.e. it is already inside a transaction). |
| 156 |
|
ErrNoTransaction = errors.New("db: store cannot begin a transaction") |
| 157 |
|
) |
| 158 |
|
|
| 159 |
|
// rowScanner is satisfied by both *sql.Row and *sql.Rows. |
| 160 |
|
type rowScanner interface { |
| 161 |
|
Scan(dest ...any) error |
| 162 |
|
} |
| 163 |
|
|
| 164 |
|
// requireOne turns a zero-rows-affected result into ErrNotFound so that missing |
| 165 |
|
// targets surface loudly instead of passing silently. |
| 166 |
9 |
func requireOne(res sql.Result, what string) error { |
| 167 |
9 |
n, err := res.RowsAffected() |
| 168 |
9 |
if err != nil { |
| 169 |
0 |
return fmt.Errorf("%s: rows affected: %w", what, err) |
| 170 |
0 |
} |
| 171 |
9 |
if n == 0 { |
| 172 |
4 |
return ErrNotFound |
| 173 |
4 |
} |
| 174 |
5 |
return nil |
| 175 |
|
} |
| 176 |
|
|
| 177 |
|
// nullable maps the empty string to a SQL NULL, for the columns the schema |
| 178 |
|
// leaves nullable (proposal.rationale). Storing "" and NULL interchangeably |
| 179 |
|
// would make round-tripping lossy. |
| 180 |
58 |
func nullable(s string) any { |
| 181 |
58 |
if s == "" { |
| 182 |
22 |
return nil |
| 183 |
22 |
} |
| 184 |
36 |
return s |
| 185 |
|
} |