~bigbes/sr-ht-spec

ref: 8219ede1804c144de2a2f2e42f3f476bff4b622a sr-ht-spec/db/project.go -rw-r--r-- 8.5 KiB
8219ede1 — Eugene Blikh feat(web,service): the owner mints and revokes agent tokens in a browser 13 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
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
}