Files
beardrive/internal/webapp/projects.go
Snow LeeandClaude Opus 4.8 f14601a983 refactor(web): extract MetaStore + typed repos; file backend (phase 1)
Introduce a pluggable metadata persistence layer: a MetaStore of typed
repositories (AccountRepo, ProjectRepo, OrgRepo, ShareRepo, DeviceRepo).
The five registries keep their in-memory maps + logic and now persist each
change as one record through a repo, instead of rewriting a whole JSON file
inline. The `file` backend (db_file.go) reproduces the exact on-disk JSON
formats, so a running hub loads unchanged. Open*(path) constructors stay as
thin wrappers over the file backend.

Zero behavior change: full webapp suite green; the example hub boots and
existing auth.json/orgs.json/projects.json load and sign-in works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
2026-07-10 00:01:56 -07:00

172 lines
4.4 KiB
Go

package webapp
import (
"crypto/rand"
"encoding/hex"
"fmt"
"regexp"
"sort"
"sync"
"time"
)
// Project is one synced project hosted by this server. Its storage lives
// under <root>/<id>/ in the object store; the id is permanent, the name is a
// renameable label.
type Project struct {
ID string `json:"id"`
Name string `json:"name"`
Org string `json:"org,omitempty"` // owning organization
Created time.Time `json:"created"`
}
var projectIDRe = regexp.MustCompile(`^p-[0-9a-f]{8}$`)
// ProjectDB is the server's project registry: an in-memory index over a
// MetaStore ProjectRepo. Reads are served from memory; every change is
// persisted as one record through the repo (file or SQL).
type ProjectDB struct {
repo ProjectRepo
mu sync.Mutex
byID map[string]Project
}
// NewProjectDB builds the registry over a repo, loading its current contents.
func NewProjectDB(repo ProjectRepo) (*ProjectDB, error) {
db := &ProjectDB{repo: repo, byID: make(map[string]Project)}
list, err := repo.Load()
if err != nil {
return nil, err
}
for _, p := range list {
db.byID[p.ID] = p
}
return db, nil
}
// OpenProjectDB loads the file-backed registry at path (a missing file is an
// empty registry) — the zero-dependency default.
func OpenProjectDB(path string) (*ProjectDB, error) {
return NewProjectDB(newFileProjectRepo(path))
}
// list returns projects sorted by name. Callers hold mu.
func (db *ProjectDB) list() []Project {
out := make([]Project, 0, len(db.byID))
for _, p := range db.byID {
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func (db *ProjectDB) List() []Project {
db.mu.Lock()
defer db.mu.Unlock()
return db.list()
}
func (db *ProjectDB) Get(id string) (Project, bool) {
db.mu.Lock()
defer db.mu.Unlock()
p, ok := db.byID[id]
return p, ok
}
// GetOrCreate returns the project with the given name in the org, creating
// it (with a fresh id) if none exists. Names are matched exactly, scoped to
// the org: two organizations can each have a "wiki".
func (db *ProjectDB) GetOrCreate(name, org string) (Project, bool, error) {
name = trimName(name)
if name == "" {
return Project{}, false, fmt.Errorf("project name must not be empty")
}
db.mu.Lock()
defer db.mu.Unlock()
for _, p := range db.byID {
if p.Name == name && p.Org == org {
return p, false, nil
}
}
var buf [4]byte
if _, err := rand.Read(buf[:]); err != nil {
return Project{}, false, err
}
p := Project{ID: "p-" + hex.EncodeToString(buf[:]), Name: name, Org: org, Created: time.Now().UTC()}
db.byID[p.ID] = p
if err := db.repo.Put(p); err != nil {
delete(db.byID, p.ID)
return Project{}, false, err
}
return p, true, nil
}
// Rename changes a project's display name (its id and storage are permanent).
func (db *ProjectDB) Rename(id, name string) error {
name = trimName(name)
if name == "" {
return fmt.Errorf("project name must not be empty")
}
db.mu.Lock()
defer db.mu.Unlock()
p, ok := db.byID[id]
if !ok {
return fmt.Errorf("no such project %q", id)
}
for _, other := range db.byID {
if other.ID != id && other.Name == name && other.Org == p.Org {
return fmt.Errorf("a project named %q already exists in this organization", name)
}
}
p.Name = name
db.byID[id] = p
return db.repo.Put(p)
}
// Delete removes a project from the registry. Its storage prefix (blobs,
// journals) is left in the object store — the id is retired, not scrubbed —
// so the caller decides whether to reclaim that space out of band.
func (db *ProjectDB) Delete(id string) error {
db.mu.Lock()
defer db.mu.Unlock()
if _, ok := db.byID[id]; !ok {
return fmt.Errorf("no such project %q", id)
}
delete(db.byID, id)
return db.repo.Delete(id)
}
// SetOrg moves a project into an org (used by the startup migration).
func (db *ProjectDB) SetOrg(id, org string) error {
db.mu.Lock()
defer db.mu.Unlock()
p, ok := db.byID[id]
if !ok {
return fmt.Errorf("no such project %q", id)
}
p.Org = org
db.byID[id] = p
return db.repo.Put(p)
}
func trimName(s string) string {
out := make([]rune, 0, len(s))
for _, r := range s {
if r == '\n' || r == '\r' || r == '\t' {
continue
}
out = append(out, r)
}
for len(out) > 0 && out[0] == ' ' {
out = out[1:]
}
for len(out) > 0 && out[len(out)-1] == ' ' {
out = out[:len(out)-1]
}
if len(out) > 128 {
out = out[:128]
}
return string(out)
}