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*. // // All is not "IDs and Refs happen to be empty", and the distinction is // load-bearing in both directions: // // - All true is the meta-project — a filter that excludes nothing. IDs and // Refs are deliberately left empty rather than enumerated: enumerating // would freeze the corpus as of the moment of the resolve, so a space // created a second later would be missing from "everything" until somebody // re-resolved. That is the sync job the design says the meta-project does // not have, relocated into the query path. // // - All false with an empty membership is a project that selects *nothing*, // and it must keep meaning that. Collapsing it into "everything" would // turn a freshly created, not-yet-populated project into the whole corpus — // the exact opposite of what its author asked for, and invisible when it // happens. // // Callers translating this into a downstream filter must therefore branch on // All, not on len(Refs). search.Query.Spaces in particular follows the opposite // convention (empty means every space), so handing it Refs unconditionally // turns "selects nothing" into "selects everything". // // IDs and Refs are index-aligned: IDs[i] is the row id of Refs[i]. Both are // present because both are needed — the index filters by space reference, while // document_id, proposal and index_stamp key off the row id. type SpaceFilter struct { All bool IDs []int Refs []core.SpaceRef } // 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 SpaceFilter{All: true} } // MatchesNothing reports whether the filter selects no space at all — an empty // project. Worth asking explicitly before running a query, since it is the one // case where the answer is known without touching the index. func (f SpaceFilter) MatchesNothing() bool { return !f.All && len(f.IDs) == 0 } // Matches reports whether a space is within the filter. func (f SpaceFilter) Matches(ref core.SpaceRef) bool { if f.All { return true } for _, r := range f.Refs { if r == ref { return true } } return false } // MatchesID reports whether a space row id is within the filter. func (f SpaceFilter) MatchesID(id int) bool { if f.All { return true } for _, got := range f.IDs { if got == id { return true } } return false } // 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) } f := SpaceFilter{ IDs: make([]int, 0, len(spaces)), Refs: make([]core.SpaceRef, 0, len(spaces)), } for _, sp := range spaces { f.IDs = append(f.IDs, sp.ID) f.Refs = append(f.Refs, sp.Ref) } return f, 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 }