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 }