coverage~bigbes/sr-ht-spec64cae3afdb/document.go

Coverage
84.0% 110/131 statements
Δ
+0.0
Blob
023dcd7
1 package db
2
3 import (
4 "context"
5 "database/sql"
6 "errors"
7 "fmt"
8 "sort"
9 "strings"
10
11 "github.com/lib/pq"
12
13 "sourcecraft.dev/bigbes/sr-ht-spec/core"
14 )
15
16 // Document is one row of the global document ID registry: where the document
17 // carrying this ID currently lives on its space's approved branch.
18 //
19 // Paths move; IDs do not. Cross-space links resolve by ID, comment anchors
20 // reference IDs, and merge staleness is keyed by ID — so this table is the map
21 // from the stable name to the moving one, and nothing else.
22 type Document struct {
23 ID core.DocID
24 SpaceID int
25 Path string
26 UpdatedRev string
27 }
28
29 // DocRef pairs a document ID with the path it occupies in a tree. It is the
30 // unit the push validator and the merge path work in: a set of (id, path) taken
31 // from the frontmatter of the documents a push or a proposal touches.
32 type DocRef struct {
33 ID core.DocID
34 Path string
35 }
36
37 // Collision is one registered ID that a batch tried to claim for a different
38 // space. Existing is the row that already holds it, so the rejection message
39 // can name where the ID actually lives instead of just saying "taken".
40 type Collision struct {
41 DocID core.DocID
42 Existing *Document
43 }
44
45 1 func (c Collision) String() string {
46 1 return fmt.Sprintf("%s is already registered in space %d at %s",
47 1 c.DocID, c.Existing.SpaceID, c.Existing.Path)
48 1 }
49
50 // CollisionError reports one or more global ID collisions. It wraps
51 // ErrDocIDTaken so callers can match the class with errors.Is and still reach
52 // the per-ID detail for the rejection message the `update` hook prints.
53 type CollisionError struct {
54 Collisions []Collision
55 }
56
57 1 func (e *CollisionError) Error() string {
58 1 parts := make([]string, len(e.Collisions))
59 1 for i, c := range e.Collisions {
60 1 parts[i] = c.String()
61 1 }
62 1 return "db: document id collision: " + strings.Join(parts, "; ")
63 }
64
65 3 func (e *CollisionError) Unwrap() error { return ErrDocIDTaken }
66
67 const documentSelect = `SELECT doc_id, space_id, path, updated_rev FROM document_id`
68
69 13 func scanDocument(sc rowScanner) (*Document, error) {
70 13 var (
71 13 d Document
72 13 docID string
73 13 )
74 13 if err := sc.Scan(&docID, &d.SpaceID, &d.Path, &d.UpdatedRev); err != nil {
75 3 return nil, err
76 3 }
77 10 parsed, err := core.ParseDocID(docID)
78 10 if err != nil {
79 0 // The registry only ever accepts parsed IDs, so a row that no longer
80 0 // parses is corruption, not input. Surface it instead of guessing.
81 0 return nil, fmt.Errorf("registry row %q: %w", docID, err)
82 0 }
83 10 d.ID = parsed
84 10 return &d, nil
85 }
86
87 // RegisterDocID claims a document ID for a space at a path. Global uniqueness is
88 // the doc_id PRIMARY KEY, so a second claim cannot be inserted at all — this
89 // method maps that refusal to ErrDocIDTaken rather than being the thing that
90 // prevents it.
91 9 func (s *Store) RegisterDocID(ctx context.Context, spaceID int, ref DocRef, rev string) (*Document, error) {
92 9 if err := core.ValidateDocPath(ref.Path); err != nil {
93 0 return nil, err
94 0 }
95 9 const q = `
96 9 INSERT INTO document_id (doc_id, space_id, path, updated_rev)
97 9 VALUES ($1, $2, $3, $4)`
98 9 _, err := s.q.ExecContext(ctx, q, ref.ID.String(), spaceID, ref.Path, rev)
99 9 if err != nil {
100 3 var pqErr *pq.Error
101 3 if errors.As(err, &pqErr) && pqErr.Code == "23505" {
102 3 return nil, fmt.Errorf("%w: %s", ErrDocIDTaken, ref.ID)
103 3 }
104 0 return nil, fmt.Errorf("register doc id %s: %w", ref.ID, err)
105 }
106 6 return &Document{ID: ref.ID, SpaceID: spaceID, Path: ref.Path, UpdatedRev: rev}, nil
107 }
108
109 // DocByID resolves a document ID to its current space and path. This is the
110 // lookup wikilink resolution and merge staleness both go through. Returns
111 // ErrNotFound if the ID is not registered.
112 9 func (s *Store) DocByID(ctx context.Context, id core.DocID) (*Document, error) {
113 9 q := documentSelect + ` WHERE doc_id = $1`
114 9 d, err := scanDocument(s.q.QueryRowContext(ctx, q, id.String()))
115 9 if errors.Is(err, sql.ErrNoRows) {
116 3 return nil, ErrNotFound
117 3 }
118 6 if err != nil {
119 0 return nil, fmt.Errorf("get doc id %s: %w", id, err)
120 0 }
121 6 return d, nil
122 }
123
124 // SetDocPath re-points a registered ID at a new path after a rename, stamping
125 // the revision that moved it. It is scoped by space: a rename never crosses
126 // spaces (only a human push can move a file, and a push touches one repo), so a
127 // spaceID that does not match the registered row yields ErrNotFound rather than
128 // silently relocating the document into another space.
129 2 func (s *Store) SetDocPath(ctx context.Context, spaceID int, ref DocRef, rev string) error {
130 2 if err := core.ValidateDocPath(ref.Path); err != nil {
131 0 return err
132 0 }
133 2 const q = `
134 2 UPDATE document_id
135 2 SET path = $3, updated_rev = $4
136 2 WHERE doc_id = $1 AND space_id = $2`
137 2 res, err := s.q.ExecContext(ctx, q, ref.ID.String(), spaceID, ref.Path, rev)
138 2 if err != nil {
139 0 return fmt.Errorf("set path for doc id %s: %w", ref.ID, err)
140 0 }
141 2 return requireOne(res, "set doc path")
142 }
143
144 // UnregisterDocID drops an ID from the registry, for a human push that deleted
145 // the document (deletion is human-push-only by design; an agent proposes
146 // `status: superseded` instead). Scoped by space for the same reason SetDocPath
147 // is. Returns ErrNotFound if no such row exists in that space.
148 2 func (s *Store) UnregisterDocID(ctx context.Context, spaceID int, id core.DocID) error {
149 2 res, err := s.q.ExecContext(ctx,
150 2 `DELETE FROM document_id WHERE doc_id = $1 AND space_id = $2`, id.String(), spaceID)
151 2 if err != nil {
152 0 return fmt.Errorf("unregister doc id %s: %w", id, err)
153 0 }
154 2 return requireOne(res, "unregister doc id")
155 }
156
157 // ListDocsBySpace returns every registered document of a space, by path. The
158 // reconciler uses it to diff the registry against what the approved tree
159 // actually contains.
160 1 func (s *Store) ListDocsBySpace(ctx context.Context, spaceID int) ([]*Document, error) {
161 1 q := documentSelect + ` WHERE space_id = $1 ORDER BY path`
162 1 rows, err := s.q.QueryContext(ctx, q, spaceID)
163 1 if err != nil {
164 0 return nil, fmt.Errorf("list docs space=%d: %w", spaceID, err)
165 0 }
166 1 defer rows.Close()
167 1 var docs []*Document
168 1 for rows.Next() {
169 1 d, err := scanDocument(rows)
170 1 if err != nil {
171 0 return nil, fmt.Errorf("scan document: %w", err)
172 0 }
173 1 docs = append(docs, d)
174 }
175 1 if err := rows.Err(); err != nil {
176 0 return nil, fmt.Errorf("iterate documents: %w", err)
177 0 }
178 1 return docs, nil
179 }
180
181 // DuplicateDocIDs reports IDs that appear more than once in a single batch,
182 // sorted. This is the half of the collision check that needs no database: a
183 // push carrying the same `id:` on two different documents is malformed on its
184 // own terms, whatever the registry says. It is also a precondition of
185 // UpsertDocIDs, since a multi-row upsert cannot touch the same key twice.
186 12 func DuplicateDocIDs(refs []DocRef) []core.DocID {
187 12 seen := make(map[string]int, len(refs))
188 21 for _, r := range refs {
189 21 seen[r.ID.String()]++
190 21 }
191 12 var dup []string
192 17 for id, n := range seen {
193 17 if n > 1 {
194 4 dup = append(dup, id)
195 4 }
196 }
197 12 sort.Strings(dup)
198 12 out := make([]core.DocID, 0, len(dup))
199 12 for _, id := range dup {
200 4 parsed, err := core.ParseDocID(id)
201 4 if err != nil {
202 0 // Impossible: the input carried parsed DocIDs.
203 0 panic(fmt.Sprintf("db: unparseable DocID in batch: %v", err))
204 }
205 4 out = append(out, parsed)
206 }
207 12 return out
208 }
209
210 // CheckDocIDCollisions reports which of refs are already registered to a
211 // different space. This is what the push-validation path calls before a push is
212 // allowed through: a duplicated `id:` corrupts the global registry and silently
213 // breaks link resolution and search, and is far cheaper to reject at push time
214 // than to find weeks later.
215 //
216 // An ID already registered to *this* space is not a collision — that is the
217 // ordinary case of editing or renaming a document that already exists.
218 // Duplicates within refs itself are reported separately by DuplicateDocIDs;
219 // this method only asks the registry.
220 4 func (s *Store) CheckDocIDCollisions(ctx context.Context, spaceID int, refs []DocRef) ([]Collision, error) {
221 4 if len(refs) == 0 {
222 1 return nil, nil
223 1 }
224 3 ids := make([]string, len(refs))
225 5 for i, r := range refs {
226 5 ids[i] = r.ID.String()
227 5 }
228 3 q := documentSelect + ` WHERE doc_id = ANY($1) AND space_id <> $2 ORDER BY doc_id`
229 3 rows, err := s.q.QueryContext(ctx, q, pq.Array(ids), spaceID)
230 3 if err != nil {
231 0 return nil, fmt.Errorf("check doc id collisions: %w", err)
232 0 }
233 3 defer rows.Close()
234 3 var out []Collision
235 3 for rows.Next() {
236 3 d, err := scanDocument(rows)
237 3 if err != nil {
238 0 return nil, fmt.Errorf("scan collision: %w", err)
239 0 }
240 3 out = append(out, Collision{DocID: d.ID, Existing: d})
241 }
242 3 if err := rows.Err(); err != nil {
243 0 return nil, fmt.Errorf("iterate collisions: %w", err)
244 0 }
245 3 return out, nil
246 }
247
248 // UpsertDocIDs registers or re-points every ID in refs for one space, in a
249 // single statement. It is the registry half of a merge: after the merge commit
250 // lands, the documents it touched are at these paths, at this revision.
251 //
252 // The cross-space guard lives in the statement, not in Go: the ON CONFLICT
253 // branch only updates when the existing row belongs to the same space, so a row
254 // owned by another space is left untouched and simply not returned. Any ID that
255 // does not come back is therefore a collision, reported as a *CollisionError
256 // naming where it really lives. A check-then-write in Go would have a window
257 // between the two; this does not.
258 8 func (s *Store) UpsertDocIDs(ctx context.Context, spaceID int, refs []DocRef, rev string) error {
259 8 if len(refs) == 0 {
260 1 return nil
261 1 }
262 7 if dup := DuplicateDocIDs(refs); len(dup) > 0 {
263 1 names := make([]string, len(dup))
264 1 for i, d := range dup {
265 1 names[i] = d.String()
266 1 }
267 1 return fmt.Errorf("%w: %s", ErrDocIDDuplicate, strings.Join(names, ", "))
268 }
269 6 ids := make([]string, len(refs))
270 6 paths := make([]string, len(refs))
271 9 for i, r := range refs {
272 9 if err := core.ValidateDocPath(r.Path); err != nil {
273 1 return err
274 1 }
275 8 ids[i] = r.ID.String()
276 8 paths[i] = r.Path
277 }
278
279 5 const q = `
280 5 INSERT INTO document_id (doc_id, space_id, path, updated_rev)
281 5 SELECT d.doc_id, $2, d.path, $3
282 5 FROM unnest($1::text[], $4::text[]) AS d(doc_id, path)
283 5 ON CONFLICT (doc_id) DO UPDATE
284 5 SET path = EXCLUDED.path, updated_rev = EXCLUDED.updated_rev
285 5 WHERE document_id.space_id = EXCLUDED.space_id
286 5 RETURNING doc_id`
287 5 rows, err := s.q.QueryContext(ctx, q, pq.Array(ids), spaceID, rev, pq.Array(paths))
288 5 if err != nil {
289 0 return fmt.Errorf("upsert doc ids: %w", err)
290 0 }
291 5 applied := make(map[string]bool, len(ids))
292 6 for rows.Next() {
293 6 var id string
294 6 if err := rows.Scan(&id); err != nil {
295 0 rows.Close()
296 0 return fmt.Errorf("scan upserted doc id: %w", err)
297 0 }
298 6 applied[id] = true
299 }
300 5 if err := rows.Err(); err != nil {
301 0 rows.Close()
302 0 return fmt.Errorf("iterate upserted doc ids: %w", err)
303 0 }
304 5 rows.Close()
305 5 if len(applied) == len(ids) {
306 3 return nil
307 3 }
308
309 2 var missing []DocRef
310 4 for _, r := range refs {
311 4 if !applied[r.ID.String()] {
312 2 missing = append(missing, r)
313 2 }
314 }
315 2 collisions, err := s.CheckDocIDCollisions(ctx, spaceID, missing)
316 2 if err != nil {
317 0 return err
318 0 }
319 2 if len(collisions) == 0 {
320 0 // The guard skipped rows but the registry says nothing owns them
321 0 // elsewhere. That is not a condition this schema can produce; refuse
322 0 // rather than report a merge as clean.
323 0 return fmt.Errorf("upsert doc ids: %d of %d rows not applied, but no collision found",
324 0 len(ids)-len(applied), len(ids))
325 0 }
326 2 return &CollisionError{Collisions: collisions}
327 }