~bigbes/sr-ht-spec

c23aec5721cb37973196ce27765eb23fd57029e9 — bigbes 27 days ago 54fac8d
feat: projects — a saved filter over one global index, not a container

A project is a named space set; querying one filters the single global
index. The meta-project is an implicit filter at a reserved address rather
than a row: a stored +everything would need a sync job on every space
creation, and its one failure mode is silently omitting a space.

SpaceFilter distinguishes All from an empty member list, because a freshly
created project has no members and must mean selects-nothing. Collapsing
the two would make every new project silently match the whole corpus.
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")