M core/errors.go => core/errors.go +6 -0
@@ 21,6 21,12 @@ var (
// ErrInvalidName is returned for a malformed owner or space name.
ErrInvalidName = errors.New("invalid name")
+ // ErrReservedName is returned for a name that is well-formed but not the
+ // caller's to claim. The one instance today is the meta-project: it is an
+ // address that resolves to a filter excluding nothing, so a stored project
+ // of that name could only shadow it.
+ ErrReservedName = errors.New("reserved name")
+
// ErrInvalidPath is returned for a path that is not a safe relative path
// inside a space: absolute, traversing, or carrying bytes that would be
// unsafe in a git tree or misleading in the review UI.
M core/names.go => core/names.go +94 -0
@@ 15,6 15,11 @@ const (
// under the repos root and a URL segment, so it stays short.
MaxSpaceNameLen = 100
+ // MaxProjectNameLen bounds project names. A project is a saved filter, not
+ // a directory, so nothing on disk is named after it — but it is a URL
+ // segment, so it stays as short as a space name.
+ MaxProjectNameLen = 100
+
// MaxPathLen bounds a document or attachment path within a space. Well
// under any filesystem limit; the point is to keep a hostile path out of
// the index and the render cache, not to be permissive.
@@ 120,6 125,95 @@ func ParseSpaceRef(s string) (SpaceRef, error) {
return ref, nil
}
+const (
+ // ProjectSigil distinguishes a project from a space in one character:
+ // "~owner/space" is a repository, "~owner/+project" is a saved filter over
+ // a set of them. Like the '~' on an owner, it is decoration on the address
+ // and is never part of the stored name.
+ ProjectSigil = "+"
+
+ // MetaProjectName is the reserved name of the meta-project,
+ // "~owner/+everything" — the project whose membership is everything.
+ //
+ // It is an address, not a row. The design's meta-project is "a filter that
+ // excludes nothing", with "no separate aggregate entity, no copying, and no
+ // sync job"; a stored row would need one, because every new space would
+ // have to be added to it and forgetting once would silently make the
+ // meta-project incomplete. So this name resolves without touching storage,
+ // and ValidateProjectName refuses it as a stored name — a row claiming it
+ // could only shadow an address that already resolves.
+ MetaProjectName = "everything"
+)
+
+// ProjectRef identifies a project: a named set of spaces owned by one user.
+// Like SpaceRef, both fields are stored undecorated — Owner carries no '~' and
+// Name carries no '+'.
+type ProjectRef struct {
+ Owner string
+ Name string
+}
+
+// String renders the canonical URL form, "~owner/+name".
+func (r ProjectRef) String() string { return "~" + r.Owner + "/" + ProjectSigil + r.Name }
+
+// IsMeta reports whether r addresses the meta-project — the degenerate filter
+// that excludes nothing. It is the one project reference that resolves without
+// a stored row.
+func (r ProjectRef) IsMeta() bool { return r.Name == MetaProjectName }
+
+// ValidateProjectName reports whether s is a well-formed *storable* project
+// name: the same character family as a space name, capped at
+// MaxProjectNameLen, and not the reserved meta-project name.
+//
+// The reserved name fails with ErrReservedName rather than ErrInvalidName,
+// because it is well-formed and the caller is being told "that one is not
+// yours to create", not "that is not a name". Parsing a reference is a
+// different question — ParseProjectRef accepts "~bigbes/+everything", since it
+// is a perfectly good address; only storing it is refused.
+func ValidateProjectName(s string) error {
+ if err := validateName("project", s, MaxProjectNameLen); err != nil {
+ return err
+ }
+ if s == MetaProjectName {
+ return fmt.Errorf("%w: project %q is the meta-project, which is a filter rather than a row",
+ ErrReservedName, s)
+ }
+ return nil
+}
+
+// ParseProjectRef parses "~owner/+name" (or "owner/+name") into a validated
+// ProjectRef. The '+' is required: it is what keeps projects and spaces in one
+// URL namespace without either being able to shadow the other.
+//
+// Unlike ValidateProjectName this accepts the meta-project, which is an address
+// that resolves to a filter rather than to a row.
+func ParseProjectRef(s string) (ProjectRef, error) {
+ trimmed := strings.Trim(s, "/")
+ if trimmed == "" {
+ return ProjectRef{}, fmt.Errorf("%w: empty project reference", ErrInvalidName)
+ }
+ segs := strings.Split(trimmed, "/")
+ if len(segs) != 2 {
+ return ProjectRef{}, fmt.Errorf("%w: project reference %q must have exactly 2 segments, got %d",
+ ErrInvalidName, s, len(segs))
+ }
+ if !strings.HasPrefix(segs[1], ProjectSigil) {
+ return ProjectRef{}, fmt.Errorf("%w: project reference %q must name the project as %q",
+ ErrInvalidName, s, ProjectSigil+"name")
+ }
+ ref := ProjectRef{
+ Owner: strings.TrimPrefix(segs[0], "~"),
+ Name: strings.TrimPrefix(segs[1], ProjectSigil),
+ }
+ if err := ValidateOwner(ref.Owner); err != nil {
+ return ProjectRef{}, err
+ }
+ if err := validateName("project", ref.Name, MaxProjectNameLen); err != nil {
+ return ProjectRef{}, err
+ }
+ return ref, nil
+}
+
// badPathRune reports whether r must never appear in a path. Two families:
// control characters, which git tolerates in a tree entry but which corrupt
// logs, JSON and the index; and the Unicode bidirectional overrides, which can
M core/names_test.go => core/names_test.go +120 -0
@@ 214,3 214,123 @@ func TestValidateDocPath(t *testing.T) {
})
}
}
+
+func TestValidateProjectName(t *testing.T) {
+ tests := []struct {
+ in string
+ want error // nil means accepted
+ }{
+ {"tarantool", nil},
+ {"all-docs", nil},
+ {"home_ops", nil},
+ {"v1.0", nil},
+ {strings.Repeat("a", MaxProjectNameLen), nil},
+
+ {"", ErrInvalidName},
+ {strings.Repeat("a", MaxProjectNameLen+1), ErrInvalidName},
+ {"-dashfirst", ErrInvalidName},
+ {"has/slash", ErrInvalidName},
+ {"has..dots", ErrInvalidName},
+ {".", ErrInvalidName},
+ {"Upper", ErrInvalidName},
+ {"has space", ErrInvalidName},
+ // The '+' is address decoration, never part of the stored name, so a
+ // name that carries one is a caller that failed to strip it.
+ {"+everything", ErrInvalidName},
+
+ // Well-formed, but the meta-project is a filter rather than a row: a
+ // project of this name could only shadow an address that already
+ // resolves without one.
+ {MetaProjectName, ErrReservedName},
+ }
+ for _, tc := range tests {
+ err := ValidateProjectName(tc.in)
+ if tc.want == nil {
+ if err != nil {
+ t.Errorf("ValidateProjectName(%q) = %v, want nil", tc.in, err)
+ }
+ continue
+ }
+ if !errors.Is(err, tc.want) {
+ t.Errorf("ValidateProjectName(%q) = %v, want %v", tc.in, err, tc.want)
+ }
+ }
+}
+
+func TestParseProjectRef(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ wantOwner string
+ wantName string
+ ok bool
+ }{
+ {"tilde form", "~bigbes/+tarantool", "bigbes", "tarantool", true},
+ {"no tilde", "bigbes/+tarantool", "bigbes", "tarantool", true},
+ {"surrounding slashes", "/~bigbes/+tarantool/", "bigbes", "tarantool", true},
+ // The meta-project is an address that resolves to a filter; only
+ // *storing* it is refused, which is ValidateProjectName's job.
+ {"meta project", "~bigbes/+everything", "bigbes", "everything", true},
+
+ {"empty", "", "", "", false},
+ {"one segment", "~bigbes", "", "", false},
+ {"three segments", "~bigbes/+tarantool/docs", "", "", false},
+ {"no sigil", "~bigbes/tarantool", "", "", false},
+ {"sigil only", "~bigbes/+", "", "", false},
+ {"empty owner", "~/+tarantool", "", "", false},
+ {"traversal name", "~bigbes/+..", "", "", false},
+ {"uppercase", "~bigbes/+Tarantool", "", "", false},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ ref, err := ParseProjectRef(tc.in)
+ if (err == nil) != tc.ok {
+ t.Fatalf("ParseProjectRef(%q) = %v, %v, want ok=%v", tc.in, ref, err, tc.ok)
+ }
+ if !tc.ok {
+ if !errors.Is(err, ErrInvalidName) {
+ t.Fatalf("error %v is not ErrInvalidName", err)
+ }
+ return
+ }
+ if ref.Owner != tc.wantOwner || ref.Name != tc.wantName {
+ t.Fatalf("ParseProjectRef(%q) = %+v, want {%q %q}", tc.in, ref, tc.wantOwner, tc.wantName)
+ }
+ if got, want := ref.String(), "~"+tc.wantOwner+"/+"+tc.wantName; got != want {
+ t.Fatalf("String() = %q, want %q", got, want)
+ }
+ // Round-tripping the rendered form must give the same reference,
+ // or a project URL means something different once it has been
+ // through a redirect.
+ again, err := ParseProjectRef(ref.String())
+ if err != nil || again != ref {
+ t.Fatalf("round trip of %q = %+v, %v", ref, again, err)
+ }
+ })
+ }
+}
+
+// A project and a space of the same name are different addresses, which is the
+// whole job of the sigil.
+func TestProjectAndSpaceNamespacesDoNotCollide(t *testing.T) {
+ sp := SpaceRef{Owner: "bigbes", Name: "rfcs"}
+ pr := ProjectRef{Owner: "bigbes", Name: "rfcs"}
+ if sp.String() == pr.String() {
+ t.Fatalf("space and project render alike: %q", sp)
+ }
+ if _, err := ParseSpaceRef(pr.String()); err == nil {
+ t.Errorf("ParseSpaceRef accepted the project reference %q", pr)
+ }
+ if _, err := ParseProjectRef(sp.String()); err == nil {
+ t.Errorf("ParseProjectRef accepted the space reference %q", sp)
+ }
+}
+
+func TestIsMeta(t *testing.T) {
+ if !(ProjectRef{Owner: "bigbes", Name: MetaProjectName}).IsMeta() {
+ t.Error("the reserved name must be the meta-project")
+ }
+ if (ProjectRef{Owner: "bigbes", Name: "tarantool"}).IsMeta() {
+ t.Error("an ordinary project must not be the meta-project")
+ }
+}
A db/project.go => db/project.go +241 -0
@@ 0,0 1,241 @@
+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
+}
A db/project_test.go => db/project_test.go +279 -0
@@ 0,0 1,279 @@
+package db
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+)
+
+// mkProject is a convenience for CreateProject in tests.
+func mkProject(t *testing.T, s *Store, ctx context.Context, owner, name string) *Project {
+ t.Helper()
+ p, err := s.CreateProject(ctx, core.ProjectRef{Owner: owner, Name: name})
+ if err != nil {
+ t.Fatalf("create project ~%s/+%s: %v", owner, name, err)
+ }
+ return p
+}
+
+func TestProjectLifecycle(t *testing.T) {
+ s, _, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ ref := core.ProjectRef{Owner: "bigbes", Name: "tarantool"}
+ p, err := s.CreateProject(ctx, ref)
+ if err != nil {
+ t.Fatalf("create project: %v", err)
+ }
+ if p.ID == 0 {
+ t.Fatal("expected a non-zero project id")
+ }
+ if p.Created.IsZero() {
+ t.Fatal("expected created to be set")
+ }
+ if p.Ref != ref {
+ t.Fatalf("ref round-trip: got %v, want %v", p.Ref, ref)
+ }
+
+ // (owner, name) is unique.
+ if _, err := s.CreateProject(ctx, ref); !errors.Is(err, ErrProjectExists) {
+ t.Fatalf("duplicate project = %v, want ErrProjectExists", err)
+ }
+ // Same name under a different owner is fine.
+ if _, err := s.CreateProject(ctx, core.ProjectRef{Owner: "someone", Name: "tarantool"}); err != nil {
+ t.Fatalf("same name, different owner: %v", err)
+ }
+ // A project and a space may share a name: they are different namespaces,
+ // addressed "~bigbes/+tarantool" and "~bigbes/tarantool".
+ if _, err := s.CreateSpace(ctx, core.SpaceRef{Owner: "bigbes", Name: "tarantool"}); err != nil {
+ t.Fatalf("space with a project's name: %v", err)
+ }
+
+ got, err := s.GetProject(ctx, ref)
+ if err != nil {
+ t.Fatalf("get project: %v", err)
+ }
+ if got.ID != p.ID || got.Ref != ref {
+ t.Fatalf("get project returned %+v, want id=%d ref=%v", got, p.ID, ref)
+ }
+ if _, err := s.GetProject(ctx, core.ProjectRef{Owner: "bigbes", Name: "absent"}); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("missing project = %v, want ErrNotFound", err)
+ }
+
+ mkProject(t, s, ctx, "bigbes", "home-ops")
+ projects, err := s.ListProjects(ctx)
+ if err != nil {
+ t.Fatalf("list projects: %v", err)
+ }
+ want := []string{"~bigbes/+home-ops", "~bigbes/+tarantool", "~someone/+tarantool"}
+ if len(projects) != len(want) {
+ t.Fatalf("expected %d projects, got %d", len(want), len(projects))
+ }
+ for i, p := range projects {
+ if p.Ref.String() != want[i] {
+ t.Errorf("projects[%d] = %s, want %s", i, p.Ref, want[i])
+ }
+ }
+
+ if err := s.DeleteProject(ctx, p.ID); err != nil {
+ t.Fatalf("delete project: %v", err)
+ }
+ if _, err := s.GetProject(ctx, ref); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("get after delete = %v, want ErrNotFound", err)
+ }
+ if err := s.DeleteProject(ctx, p.ID); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("second delete = %v, want ErrNotFound", err)
+ }
+}
+
+// A project is a saved filter: this is the query the index needs, and the one
+// every other project operation exists to serve.
+func TestProjectMembership(t *testing.T) {
+ s, _, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ rfcs := mkSpace(t, s, ctx, "bigbes", "rfcs")
+ notes := mkSpace(t, s, ctx, "bigbes", "notes")
+ other := mkSpace(t, s, ctx, "bigbes", "unrelated")
+ proj := mkProject(t, s, ctx, "bigbes", "tarantool")
+
+ // A brand-new project selects nothing — not everything.
+ spaces, err := s.ProjectSpaces(ctx, proj.ID)
+ if err != nil {
+ t.Fatalf("project spaces: %v", err)
+ }
+ if len(spaces) != 0 {
+ t.Fatalf("a fresh project selects %d spaces, want 0", len(spaces))
+ }
+
+ if err := s.AddProjectSpace(ctx, proj.ID, rfcs.ID); err != nil {
+ t.Fatalf("add rfcs: %v", err)
+ }
+ if err := s.AddProjectSpace(ctx, proj.ID, notes.ID); err != nil {
+ t.Fatalf("add notes: %v", err)
+ }
+ // Membership is a set: adding twice is not an error and adds nothing.
+ if err := s.AddProjectSpace(ctx, proj.ID, rfcs.ID); err != nil {
+ t.Fatalf("re-add rfcs: %v", err)
+ }
+
+ spaces, err = s.ProjectSpaces(ctx, proj.ID)
+ if err != nil {
+ t.Fatalf("project spaces: %v", err)
+ }
+ want := []string{"~bigbes/notes", "~bigbes/rfcs"}
+ if len(spaces) != len(want) {
+ t.Fatalf("project selects %d spaces, want %d", len(spaces), len(want))
+ }
+ for i, sp := range spaces {
+ if sp.Ref.String() != want[i] {
+ t.Errorf("spaces[%d] = %s, want %s", i, sp.Ref, want[i])
+ }
+ if sp.ID == 0 {
+ t.Errorf("spaces[%d] has no id; the id set is the filter", i)
+ }
+ }
+
+ // The other direction.
+ projects, err := s.ProjectsBySpace(ctx, rfcs.ID)
+ if err != nil {
+ t.Fatalf("projects by space: %v", err)
+ }
+ if len(projects) != 1 || projects[0].ID != proj.ID {
+ t.Fatalf("projects of ~bigbes/rfcs = %+v, want just %s", projects, proj.Ref)
+ }
+ projects, err = s.ProjectsBySpace(ctx, other.ID)
+ if err != nil {
+ t.Fatalf("projects by space: %v", err)
+ }
+ if len(projects) != 0 {
+ t.Fatalf("a space in no project reports %d, want 0", len(projects))
+ }
+ // A space in two projects is ordinary: projects are filters, not owners.
+ second := mkProject(t, s, ctx, "bigbes", "all-rfcs")
+ if err := s.AddProjectSpace(ctx, second.ID, rfcs.ID); err != nil {
+ t.Fatalf("add rfcs to a second project: %v", err)
+ }
+ projects, err = s.ProjectsBySpace(ctx, rfcs.ID)
+ if err != nil {
+ t.Fatalf("projects by space: %v", err)
+ }
+ if len(projects) != 2 {
+ t.Fatalf("~bigbes/rfcs is in %d projects, want 2", len(projects))
+ }
+
+ if err := s.RemoveProjectSpace(ctx, proj.ID, notes.ID); err != nil {
+ t.Fatalf("remove notes: %v", err)
+ }
+ // Removing what is not a member is reported, not silently accepted.
+ if err := s.RemoveProjectSpace(ctx, proj.ID, notes.ID); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("second remove = %v, want ErrNotFound", err)
+ }
+ spaces, err = s.ProjectSpaces(ctx, proj.ID)
+ if err != nil {
+ t.Fatalf("project spaces: %v", err)
+ }
+ if len(spaces) != 1 || spaces[0].ID != rfcs.ID {
+ t.Fatalf("after removal the project selects %+v", spaces)
+ }
+ // The removed space still exists; only the filter term went.
+ if _, err := s.GetSpaceByID(ctx, notes.ID); err != nil {
+ t.Fatalf("removing a space from a project deleted the space: %v", err)
+ }
+}
+
+// A filter term pointing at nothing would silently narrow every query made
+// through it, so the foreign keys are enforced and reported.
+func TestAddProjectSpaceRequiresBothRows(t *testing.T) {
+ s, _, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ sp := mkSpace(t, s, ctx, "bigbes", "rfcs")
+ proj := mkProject(t, s, ctx, "bigbes", "tarantool")
+
+ if err := s.AddProjectSpace(ctx, 99999, sp.ID); !errors.Is(err, ErrNotFound) {
+ t.Errorf("add to a missing project = %v, want ErrNotFound", err)
+ }
+ if err := s.AddProjectSpace(ctx, proj.ID, 99999); !errors.Is(err, ErrNotFound) {
+ t.Errorf("add a missing space = %v, want ErrNotFound", err)
+ }
+}
+
+// Both sides cascade, and each cascade stops at the membership row: deleting a
+// project deletes no content, and deleting a space leaves no dangling filter
+// term behind.
+func TestProjectMembershipCascades(t *testing.T) {
+ s, pool, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ rfcs := mkSpace(t, s, ctx, "bigbes", "rfcs")
+ notes := mkSpace(t, s, ctx, "bigbes", "notes")
+ proj := mkProject(t, s, ctx, "bigbes", "tarantool")
+ for _, sp := range []*Space{rfcs, notes} {
+ if err := s.AddProjectSpace(ctx, proj.ID, sp.ID); err != nil {
+ t.Fatalf("add %s: %v", sp.Ref, err)
+ }
+ }
+
+ // Deleting a space removes it from the filter and leaves the rest.
+ if _, err := pool.ExecContext(ctx, `DELETE FROM space WHERE id = $1`, notes.ID); err != nil {
+ t.Fatalf("delete space: %v", err)
+ }
+ spaces, err := s.ProjectSpaces(ctx, proj.ID)
+ if err != nil {
+ t.Fatalf("project spaces: %v", err)
+ }
+ if len(spaces) != 1 || spaces[0].ID != rfcs.ID {
+ t.Fatalf("after deleting a member space the project selects %+v", spaces)
+ }
+
+ // Deleting the project drops the membership rows and no space.
+ if err := s.DeleteProject(ctx, proj.ID); err != nil {
+ t.Fatalf("delete project: %v", err)
+ }
+ var members int
+ if err := pool.QueryRowContext(ctx,
+ `SELECT count(*) FROM project_space WHERE project_id = $1`, proj.ID).Scan(&members); err != nil {
+ t.Fatalf("count membership rows: %v", err)
+ }
+ if members != 0 {
+ t.Errorf("%d membership rows survived the project", members)
+ }
+ if _, err := s.GetSpaceByID(ctx, rfcs.ID); err != nil {
+ t.Fatalf("deleting a project deleted a space: %v", err)
+ }
+}
+
+// Projects add no ID scoping whatsoever: document IDs are global, so the same
+// ID cannot exist in two spaces regardless of which projects contain them.
+func TestProjectsDoNotScopeDocumentIDs(t *testing.T) {
+ s, _, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ rfcs := mkSpace(t, s, ctx, "bigbes", "rfcs")
+ notes := mkSpace(t, s, ctx, "bigbes", "notes")
+ a := mkProject(t, s, ctx, "bigbes", "a")
+ b := mkProject(t, s, ctx, "bigbes", "b")
+ if err := s.AddProjectSpace(ctx, a.ID, rfcs.ID); err != nil {
+ t.Fatalf("add rfcs to a: %v", err)
+ }
+ if err := s.AddProjectSpace(ctx, b.ID, notes.ID); err != nil {
+ t.Fatalf("add notes to b: %v", err)
+ }
+
+ id := docID(t, "SPEC-7")
+ if _, err := s.RegisterDocID(ctx, rfcs.ID, DocRef{ID: id, Path: "specs/0007.md"}, "abc"); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ // Disjoint projects, and still a collision: uniqueness is global.
+ if _, err := s.RegisterDocID(ctx, notes.ID, DocRef{ID: id, Path: "notes/0007.md"}, "abc"); !errors.Is(err, ErrDocIDTaken) {
+ t.Fatalf("same id in a disjoint project = %v, want ErrDocIDTaken", err)
+ }
+}
M db/store.go => db/store.go +7 -3
@@ 1,7 1,7 @@
// Package db is the PostgreSQL persistence layer for spec.sr.ht. It maps the
-// six tables of schema.sql — space, document_id, proposal, agent_token,
-// index_stamp and digest_mark — to core value types with plain database/sql and
-// $n placeholders (no ORM).
+// eight tables of schema.sql — space, document_id, proposal, agent_token,
+// index_stamp, digest_mark, project and project_space — to core value types
+// with plain database/sql and $n placeholders (no ORM).
//
// The layering rule from the design is what shapes this package: **git refs are
// the source of truth for whether a proposal exists and whether it merged;
@@ 124,6 124,10 @@ var (
// space with that name (uq_space_owner_name violation).
ErrSpaceExists = errors.New("db: space already exists")
+ // ErrProjectExists is returned by CreateProject when the owner already has
+ // a project with that name (uq_project_owner_name violation).
+ ErrProjectExists = errors.New("db: project already exists")
+
// ErrDocIDTaken is returned when a document ID is already registered.
// Document IDs are globally unique, so this is the registry refusing a
// collision — the invariant that lets [[SPEC-0007]] resolve the same way
M db/unit_test.go => db/unit_test.go +44 -14
@@ 6,7 6,9 @@ import (
"database/sql"
"errors"
"os"
+ "path/filepath"
"regexp"
+ "sort"
"strings"
"testing"
"time"
@@ 195,6 197,17 @@ func TestResolveArgumentsValidated(t *testing.T) {
if _, err := s.CreateSpace(ctx, core.SpaceRef{Owner: "bigbes", Name: "../etc"}); !errors.Is(err, core.ErrInvalidName) {
t.Fatalf("CreateSpace with a traversing name = %v, want ErrInvalidName", err)
}
+ if _, err := s.CreateProject(ctx, core.ProjectRef{Owner: "bigbes", Name: "../etc"}); !errors.Is(err, core.ErrInvalidName) {
+ t.Fatalf("CreateProject with a traversing name = %v, want ErrInvalidName", err)
+ }
+ // The meta-project is an address that resolves to a filter excluding
+ // nothing. A row claiming its name could only shadow it, so no door
+ // creates one.
+ if _, err := s.CreateProject(ctx, core.ProjectRef{
+ Owner: "bigbes", Name: core.MetaProjectName,
+ }); !errors.Is(err, core.ErrReservedName) {
+ t.Fatalf("CreateProject of the meta-project = %v, want ErrReservedName", err)
+ }
if _, err := s.CreateAgentToken(ctx, "", HashToken("x")); err == nil {
t.Fatal("CreateAgentToken without a name must fail")
}
@@ 267,26 280,43 @@ func TestNullable(t *testing.T) {
}
// TestSchemaMatchesMigration is the check a fresh install would otherwise fail:
-// schema.sql is the authoritative DDL, migrations/0001_initial.sql builds the
-// same objects, and the two drifting apart means a migrated database and a
-// freshly created one are different databases.
+// schema.sql is the authoritative DDL, the migrations under migrations/ build
+// the same objects in the same order, and the two drifting apart means a
+// migrated database and a freshly created one are different databases.
+//
+// Every migration is replayed, not just the first: a new table added to
+// schema.sql and to 0002 must appear in both, and appending it to only one of
+// them is exactly the drift this test exists to catch.
func TestSchemaMatchesMigration(t *testing.T) {
schema, err := os.ReadFile("../schema.sql")
if err != nil {
t.Fatalf("read schema.sql: %v", err)
}
- migration, err := os.ReadFile("../migrations/0001_initial.sql")
+ files, err := filepath.Glob("../migrations/*.sql")
if err != nil {
- t.Fatalf("read migration: %v", err)
+ t.Fatalf("glob migrations: %v", err)
+ }
+ if len(files) == 0 {
+ t.Fatal("no migrations found")
}
+ sort.Strings(files) // brant applies them in filename order
- up, down, ok := splitBrant(string(migration))
- if !ok {
- t.Fatal("migration is missing a `-- +brant Up` / `-- +brant Down` pair")
+ var ups, downs []string
+ for _, f := range files {
+ migration, err := os.ReadFile(f)
+ if err != nil {
+ t.Fatalf("read %s: %v", f, err)
+ }
+ up, down, ok := splitBrant(string(migration))
+ if !ok {
+ t.Fatalf("%s is missing a `-- +brant Up` / `-- +brant Down` pair", f)
+ }
+ ups = append(ups, normalizeStatements(up)...)
+ downs = append(downs, normalizeStatements(down)...)
}
fromSchema := normalizeStatements(string(schema))
- fromMigration := normalizeStatements(up)
+ fromMigration := ups
if len(fromSchema) != len(fromMigration) {
t.Fatalf("schema.sql has %d statements, migration Up has %d:\n%v\n%v",
len(fromSchema), len(fromMigration), fromSchema, fromMigration)
@@ 300,7 330,7 @@ func TestSchemaMatchesMigration(t *testing.T) {
// Every table created must be dropped, so a Down actually undoes the Up.
created := tableNames(fromSchema, "CREATE TABLE ")
- dropped := tableNames(normalizeStatements(down), "DROP TABLE ")
+ dropped := tableNames(downs, "DROP TABLE ")
if len(created) == 0 {
t.Fatal("no CREATE TABLE statements found in schema.sql")
}
@@ 310,15 340,15 @@ func TestSchemaMatchesMigration(t *testing.T) {
}
}
- // The design deliberately omits these two; adding either needs a design
- // change, not a quiet migration.
- for _, absent := range []string{"project", "comment"} {
+ // The design deliberately omits this one; adding it needs a design change,
+ // not a quiet migration.
+ for _, absent := range []string{"comment"} {
if contains(created, absent) {
t.Errorf("table %q is deliberately absent from v1", absent)
}
}
for _, want := range []string{"space", "document_id", "proposal", "agent_token",
- "index_stamp", "digest_mark"} {
+ "index_stamp", "digest_mark", "project", "project_space"} {
if !contains(created, want) {
t.Errorf("table %q is missing from schema.sql", want)
}
A migrations/0002_project.sql => migrations/0002_project.sql +19 -0
@@ 0,0 1,19 @@
+-- +brant Up
+CREATE TABLE project (
+ id SERIAL PRIMARY KEY,
+ owner TEXT NOT NULL,
+ name TEXT NOT NULL,
+ created TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT uq_project_owner_name UNIQUE (owner, name)
+);
+
+CREATE TABLE project_space (
+ project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE,
+ space_id INTEGER NOT NULL REFERENCES space(id) ON DELETE CASCADE,
+ PRIMARY KEY (project_id, space_id)
+);
+CREATE INDEX ix_project_space_space ON project_space (space_id);
+
+-- +brant Down
+DROP TABLE project_space;
+DROP TABLE project;
M schema.sql => schema.sql +36 -5
@@ 10,11 10,9 @@
-- non-transactional merge path (git refs, then Postgres, then the index)
-- tolerable.
--
--- Deliberately absent: `project` (a project is a saved filter — a name plus a
--- space-id list — and committing it before there are several spaces to filter
--- would be speculative; it arrives with the meta-project in Phase 2) and
--- `comment` (inline comments are post-v1, and the anchoring model should be
--- settled by building the review UI before it is committed to a schema).
+-- Deliberately absent: `comment` (inline comments are post-v1, and the
+-- anchoring model should be settled by building the review UI before it is
+-- committed to a schema).
-- Spaces exist as repos; this table is for listing and index bookkeeping.
CREATE TABLE space (
@@ 95,3 93,36 @@ CREATE TABLE digest_mark (
owner TEXT PRIMARY KEY,
seen_at TIMESTAMPTZ NOT NULL
);
+
+-- A project is pure metadata: a saved filter, not a container. It owns no index
+-- and no storage, so this table is a name and the next one is the filter.
+--
+-- There is exactly one bleve index; querying a project means restricting that
+-- index to the project's spaces. Per-project indexes were specified in an
+-- earlier draft and retracted: every merge would fan out to N rebuilds and
+-- adding a space to a project would force one. Nothing here scopes document
+-- IDs either — those are global (see document_id above), because projects are
+-- edited *after* merges, so a per-project registry could juxtapose two
+-- already-merged documents sharing an ID with no merge left to reject.
+CREATE TABLE project (
+ id SERIAL PRIMARY KEY,
+ owner TEXT NOT NULL, -- "bigbes", no ~ prefix
+ name TEXT NOT NULL, -- "tarantool", no + prefix
+ created TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT uq_project_owner_name UNIQUE (owner, name)
+);
+
+-- The filter itself: which spaces a project selects. The composite primary key
+-- is what makes membership a set — a space cannot be added to a project twice,
+-- so no query has to deduplicate — and it is also the index for resolving a
+-- project to its spaces. Both sides cascade: deleting a project drops its
+-- membership rows and nothing else, and deleting a space removes it from every
+-- project that named it rather than leaving a dangling filter term.
+CREATE TABLE project_space (
+ project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE,
+ space_id INTEGER NOT NULL REFERENCES space(id) ON DELETE CASCADE,
+ PRIMARY KEY (project_id, space_id)
+);
+-- The other direction: which projects contain this space. Not covered by the
+-- primary key, whose leading column is project_id.
+CREATE INDEX ix_project_space_space ON project_space (space_id);
M service/doc.go => service/doc.go +14 -0
@@ 27,6 27,20 @@
// The write plane — propose, If-Match resolution, merge, auto-merge policy,
// digest bookkeeping — is Phase 3 and is deliberately absent.
//
+// # What Phase 2 adds here
+//
+// Projects: [Service.CreateProject], [Service.GetProject],
+// [Service.ListProjects], [Service.DeleteProject],
+// [Service.AddSpaceToProject], [Service.RemoveSpaceFromProject],
+// [Service.ProjectSpaces], [Service.ProjectsForSpace], and the read they exist
+// for, [Service.ResolveProject].
+//
+// A project is pure metadata — a saved filter, not a container. It owns no
+// index and no storage: there is one global bleve index, and a project query is
+// that index restricted to the project's spaces ([SpaceFilter]). The
+// meta-project, "~owner/+everything", is the degenerate case — a filter that
+// excludes nothing ([EverythingFilter]) — and is deliberately not a row.
+//
// # One storage tier
//
// Every read resolves a git revision and reads blobs. The approved head, a
A service/project.go => service/project.go +333 -0
@@ 0,0 1,333 @@
+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
+}
A service/project_test.go => service/project_test.go +317 -0
@@ 0,0 1,317 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+)
+
+var fxProject = core.ProjectRef{Owner: "bigbes", Name: "tarantool"}
+
+func metaRef() core.ProjectRef {
+ return core.ProjectRef{Owner: "bigbes", Name: core.MetaProjectName}
+}
+
+// The meta-project is a value, not a row: resolving it needs no database, which
+// is exactly why it needs no sync job either.
+func TestEverythingFilterNeedsNoStorage(t *testing.T) {
+ svc, _ := newService(t) // its database handle cannot be reached
+
+ f, err := svc.ResolveProject(context.Background(), metaRef())
+ if err != nil {
+ t.Fatalf("ResolveProject(meta): %v", err)
+ }
+ if !f.All {
+ t.Fatal("the meta-project must exclude nothing")
+ }
+ // Deliberately unenumerated: a frozen list would omit every space created
+ // after the resolve.
+ if len(f.Refs) != 0 || len(f.IDs) != 0 {
+ t.Fatalf("the meta filter enumerated %d spaces; it must not", len(f.Refs))
+ }
+ if f.MatchesNothing() {
+ t.Fatal("the meta filter matches everything, not nothing")
+ }
+ if !f.Matches(fxSpace) || !f.MatchesID(1) {
+ t.Fatal("the meta filter must match any space")
+ }
+}
+
+// The trap this type exists to prevent: an empty project is not the corpus.
+func TestEmptyFilterIsNotEverything(t *testing.T) {
+ var empty SpaceFilter
+ if empty.All {
+ t.Fatal("the zero filter must not be the meta-project")
+ }
+ if !empty.MatchesNothing() {
+ t.Fatal("a filter with no terms selects nothing")
+ }
+ if empty.Matches(fxSpace) || empty.MatchesID(1) {
+ t.Fatal("a filter with no terms must match no space")
+ }
+}
+
+func TestSpaceFilterMatches(t *testing.T) {
+ other := core.SpaceRef{Owner: "bigbes", Name: "notes"}
+ f := SpaceFilter{IDs: []int{7}, Refs: []core.SpaceRef{fxSpace}}
+ if !f.Matches(fxSpace) || !f.MatchesID(7) {
+ t.Error("a member space must match")
+ }
+ if f.Matches(other) || f.MatchesID(8) {
+ t.Error("a non-member space must not match")
+ }
+ if f.MatchesNothing() {
+ t.Error("a filter with a term does not match nothing")
+ }
+}
+
+func TestProjectLifecycle(t *testing.T) {
+ svc, _ := newTestService(t)
+ ctx := context.Background()
+
+ p, err := svc.CreateProject(ctx, fxProject)
+ if err != nil {
+ t.Fatalf("CreateProject: %v", err)
+ }
+ if p.ID == 0 {
+ t.Error("project has no row id")
+ }
+ if _, err := svc.CreateProject(ctx, fxProject); !errors.Is(err, ErrProjectExists) {
+ t.Errorf("second CreateProject err = %v, want ErrProjectExists", err)
+ }
+
+ got, err := svc.GetProject(ctx, fxProject)
+ if err != nil {
+ t.Fatalf("GetProject: %v", err)
+ }
+ if got.ID != p.ID || got.Ref != fxProject {
+ t.Errorf("GetProject = %+v", got)
+ }
+ if _, err := svc.GetProject(ctx, core.ProjectRef{Owner: "bigbes", Name: "absent"}); !errors.Is(err, ErrNotFound) {
+ t.Errorf("GetProject of a missing project = %v, want ErrNotFound", err)
+ }
+
+ projects, err := svc.ListProjects(ctx)
+ if err != nil {
+ t.Fatalf("ListProjects: %v", err)
+ }
+ if len(projects) != 1 || projects[0].Ref != fxProject {
+ t.Fatalf("projects = %+v", projects)
+ }
+
+ if err := svc.DeleteProject(ctx, fxProject); err != nil {
+ t.Fatalf("DeleteProject: %v", err)
+ }
+ if err := svc.DeleteProject(ctx, fxProject); !errors.Is(err, ErrNotFound) {
+ t.Errorf("second DeleteProject = %v, want ErrNotFound", err)
+ }
+}
+
+// The key read: a project is a saved filter over one global index, and this is
+// the filter.
+func TestResolveProject(t *testing.T) {
+ svc, _ := newTestService(t)
+ ctx := context.Background()
+
+ rfcs, err := svc.CreateSpace(ctx, fxSpace)
+ if err != nil {
+ t.Fatalf("CreateSpace: %v", err)
+ }
+ notes, err := svc.CreateSpace(ctx, core.SpaceRef{Owner: "bigbes", Name: "notes"})
+ if err != nil {
+ t.Fatalf("CreateSpace: %v", err)
+ }
+ if _, err := svc.CreateProject(ctx, fxProject); err != nil {
+ t.Fatalf("CreateProject: %v", err)
+ }
+
+ // A project with no members selects nothing — never everything.
+ f, err := svc.ResolveProject(ctx, fxProject)
+ if err != nil {
+ t.Fatalf("ResolveProject: %v", err)
+ }
+ if f.All {
+ t.Fatal("an empty project must not resolve to the meta-project")
+ }
+ if !f.MatchesNothing() {
+ t.Fatalf("an empty project resolved to %+v", f)
+ }
+
+ if err := svc.AddSpaceToProject(ctx, fxProject, fxSpace); err != nil {
+ t.Fatalf("AddSpaceToProject: %v", err)
+ }
+ // Membership is a set.
+ if err := svc.AddSpaceToProject(ctx, fxProject, fxSpace); err != nil {
+ t.Fatalf("re-adding a member: %v", err)
+ }
+
+ f, err = svc.ResolveProject(ctx, fxProject)
+ if err != nil {
+ t.Fatalf("ResolveProject: %v", err)
+ }
+ if len(f.Refs) != 1 || f.Refs[0] != fxSpace {
+ t.Fatalf("filter refs = %+v", f.Refs)
+ }
+ if len(f.IDs) != 1 || f.IDs[0] != rfcs.ID {
+ t.Fatalf("filter ids = %+v, want [%d]", f.IDs, rfcs.ID)
+ }
+ if !f.Matches(fxSpace) || !f.MatchesID(rfcs.ID) {
+ t.Error("the member space must match")
+ }
+ if f.Matches(notes.Ref) || f.MatchesID(notes.ID) {
+ t.Error("a space outside the project must not match")
+ }
+
+ // Membership listing, the counterpart of the filter.
+ spaces, err := svc.ProjectSpaces(ctx, fxProject)
+ if err != nil {
+ t.Fatalf("ProjectSpaces: %v", err)
+ }
+ if len(spaces) != 1 || spaces[0].Ref != fxSpace {
+ t.Fatalf("ProjectSpaces = %+v", spaces)
+ }
+ if spaces[0].Repo != nil {
+ t.Error("ProjectSpaces opened repositories; listing must stay cheap")
+ }
+
+ if err := svc.RemoveSpaceFromProject(ctx, fxProject, fxSpace); err != nil {
+ t.Fatalf("RemoveSpaceFromProject: %v", err)
+ }
+ if err := svc.RemoveSpaceFromProject(ctx, fxProject, fxSpace); !errors.Is(err, ErrNotFound) {
+ t.Errorf("removing a non-member = %v, want ErrNotFound", err)
+ }
+ // The space survives its removal from a filter.
+ if _, err := svc.OpenSpace(ctx, fxSpace); err != nil {
+ t.Errorf("removing a space from a project broke the space: %v", err)
+ }
+
+ if _, err := svc.ResolveProject(ctx, core.ProjectRef{Owner: "bigbes", Name: "absent"}); !errors.Is(err, ErrNotFound) {
+ t.Errorf("ResolveProject of a missing project = %v, want ErrNotFound", err)
+ }
+}
+
+// The meta-project unifies what the service owns, with no membership to
+// maintain: a space created after it was last looked at is in it already.
+func TestMetaProjectListsEverySpaceIncludingNewOnes(t *testing.T) {
+ svc, _ := newTestService(t)
+ ctx := context.Background()
+
+ if _, err := svc.CreateSpace(ctx, fxSpace); err != nil {
+ t.Fatalf("CreateSpace: %v", err)
+ }
+ spaces, err := svc.ProjectSpaces(ctx, metaRef())
+ if err != nil {
+ t.Fatalf("ProjectSpaces(meta): %v", err)
+ }
+ if len(spaces) != 1 {
+ t.Fatalf("meta-project lists %d spaces, want 1", len(spaces))
+ }
+
+ if _, err := svc.CreateSpace(ctx, core.SpaceRef{Owner: "bigbes", Name: "notes"}); err != nil {
+ t.Fatalf("CreateSpace: %v", err)
+ }
+ spaces, err = svc.ProjectSpaces(ctx, metaRef())
+ if err != nil {
+ t.Fatalf("ProjectSpaces(meta): %v", err)
+ }
+ if len(spaces) != 2 {
+ t.Fatalf("meta-project lists %d spaces after a new one was created, want 2", len(spaces))
+ }
+}
+
+// The meta-project has no row, so every operation on a row refuses it — and the
+// one that creates rows refuses to make one that would shadow it.
+func TestMetaProjectHasNoRow(t *testing.T) {
+ svc, _ := newTestService(t)
+ ctx := context.Background()
+
+ if _, err := svc.CreateSpace(ctx, fxSpace); err != nil {
+ t.Fatalf("CreateSpace: %v", err)
+ }
+ if _, err := svc.CreateProject(ctx, metaRef()); !errors.Is(err, core.ErrReservedName) {
+ t.Errorf("CreateProject(meta) = %v, want ErrReservedName", err)
+ }
+ if _, err := svc.GetProject(ctx, metaRef()); !errors.Is(err, ErrNotFound) {
+ t.Errorf("GetProject(meta) = %v, want ErrNotFound", err)
+ }
+ if err := svc.DeleteProject(ctx, metaRef()); !errors.Is(err, ErrNotFound) {
+ t.Errorf("DeleteProject(meta) = %v, want ErrNotFound", err)
+ }
+ if err := svc.AddSpaceToProject(ctx, metaRef(), fxSpace); !errors.Is(err, ErrNotFound) {
+ t.Errorf("AddSpaceToProject(meta) = %v, want ErrNotFound", err)
+ }
+ if err := svc.RemoveSpaceFromProject(ctx, metaRef(), fxSpace); !errors.Is(err, ErrNotFound) {
+ t.Errorf("RemoveSpaceFromProject(meta) = %v, want ErrNotFound", err)
+ }
+ // And it is not in the project list, because the list is of rows.
+ projects, err := svc.ListProjects(ctx)
+ if err != nil {
+ t.Fatalf("ListProjects: %v", err)
+ }
+ if len(projects) != 0 {
+ t.Fatalf("ListProjects = %+v, want none", projects)
+ }
+}
+
+func TestProjectMembershipRequiresBothSides(t *testing.T) {
+ svc, _ := newTestService(t)
+ ctx := context.Background()
+
+ if _, err := svc.CreateProject(ctx, fxProject); err != nil {
+ t.Fatalf("CreateProject: %v", err)
+ }
+ if err := svc.AddSpaceToProject(ctx, fxProject, fxSpace); !errors.Is(err, ErrNotFound) {
+ t.Errorf("adding a space that does not exist = %v, want ErrNotFound", err)
+ }
+ if _, err := svc.CreateSpace(ctx, fxSpace); err != nil {
+ t.Fatalf("CreateSpace: %v", err)
+ }
+ missing := core.ProjectRef{Owner: "bigbes", Name: "absent"}
+ if err := svc.AddSpaceToProject(ctx, missing, fxSpace); !errors.Is(err, ErrNotFound) {
+ t.Errorf("adding to a project that does not exist = %v, want ErrNotFound", err)
+ }
+}
+
+// A space belongs to any number of projects; a project is a filter, not an
+// owner, so nothing about the space changes when one contains it.
+func TestProjectsForSpace(t *testing.T) {
+ svc, _ := newTestService(t)
+ ctx := context.Background()
+
+ if _, err := svc.CreateSpace(ctx, fxSpace); err != nil {
+ t.Fatalf("CreateSpace: %v", err)
+ }
+ projects, err := svc.ProjectsForSpace(ctx, fxSpace)
+ if err != nil {
+ t.Fatalf("ProjectsForSpace: %v", err)
+ }
+ if len(projects) != 0 {
+ t.Fatalf("a space in no project reports %+v", projects)
+ }
+
+ for _, name := range []string{"tarantool", "all-docs"} {
+ ref := core.ProjectRef{Owner: "bigbes", Name: name}
+ if _, err := svc.CreateProject(ctx, ref); err != nil {
+ t.Fatalf("CreateProject %s: %v", ref, err)
+ }
+ if err := svc.AddSpaceToProject(ctx, ref, fxSpace); err != nil {
+ t.Fatalf("AddSpaceToProject %s: %v", ref, err)
+ }
+ }
+ projects, err = svc.ProjectsForSpace(ctx, fxSpace)
+ if err != nil {
+ t.Fatalf("ProjectsForSpace: %v", err)
+ }
+ if len(projects) != 2 {
+ t.Fatalf("projects = %+v, want 2", projects)
+ }
+ if projects[0].Ref.Name != "all-docs" || projects[1].Ref.Name != "tarantool" {
+ t.Errorf("projects are not ordered by name: %+v", projects)
+ }
+
+ if _, err := svc.ProjectsForSpace(ctx, core.SpaceRef{Owner: "bigbes", Name: "absent"}); !errors.Is(err, ErrNotFound) {
+ t.Errorf("ProjectsForSpace of a missing space = %v, want ErrNotFound", err)
+ }
+}
M service/service.go => service/service.go +3 -0
@@ 37,6 37,9 @@ var (
// either its repository on disk or its row.
ErrSpaceExists = errors.New("service: space already exists")
+ // ErrProjectExists marks a create that would clobber an existing project.
+ ErrProjectExists = errors.New("service: project already exists")
+
// ErrPushRejected marks a push the update hook must refuse. Type-assert to
// *PushRejection for the message to print to the pushing client.
ErrPushRejected = errors.New("service: push rejected")