From c23aec5721cb37973196ce27765eb23fd57029e9 Mon Sep 17 00:00:00 2001 From: bigbes Date: Wed, 22 Jul 2026 14:30:01 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20projects=20=E2=80=94=20a=20saved=20filt?= =?UTF-8?q?er=20over=20one=20global=20index,=20not=20a=20container?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/errors.go | 6 + core/names.go | 94 ++++++++++ core/names_test.go | 120 +++++++++++++ db/project.go | 241 ++++++++++++++++++++++++++ db/project_test.go | 279 ++++++++++++++++++++++++++++++ db/store.go | 10 +- db/unit_test.go | 58 +++++-- migrations/0002_project.sql | 19 ++ schema.sql | 41 ++++- service/doc.go | 14 ++ service/project.go | 333 ++++++++++++++++++++++++++++++++++++ service/project_test.go | 317 ++++++++++++++++++++++++++++++++++ service/service.go | 3 + 13 files changed, 1513 insertions(+), 22 deletions(-) create mode 100644 db/project.go create mode 100644 db/project_test.go create mode 100644 migrations/0002_project.sql create mode 100644 service/project.go create mode 100644 service/project_test.go diff --git a/core/errors.go b/core/errors.go index 526706b28474904904a0b3f2d783191895b76bda..05f20060d64269a0e82cbd7c4e9abe4a84107071 100644 --- a/core/errors.go +++ b/core/errors.go @@ -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. diff --git a/core/names.go b/core/names.go index 45405089aea2bf86800b8b2f9f5c838574eee718..4bc45f0ee7ae34c205c5c8e3515260af1df92819 100644 --- a/core/names.go +++ b/core/names.go @@ -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 diff --git a/core/names_test.go b/core/names_test.go index 5844d0677a85d2ed7104b3d45954a4225c9bcf56..c8bd7af32fec4862914409713093569e36ccfabb 100644 --- a/core/names_test.go +++ b/core/names_test.go @@ -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") + } +} diff --git a/db/project.go b/db/project.go new file mode 100644 index 0000000000000000000000000000000000000000..ea88d4644db68e1079e9592670ec9d45d38374e8 --- /dev/null +++ b/db/project.go @@ -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 +} diff --git a/db/project_test.go b/db/project_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e6c5d9224e2594749c6f0cb3a56033f3c4e98088 --- /dev/null +++ b/db/project_test.go @@ -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) + } +} diff --git a/db/store.go b/db/store.go index 3b55356903e14e17fba9145af08046f4c8315a78..84ef0d3f5be2acb8a5a3e217d1d364693094b399 100644 --- a/db/store.go +++ b/db/store.go @@ -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 diff --git a/db/unit_test.go b/db/unit_test.go index 586576b9c1f46a7e856b3d4161a918ad914293e0..319e034a71661177af6e27557beb3cc877f98df6 100644 --- a/db/unit_test.go +++ b/db/unit_test.go @@ -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) } diff --git a/migrations/0002_project.sql b/migrations/0002_project.sql new file mode 100644 index 0000000000000000000000000000000000000000..5c634c3cc9c69665f0cde9320a34e3a5f56bcae7 --- /dev/null +++ b/migrations/0002_project.sql @@ -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; diff --git a/schema.sql b/schema.sql index 5df79e11933f2a938a0cef2cec31147b8766ad35..39cb03326a2a487f9aa491a71385c27f2787be93 100644 --- a/schema.sql +++ b/schema.sql @@ -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); diff --git a/service/doc.go b/service/doc.go index e9f73a3d936ecb15841eafb8991dea0aeafddbbb..635427e3dd00a909ac721a6639ccae036056fc48 100644 --- a/service/doc.go +++ b/service/doc.go @@ -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 diff --git a/service/project.go b/service/project.go new file mode 100644 index 0000000000000000000000000000000000000000..7a57c54d46f8abd0d8227119b2b8a23bac90e2ec --- /dev/null +++ b/service/project.go @@ -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 +} diff --git a/service/project_test.go b/service/project_test.go new file mode 100644 index 0000000000000000000000000000000000000000..416ef8c55c624f551b5315ba40f6601bfdd314dc --- /dev/null +++ b/service/project_test.go @@ -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) + } +} diff --git a/service/service.go b/service/service.go index 3567b295b882e8d7460e8b134643e50e09113256..ab16cfbda2097da0489da10b7271237e7dca81d9 100644 --- a/service/service.go +++ b/service/service.go @@ -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")