package service
import (
"context"
"errors"
"fmt"
"time"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
"sourcecraft.dev/bigbes/sr-ht-spec/db"
)
// Project is one project: a named set of spaces sharing one search scope, one
// MCP view and one wikilink namespace.
//
// A project is pure metadata — a saved filter, not a container. It owns no
// index and no storage, which is why this type carries no repository, no
// revision and no membership: there is one global bleve index, and querying a
// project means restricting that index to the project's spaces. Resolving a
// project to that restriction is ResolveProject.
type Project struct {
Ref core.ProjectRef
ID int
Created time.Time
}
// SpaceFilter is what a project resolves to: the set of spaces a query is
// restricted to. It is the entirety of what a project *does*, and it is
// core.SpaceFilter under this package's name.
//
// It lives in core because search.Query takes the same type. The alternative —
// this package's struct translated into a query's own space list at each
// caller — is where the polarity trap lived: an empty project's membership is
// an empty slice, "no terms" reads as "no restriction" to a query filter, and a
// freshly created project silently became the whole corpus. There is no slice
// to hand over any more; a filter carries its own polarity all the way down.
type SpaceFilter = core.SpaceFilter
// EverythingFilter is the degenerate filter: the meta-project, "merge all my
// doc work into one searchable thing".
//
// It is a value, not a row, and that is the decision this file rests on. A
// stored `+everything` project would have to be kept in step with every
// CreateSpace — a sync job whose one failure mode is a meta-project that
// silently omits a space — and it could be renamed or deleted, which the
// meta-project must not be. As a filter that excludes nothing it needs no
// storage, no migration data and no maintenance, and adding a space to the
// service adds it to the meta-project by construction.
func EverythingFilter() SpaceFilter { return core.EverythingFilter() }
// ResolveProject resolves a project reference to the space filter its queries
// run under. This is the read the whole feature exists for.
//
// The meta-project resolves without touching the database, to the filter that
// excludes nothing. Every other reference resolves to its membership; a project
// with no member spaces resolves to a filter that selects nothing, which is
// what it says.
//
// Returns ErrNotFound if no such project exists.
func (s *Service) ResolveProject(ctx context.Context, ref core.ProjectRef) (SpaceFilter, error) {
if ref.IsMeta() {
return EverythingFilter(), nil
}
row, err := s.projectRow(ctx, ref)
if err != nil {
return SpaceFilter{}, err
}
spaces, err := s.store.ProjectSpaces(ctx, row.ID)
if err != nil {
return SpaceFilter{}, fmt.Errorf("service: resolve project %s: %w", ref, err)
}
refs := make([]core.SpaceRef, 0, len(spaces))
ids := make([]int, 0, len(spaces))
for _, sp := range spaces {
refs = append(refs, sp.Ref)
ids = append(ids, sp.ID)
}
// Named, not "all": a project with no members selects nothing, and the
// filter says so wherever it is carried.
return core.SpacesFilter(refs, ids), nil
}
// CreateProject creates an empty project.
//
// Empty is the honest starting state: a project is a saved filter and a filter
// with no terms selects nothing, so a new project returns no results until
// spaces are added to it. Creating it pre-populated with anything would be
// guessing at what the filter is for.
//
// The reserved meta-project name is refused with core.ErrReservedName — it is
// an address that resolves to a filter, and a row of that name could only
// shadow it.
func (s *Service) 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
}
row, err := s.store.CreateProject(ctx, ref)
if err != nil {
if errors.Is(err, db.ErrProjectExists) {
return nil, fmt.Errorf("%w: %w", ErrProjectExists, err)
}
return nil, fmt.Errorf("service: create project %s: %w", ref, err)
}
return &Project{Ref: row.Ref, ID: row.ID, Created: row.Created}, nil
}
// GetProject resolves a project by reference. Returns ErrNotFound if no such
// project exists — including for the meta-project, which has no row; callers
// wanting its filter call ResolveProject, and callers wanting its membership
// call ProjectSpaces.
func (s *Service) GetProject(ctx context.Context, ref core.ProjectRef) (*Project, error) {
row, err := s.projectRow(ctx, ref)
if err != nil {
return nil, err
}
return &Project{Ref: row.Ref, ID: row.ID, Created: row.Created}, nil
}
// ListProjects returns every stored project, ordered by owner then name.
//
// The meta-project is not in the list, because it is not a row. A caller
// rendering a project index adds it as the degenerate filter it is, which also
// means it can never be missing, renamed or deleted.
func (s *Service) ListProjects(ctx context.Context) ([]*Project, error) {
rows, err := s.store.ListProjects(ctx)
if err != nil {
return nil, fmt.Errorf("service: list projects: %w", err)
}
out := make([]*Project, 0, len(rows))
for _, row := range rows {
out = append(out, &Project{Ref: row.Ref, ID: row.ID, Created: row.Created})
}
return out, nil
}
// DeleteProject deletes a project and its membership rows.
//
// Nothing else goes: a project owns no index and no storage, so deleting one
// deletes a name and a saved query. Every space it named still exists, still
// holds its documents, and is still in the one global index.
func (s *Service) DeleteProject(ctx context.Context, ref core.ProjectRef) error {
row, err := s.projectRow(ctx, ref)
if err != nil {
return err
}
if err := s.store.DeleteProject(ctx, row.ID); err != nil {
if errors.Is(err, db.ErrNotFound) {
return fmt.Errorf("%w: project %s", ErrNotFound, ref)
}
return fmt.Errorf("service: delete project %s: %w", ref, err)
}
return nil
}
// AddSpaceToProject adds a space to a project's filter. Adding a space that is
// already a member changes nothing and is not an error: membership is a set.
//
// Both sides must exist. The space is resolved by its row rather than by
// opening its repository: membership is metadata about a row, and requiring the
// bare repository to be openable would make editing a saved filter fail on a
// space whose repository is being moved.
func (s *Service) AddSpaceToProject(ctx context.Context, ref core.ProjectRef, space core.SpaceRef) error {
proj, sp, err := s.projectAndSpace(ctx, ref, space)
if err != nil {
return err
}
if err := s.store.AddProjectSpace(ctx, proj.ID, sp.ID); err != nil {
if errors.Is(err, db.ErrNotFound) {
return fmt.Errorf("%w: project %s or space %s", ErrNotFound, ref, space)
}
return fmt.Errorf("service: add %s to project %s: %w", space, ref, err)
}
return nil
}
// RemoveSpaceFromProject drops a space from a project's filter, leaving the
// space itself untouched. Returns ErrNotFound if the space was not a member —
// reporting it beats claiming a filter change that did not happen.
func (s *Service) RemoveSpaceFromProject(ctx context.Context, ref core.ProjectRef, space core.SpaceRef) error {
proj, sp, err := s.projectAndSpace(ctx, ref, space)
if err != nil {
return err
}
if err := s.store.RemoveProjectSpace(ctx, proj.ID, sp.ID); err != nil {
if errors.Is(err, db.ErrNotFound) {
return fmt.Errorf("%w: space %s is not a member of project %s", ErrNotFound, space, ref)
}
return fmt.Errorf("service: remove %s from project %s: %w", space, ref, err)
}
return nil
}
// ProjectSpaces lists a project's member spaces, ordered by owner then name.
//
// This is the listing counterpart of ResolveProject, and the difference is not
// cosmetic: a filter is never enumerated (see SpaceFilter), whereas a list is
// enumerated by definition. So the meta-project lists every space here while
// resolving to an unenumerated "everything" there — a page showing what is in
// it wants the names, and a query must not freeze them.
//
// Repositories are not opened, exactly as ListSpaces does not open them.
func (s *Service) ProjectSpaces(ctx context.Context, ref core.ProjectRef) ([]*Space, error) {
if ref.IsMeta() {
return s.ListSpaces(ctx)
}
row, err := s.projectRow(ctx, ref)
if err != nil {
return nil, err
}
rows, err := s.store.ProjectSpaces(ctx, row.ID)
if err != nil {
return nil, fmt.Errorf("service: list spaces of project %s: %w", ref, err)
}
out := make([]*Space, 0, len(rows))
for _, sp := range rows {
out = append(out, &Space{Ref: sp.Ref, ID: sp.ID, Created: sp.Created})
}
return out, nil
}
// ProjectsForSpace returns every project a space belongs to, ordered by owner
// then name. A space belongs to any number of them, including none — and to
// the meta-project always, which is not listed here because it is not a row.
func (s *Service) ProjectsForSpace(ctx context.Context, space core.SpaceRef) ([]*Project, error) {
row, err := s.store.GetSpace(ctx, space)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("%w: space %s", ErrNotFound, space)
}
return nil, fmt.Errorf("service: look up space %s: %w", space, err)
}
rows, err := s.store.ProjectsBySpace(ctx, row.ID)
if err != nil {
return nil, fmt.Errorf("service: list projects of space %s: %w", space, err)
}
out := make([]*Project, 0, len(rows))
for _, p := range rows {
out = append(out, &Project{Ref: p.Ref, ID: p.ID, Created: p.Created})
}
return out, nil
}
// projectRow resolves a project reference to its row, mapping db's absence onto
// this package's. The meta-project is refused here rather than at each caller:
// it has no row, and every operation that needs one — get, delete, membership
// editing — is an operation on storage that the meta-project deliberately does
// not have.
func (s *Service) projectRow(ctx context.Context, ref core.ProjectRef) (*db.Project, error) {
if ref.IsMeta() {
return nil, fmt.Errorf("%w: project %s is the meta-project, a filter over every space rather than a row",
ErrNotFound, ref)
}
row, err := s.store.GetProject(ctx, ref)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, fmt.Errorf("%w: project %s", ErrNotFound, ref)
}
return nil, fmt.Errorf("service: look up project %s: %w", ref, err)
}
return row, nil
}
// projectAndSpace resolves both sides of a membership edit, so the two doors
// that perform one report a missing project and a missing space the same way.
func (s *Service) projectAndSpace(ctx context.Context, ref core.ProjectRef, space core.SpaceRef) (*db.Project, *db.Space, error) {
proj, err := s.projectRow(ctx, ref)
if err != nil {
return nil, nil, err
}
sp, err := s.store.GetSpace(ctx, space)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, nil, fmt.Errorf("%w: space %s", ErrNotFound, space)
}
return nil, nil, fmt.Errorf("service: look up space %s: %w", space, err)
}
return proj, sp, nil
}