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