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}
}