~bigbes/sr-ht-spec

ref: 7f779fef12194d49b9ce97ad4e2a80af1c3d6358 sr-ht-spec/db/store.go -rw-r--r-- 7.6 KiB
7f779fef — Eugene Blikh feat(web): review queue — inbox + policy-merged digest (Phase 4) 26 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
// Package db is the PostgreSQL persistence layer for spec.sr.ht. It maps the
// eight tables of schema.sql — space, document_id, proposal, agent_token,
// index_stamp, digest_mark, project and project_space — to core value types
// with plain database/sql and $n placeholders (no ORM).
//
// The layering rule from the design is what shapes this package: **git refs are
// the source of truth for whether a proposal exists and whether it merged;
// Postgres holds metadata that is reconstructable from git; the index and the
// render cache are pure caches.** So nothing here stores a document body, and
// every row is something the reconciler could rebuild from refs. The queries
// are correspondingly small: registry lookups, one state machine, and two
// key/value stamps.
//
// Design: a Store wraps a Querier — an interface satisfied by *sql.DB, *sql.Tx
// and *sql.Conn alike. This gives us two things at once:
//
//   - Context-first, middleware-compatible signatures. Production callers build
//     a Store from the connection that core-go's database middleware injects
//     into the request context (see FromContext, which reads the same *sql.DB
//     that database.Middleware installed). Every method takes ctx first and
//     threads it into the query for cancellation.
//
//   - Trivial test injection. Tests call NewStore(db) with a plain *sql.DB, no
//     HTTP context required.
//
// Because the wrapped value is an interface, a Store can be re-bound to an open
// transaction with WithTx(tx) or InTx(ctx, fn). That is what MergeProposal
// needs: recording the merge and re-pointing the affected document_id rows are
// one invariant, not two writes that may half-happen.
package db

import (
	"context"
	"database/sql"
	"errors"
	"fmt"

	"sourcecraft.dev/bigbes/sr-ht-core/database"
)

// Querier is the common subset of *sql.DB, *sql.Tx and *sql.Conn used by this
// package. Binding a Store to any of them keeps every method identical whether
// it runs standalone (autocommit) or inside a caller-managed transaction.
type Querier interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}

// beginner is the subset of *sql.DB that can open a transaction. A Store bound
// to a *sql.Tx does not satisfy it, which is how InTx refuses to nest instead of
// silently running the body outside a transaction.
type beginner interface {
	BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
}

// Store is the entry point for all queries in this package.
type Store struct {
	q Querier
}

// NewStore builds a Store over a database handle (or any Querier). Tests pass a
// plain *sql.DB; production wiring passes the shared pool.
func NewStore(q Querier) *Store {
	return &Store{q: q}
}

// FromContext builds a Store over the *sql.DB that core-go's database.Middleware
// installed in ctx. It panics (via database.DBForContext) if no database is
// present in the context — a programming error, never a runtime condition to
// recover from. We wrap the pooled *sql.DB rather than checking out a *sql.Conn
// (database.ForContext) so the Store has no connection to leak; the pool manages
// connection lifetime and ctx still bounds each query.
func FromContext(ctx context.Context) *Store {
	return &Store{q: database.DBForContext(ctx)}
}

// WithTx returns a Store that runs every query on tx instead of the pool. Used
// where the caller already owns a transaction and wants these queries inside it.
func (s *Store) WithTx(tx *sql.Tx) *Store {
	return &Store{q: tx}
}

// InTx runs fn inside a transaction, on a Store bound to it. fn returning an
// error rolls back and the error is returned unwrapped, so callers can still
// match sentinels with errors.Is. A panic in fn rolls back and re-panics rather
// than leaving the transaction open.
//
// It requires the Store to wrap something that can begin a transaction (the
// pool). A Store already bound to a *sql.Tx returns ErrNoTransaction: nesting is
// a caller bug, and quietly running the body without transactional isolation
// would defeat the only reason this method exists.
func (s *Store) InTx(ctx context.Context, fn func(*Store) error) error {
	b, ok := s.q.(beginner)
	if !ok {
		return ErrNoTransaction
	}
	tx, err := b.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin transaction: %w", err)
	}
	committed := false
	defer func() {
		if !committed {
			tx.Rollback()
		}
	}()
	if err := fn(&Store{q: tx}); err != nil {
		return err
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit transaction: %w", err)
	}
	committed = true
	return nil
}

// Typed errors returned by this package. Callers match them with errors.Is.
var (
	// ErrNotFound is returned when a lookup, update or delete matched no row.
	ErrNotFound = errors.New("db: not found")

	// ErrSpaceExists is returned by CreateSpace when the owner already has a
	// space with that name (uq_space_owner_name violation).
	ErrSpaceExists = errors.New("db: space already exists")

	// ErrProjectExists is returned by CreateProject when the owner already has
	// a project with that name (uq_project_owner_name violation).
	ErrProjectExists = errors.New("db: project already exists")

	// ErrDocIDTaken is returned when a document ID is already registered.
	// Document IDs are globally unique, so this is the registry refusing a
	// collision — the invariant that lets [[SPEC-0007]] resolve the same way
	// everywhere and lets a later import not clash with what is already here.
	ErrDocIDTaken = errors.New("db: document id already registered")

	// ErrDocIDDuplicate is returned when one batch of documents carries the
	// same ID twice. Distinct from ErrDocIDTaken: the collision is inside the
	// push itself, not against the registry.
	ErrDocIDDuplicate = errors.New("db: document id appears twice in one batch")

	// ErrTokenExists is returned by CreateAgentToken when that exact token is
	// already registered (agent_token.token_hash UNIQUE).
	ErrTokenExists = errors.New("db: agent token already registered")

	// ErrTokenRevoked is returned by AuthenticateAgentToken for a token that
	// exists but has been revoked. Kept distinct from ErrNotFound so the audit
	// log can say which happened; both map to 401 at the API boundary.
	ErrTokenRevoked = errors.New("db: agent token revoked")

	// ErrProposalNotOpen is returned by DeleteOpenProposal for a proposal that
	// exists but has already been resolved. Kept distinct from ErrNotFound so
	// the reconciler can tell "the row is gone", which is the state it wanted,
	// from "the proposal merged under us", which means the repair it planned no
	// longer applies.
	ErrProposalNotOpen = errors.New("db: proposal is not open")

	// ErrNoTransaction is returned by InTx when the Store is not bound to
	// something that can begin one (i.e. it is already inside a transaction).
	ErrNoTransaction = errors.New("db: store cannot begin a transaction")
)

// rowScanner is satisfied by both *sql.Row and *sql.Rows.
type rowScanner interface {
	Scan(dest ...any) error
}

// requireOne turns a zero-rows-affected result into ErrNotFound so that missing
// targets surface loudly instead of passing silently.
func requireOne(res sql.Result, what string) error {
	n, err := res.RowsAffected()
	if err != nil {
		return fmt.Errorf("%s: rows affected: %w", what, err)
	}
	if n == 0 {
		return ErrNotFound
	}
	return nil
}

// nullable maps the empty string to a SQL NULL, for the columns the schema
// leaves nullable (proposal.rationale). Storing "" and NULL interchangeably
// would make round-tripping lossy.
func nullable(s string) any {
	if s == "" {
		return nil
	}
	return s
}