~bigbes/sr-ht-spec

ref: eaa2b65f2b205ccda00b2f09264f6fc966209373 sr-ht-spec/db/document.go -rw-r--r-- 11.0 KiB
eaa2b65f — bigbes fix(service): separate the read-plane rev guard from the review path 27 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
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
package db

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

	"github.com/lib/pq"

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

// Document is one row of the global document ID registry: where the document
// carrying this ID currently lives on its space's approved branch.
//
// Paths move; IDs do not. Cross-space links resolve by ID, comment anchors
// reference IDs, and merge staleness is keyed by ID — so this table is the map
// from the stable name to the moving one, and nothing else.
type Document struct {
	ID         core.DocID
	SpaceID    int
	Path       string
	UpdatedRev string
}

// DocRef pairs a document ID with the path it occupies in a tree. It is the
// unit the push validator and the merge path work in: a set of (id, path) taken
// from the frontmatter of the documents a push or a proposal touches.
type DocRef struct {
	ID   core.DocID
	Path string
}

// Collision is one registered ID that a batch tried to claim for a different
// space. Existing is the row that already holds it, so the rejection message
// can name where the ID actually lives instead of just saying "taken".
type Collision struct {
	DocID    core.DocID
	Existing *Document
}

func (c Collision) String() string {
	return fmt.Sprintf("%s is already registered in space %d at %s",
		c.DocID, c.Existing.SpaceID, c.Existing.Path)
}

// CollisionError reports one or more global ID collisions. It wraps
// ErrDocIDTaken so callers can match the class with errors.Is and still reach
// the per-ID detail for the rejection message the `update` hook prints.
type CollisionError struct {
	Collisions []Collision
}

func (e *CollisionError) Error() string {
	parts := make([]string, len(e.Collisions))
	for i, c := range e.Collisions {
		parts[i] = c.String()
	}
	return "db: document id collision: " + strings.Join(parts, "; ")
}

func (e *CollisionError) Unwrap() error { return ErrDocIDTaken }

const documentSelect = `SELECT doc_id, space_id, path, updated_rev FROM document_id`

func scanDocument(sc rowScanner) (*Document, error) {
	var (
		d     Document
		docID string
	)
	if err := sc.Scan(&docID, &d.SpaceID, &d.Path, &d.UpdatedRev); err != nil {
		return nil, err
	}
	parsed, err := core.ParseDocID(docID)
	if err != nil {
		// The registry only ever accepts parsed IDs, so a row that no longer
		// parses is corruption, not input. Surface it instead of guessing.
		return nil, fmt.Errorf("registry row %q: %w", docID, err)
	}
	d.ID = parsed
	return &d, nil
}

// RegisterDocID claims a document ID for a space at a path. Global uniqueness is
// the doc_id PRIMARY KEY, so a second claim cannot be inserted at all — this
// method maps that refusal to ErrDocIDTaken rather than being the thing that
// prevents it.
func (s *Store) RegisterDocID(ctx context.Context, spaceID int, ref DocRef, rev string) (*Document, error) {
	if err := core.ValidateDocPath(ref.Path); err != nil {
		return nil, err
	}
	const q = `
INSERT INTO document_id (doc_id, space_id, path, updated_rev)
VALUES ($1, $2, $3, $4)`
	_, err := s.q.ExecContext(ctx, q, ref.ID.String(), spaceID, ref.Path, rev)
	if err != nil {
		var pqErr *pq.Error
		if errors.As(err, &pqErr) && pqErr.Code == "23505" {
			return nil, fmt.Errorf("%w: %s", ErrDocIDTaken, ref.ID)
		}
		return nil, fmt.Errorf("register doc id %s: %w", ref.ID, err)
	}
	return &Document{ID: ref.ID, SpaceID: spaceID, Path: ref.Path, UpdatedRev: rev}, nil
}

// DocByID resolves a document ID to its current space and path. This is the
// lookup wikilink resolution and merge staleness both go through. Returns
// ErrNotFound if the ID is not registered.
func (s *Store) DocByID(ctx context.Context, id core.DocID) (*Document, error) {
	q := documentSelect + ` WHERE doc_id = $1`
	d, err := scanDocument(s.q.QueryRowContext(ctx, q, id.String()))
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get doc id %s: %w", id, err)
	}
	return d, nil
}

// SetDocPath re-points a registered ID at a new path after a rename, stamping
// the revision that moved it. It is scoped by space: a rename never crosses
// spaces (only a human push can move a file, and a push touches one repo), so a
// spaceID that does not match the registered row yields ErrNotFound rather than
// silently relocating the document into another space.
func (s *Store) SetDocPath(ctx context.Context, spaceID int, ref DocRef, rev string) error {
	if err := core.ValidateDocPath(ref.Path); err != nil {
		return err
	}
	const q = `
UPDATE document_id
SET path = $3, updated_rev = $4
WHERE doc_id = $1 AND space_id = $2`
	res, err := s.q.ExecContext(ctx, q, ref.ID.String(), spaceID, ref.Path, rev)
	if err != nil {
		return fmt.Errorf("set path for doc id %s: %w", ref.ID, err)
	}
	return requireOne(res, "set doc path")
}

// UnregisterDocID drops an ID from the registry, for a human push that deleted
// the document (deletion is human-push-only by design; an agent proposes
// `status: superseded` instead). Scoped by space for the same reason SetDocPath
// is. Returns ErrNotFound if no such row exists in that space.
func (s *Store) UnregisterDocID(ctx context.Context, spaceID int, id core.DocID) error {
	res, err := s.q.ExecContext(ctx,
		`DELETE FROM document_id WHERE doc_id = $1 AND space_id = $2`, id.String(), spaceID)
	if err != nil {
		return fmt.Errorf("unregister doc id %s: %w", id, err)
	}
	return requireOne(res, "unregister doc id")
}

// ListDocsBySpace returns every registered document of a space, by path. The
// reconciler uses it to diff the registry against what the approved tree
// actually contains.
func (s *Store) ListDocsBySpace(ctx context.Context, spaceID int) ([]*Document, error) {
	q := documentSelect + ` WHERE space_id = $1 ORDER BY path`
	rows, err := s.q.QueryContext(ctx, q, spaceID)
	if err != nil {
		return nil, fmt.Errorf("list docs space=%d: %w", spaceID, err)
	}
	defer rows.Close()
	var docs []*Document
	for rows.Next() {
		d, err := scanDocument(rows)
		if err != nil {
			return nil, fmt.Errorf("scan document: %w", err)
		}
		docs = append(docs, d)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate documents: %w", err)
	}
	return docs, nil
}

// DuplicateDocIDs reports IDs that appear more than once in a single batch,
// sorted. This is the half of the collision check that needs no database: a
// push carrying the same `id:` on two different documents is malformed on its
// own terms, whatever the registry says. It is also a precondition of
// UpsertDocIDs, since a multi-row upsert cannot touch the same key twice.
func DuplicateDocIDs(refs []DocRef) []core.DocID {
	seen := make(map[string]int, len(refs))
	for _, r := range refs {
		seen[r.ID.String()]++
	}
	var dup []string
	for id, n := range seen {
		if n > 1 {
			dup = append(dup, id)
		}
	}
	sort.Strings(dup)
	out := make([]core.DocID, 0, len(dup))
	for _, id := range dup {
		parsed, err := core.ParseDocID(id)
		if err != nil {
			// Impossible: the input carried parsed DocIDs.
			panic(fmt.Sprintf("db: unparseable DocID in batch: %v", err))
		}
		out = append(out, parsed)
	}
	return out
}

// CheckDocIDCollisions reports which of refs are already registered to a
// different space. This is what the push-validation path calls before a push is
// allowed through: a duplicated `id:` corrupts the global registry and silently
// breaks link resolution and search, and is far cheaper to reject at push time
// than to find weeks later.
//
// An ID already registered to *this* space is not a collision — that is the
// ordinary case of editing or renaming a document that already exists.
// Duplicates within refs itself are reported separately by DuplicateDocIDs;
// this method only asks the registry.
func (s *Store) CheckDocIDCollisions(ctx context.Context, spaceID int, refs []DocRef) ([]Collision, error) {
	if len(refs) == 0 {
		return nil, nil
	}
	ids := make([]string, len(refs))
	for i, r := range refs {
		ids[i] = r.ID.String()
	}
	q := documentSelect + ` WHERE doc_id = ANY($1) AND space_id <> $2 ORDER BY doc_id`
	rows, err := s.q.QueryContext(ctx, q, pq.Array(ids), spaceID)
	if err != nil {
		return nil, fmt.Errorf("check doc id collisions: %w", err)
	}
	defer rows.Close()
	var out []Collision
	for rows.Next() {
		d, err := scanDocument(rows)
		if err != nil {
			return nil, fmt.Errorf("scan collision: %w", err)
		}
		out = append(out, Collision{DocID: d.ID, Existing: d})
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate collisions: %w", err)
	}
	return out, nil
}

// UpsertDocIDs registers or re-points every ID in refs for one space, in a
// single statement. It is the registry half of a merge: after the merge commit
// lands, the documents it touched are at these paths, at this revision.
//
// The cross-space guard lives in the statement, not in Go: the ON CONFLICT
// branch only updates when the existing row belongs to the same space, so a row
// owned by another space is left untouched and simply not returned. Any ID that
// does not come back is therefore a collision, reported as a *CollisionError
// naming where it really lives. A check-then-write in Go would have a window
// between the two; this does not.
func (s *Store) UpsertDocIDs(ctx context.Context, spaceID int, refs []DocRef, rev string) error {
	if len(refs) == 0 {
		return nil
	}
	if dup := DuplicateDocIDs(refs); len(dup) > 0 {
		names := make([]string, len(dup))
		for i, d := range dup {
			names[i] = d.String()
		}
		return fmt.Errorf("%w: %s", ErrDocIDDuplicate, strings.Join(names, ", "))
	}
	ids := make([]string, len(refs))
	paths := make([]string, len(refs))
	for i, r := range refs {
		if err := core.ValidateDocPath(r.Path); err != nil {
			return err
		}
		ids[i] = r.ID.String()
		paths[i] = r.Path
	}

	const q = `
INSERT INTO document_id (doc_id, space_id, path, updated_rev)
SELECT d.doc_id, $2, d.path, $3
FROM unnest($1::text[], $4::text[]) AS d(doc_id, path)
ON CONFLICT (doc_id) DO UPDATE
	SET path = EXCLUDED.path, updated_rev = EXCLUDED.updated_rev
	WHERE document_id.space_id = EXCLUDED.space_id
RETURNING doc_id`
	rows, err := s.q.QueryContext(ctx, q, pq.Array(ids), spaceID, rev, pq.Array(paths))
	if err != nil {
		return fmt.Errorf("upsert doc ids: %w", err)
	}
	applied := make(map[string]bool, len(ids))
	for rows.Next() {
		var id string
		if err := rows.Scan(&id); err != nil {
			rows.Close()
			return fmt.Errorf("scan upserted doc id: %w", err)
		}
		applied[id] = true
	}
	if err := rows.Err(); err != nil {
		rows.Close()
		return fmt.Errorf("iterate upserted doc ids: %w", err)
	}
	rows.Close()
	if len(applied) == len(ids) {
		return nil
	}

	var missing []DocRef
	for _, r := range refs {
		if !applied[r.ID.String()] {
			missing = append(missing, r)
		}
	}
	collisions, err := s.CheckDocIDCollisions(ctx, spaceID, missing)
	if err != nil {
		return err
	}
	if len(collisions) == 0 {
		// The guard skipped rows but the registry says nothing owns them
		// elsewhere. That is not a condition this schema can produce; refuse
		// rather than report a merge as clean.
		return fmt.Errorf("upsert doc ids: %d of %d rows not applied, but no collision found",
			len(ids)-len(applied), len(ids))
	}
	return &CollisionError{Collisions: collisions}
}