coverage~bigbes/sr-ht-spec3cb1c03ddb/store.go

Coverage
82.1% 23/28 statements
Δ
Blob
f7ae3c6
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 }