package db
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/lib/pq"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// Project is a named set of spaces: one search scope, one MCP view, one
// wikilink namespace.
//
// A project is **pure metadata — a saved filter, not a container**. It owns no
// index and no storage, so this row is a name and project_space is the filter.
// Nothing here is a parent of a space: a space belongs to any number of
// projects, or none, and deleting a project deletes no content.
//
// In particular a project does not scope document IDs. Those are global, and
// deliberately so: projects are edited after merges, so a per-project registry
// could juxtapose two already-merged documents claiming one identity with no
// merge left to reject.
type Project struct {
ID int
Ref core.ProjectRef
Created time.Time
}
const projectSelect = `SELECT id, owner, name, created FROM project`
func scanProject(sc rowScanner) (*Project, error) {
var p Project
if err := sc.Scan(&p.ID, &p.Ref.Owner, &p.Ref.Name, &p.Created); err != nil {
return nil, err
}
return &p, nil
}
// CreateProject inserts a project row with no member spaces.
//
// The name is validated with core first, which also refuses the reserved
// meta-project name: "~owner/+everything" is an address that resolves to a
// filter excluding nothing, so a row claiming it could only shadow it — and
// keeping it a row would need the sync job the design says the meta-project
// does not have.
//
// A uq_project_owner_name violation maps to ErrProjectExists.
func (s *Store) CreateProject(ctx context.Context, ref core.ProjectRef) (*Project, error) {
if err := core.ValidateOwner(ref.Owner); err != nil {
return nil, err
}
if err := core.ValidateProjectName(ref.Name); err != nil {
return nil, err
}
const q = `
INSERT INTO project (owner, name, created)
VALUES ($1, $2, $3)
RETURNING id, created`
var p Project
p.Ref = ref
err := s.q.QueryRowContext(ctx, q, ref.Owner, ref.Name, time.Now().UTC()).
Scan(&p.ID, &p.Created)
if err != nil {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "23505" {
return nil, fmt.Errorf("%w: %s", ErrProjectExists, ref)
}
return nil, fmt.Errorf("insert project %s: %w", ref, err)
}
return &p, nil
}
// GetProject resolves a project by owner and name. Returns ErrNotFound if no
// such project exists.
func (s *Store) GetProject(ctx context.Context, ref core.ProjectRef) (*Project, error) {
q := projectSelect + ` WHERE owner = $1 AND name = $2`
p, err := scanProject(s.q.QueryRowContext(ctx, q, ref.Owner, ref.Name))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get project %s: %w", ref, err)
}
return p, nil
}
// ListProjects returns every project, ordered by owner then name.
//
// The meta-project is not among them, and must not be: it has no row. A caller
// rendering a project list adds it as the degenerate filter it is.
func (s *Store) ListProjects(ctx context.Context) ([]*Project, error) {
q := projectSelect + ` ORDER BY owner, name`
rows, err := s.q.QueryContext(ctx, q)
if err != nil {
return nil, fmt.Errorf("list projects: %w", err)
}
defer rows.Close()
var projects []*Project
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, fmt.Errorf("scan project: %w", err)
}
projects = append(projects, p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate projects: %w", err)
}
return projects, nil
}
// DeleteProject removes a project and, by cascade, its membership rows. No
// space, document or index entry is touched — a project is a saved filter, so
// deleting one deletes a name and a query, never content.
//
// Returns ErrNotFound if no such project exists.
func (s *Store) DeleteProject(ctx context.Context, projectID int) error {
res, err := s.q.ExecContext(ctx, `DELETE FROM project WHERE id = $1`, projectID)
if err != nil {
return fmt.Errorf("delete project %d: %w", projectID, err)
}
return requireOne(res, "delete project")
}
// AddProjectSpace adds a space to a project's filter.
//
// Membership is a set, so adding a space that is already a member succeeds and
// changes nothing: "the space is in the project" is true either way, and there
// is no state for a second copy to occupy. This is not a swallowed conflict —
// the composite primary key is what makes the duplicate unrepresentable, and
// this method says so out loud.
//
// A project_id or space_id with no row behind it violates a foreign key and is
// reported as ErrNotFound: a filter term pointing at nothing would silently
// narrow every query made through it.
func (s *Store) AddProjectSpace(ctx context.Context, projectID, spaceID int) error {
const q = `
INSERT INTO project_space (project_id, space_id)
VALUES ($1, $2)
ON CONFLICT (project_id, space_id) DO NOTHING`
if _, err := s.q.ExecContext(ctx, q, projectID, spaceID); err != nil {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "23503" {
return fmt.Errorf("%w: project %d or space %d", ErrNotFound, projectID, spaceID)
}
return fmt.Errorf("add space %d to project %d: %w", spaceID, projectID, err)
}
return nil
}
// RemoveProjectSpace drops a space from a project's filter. The space itself is
// untouched; only the filter term goes.
//
// Returns ErrNotFound when the space was not a member. Unlike adding, removing
// is not idempotent: "remove what is not there" is either a caller working from
// a stale membership list or a concurrent edit, and both are worth hearing
// about, whereas a silent success reports a filter change that did not happen.
func (s *Store) RemoveProjectSpace(ctx context.Context, projectID, spaceID int) error {
const q = `DELETE FROM project_space WHERE project_id = $1 AND space_id = $2`
res, err := s.q.ExecContext(ctx, q, projectID, spaceID)
if err != nil {
return fmt.Errorf("remove space %d from project %d: %w", spaceID, projectID, err)
}
return requireOne(res, "remove project space")
}
// ProjectSpaces resolves a project to its member spaces, ordered by owner then
// name. This is the filter, and it is the whole of what a project *is*: a
// project query is the one global index restricted to these spaces.
//
// The rows are returned rather than bare ids because both forms are needed at
// once — the index filters by space reference, while document_id, proposal and
// index_stamp all key off the id — and one join is cheaper than resolving each
// id afterwards.
//
// An empty result means the project selects no spaces. It emphatically does not
// mean "everything": that would turn an empty saved filter into the whole
// corpus, which is the opposite of what its author asked for. Callers must not
// collapse the two.
func (s *Store) ProjectSpaces(ctx context.Context, projectID int) ([]*Space, error) {
const q = `
SELECT s.id, s.owner, s.name, s.created
FROM space s
JOIN project_space ps ON ps.space_id = s.id
WHERE ps.project_id = $1
ORDER BY s.owner, s.name`
rows, err := s.q.QueryContext(ctx, q, projectID)
if err != nil {
return nil, fmt.Errorf("list spaces of project %d: %w", projectID, err)
}
defer rows.Close()
var spaces []*Space
for rows.Next() {
sp, err := scanSpace(rows)
if err != nil {
return nil, fmt.Errorf("scan project space: %w", err)
}
spaces = append(spaces, sp)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate project spaces: %w", err)
}
return spaces, nil
}
// ProjectsBySpace returns every project a space belongs to, ordered by owner
// then name. A space belongs to any number of them, including none.
//
// This is the direction the write side asks about: after a merge, "which saved
// filters just changed" is this query — though nothing is reindexed per project,
// because there is one index and every project containing the space sees the
// change for free.
func (s *Store) ProjectsBySpace(ctx context.Context, spaceID int) ([]*Project, error) {
const q = `
SELECT p.id, p.owner, p.name, p.created
FROM project p
JOIN project_space ps ON ps.project_id = p.id
WHERE ps.space_id = $1
ORDER BY p.owner, p.name`
rows, err := s.q.QueryContext(ctx, q, spaceID)
if err != nil {
return nil, fmt.Errorf("list projects of space %d: %w", spaceID, err)
}
defer rows.Close()
var projects []*Project
for rows.Next() {
p, err := scanProject(rows)
if err != nil {
return nil, fmt.Errorf("scan project: %w", err)
}
projects = append(projects, p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate projects: %w", err)
}
return projects, nil
}