Skip to content
quarry Artifact [b3937033eb]
Login

Artifact [b3937033eb]

Artifact b3937033eb2229fd109914b7bfe58ea43e1c544a47f9001c3926348024d84211:


// team.go is RFC 0018's structure: a user-owned place that holds
// repositories and carries grants.
//
// Two things here are load-bearing and easy to get wrong, so they are
// stated at the top.
//
// OWNERSHIP IS A MEMBERSHIP ROW, not a column. Every forge surveyed for
// 0018 does it this way — Forgejo auto-creates an Owners team, GitHub
// advises at least two owners — and a column would be bus-factor one by
// construction. It also keeps "who manages this" the same shape as "who
// can do what" rather than a special case beside it.
//
// A REPOSITORY IS UNDER EXACTLY ONE TEAM, and the schema says so: the
// primary key of team_repo is the repository, not the pair. Exclusivity
// enforced by the table cannot be forgotten by a caller, and it is what
// keeps the aggregations honest — a repository in two teams would have
// its chat in two rooms and its tickets on two boards.
package oltp

import (
	"database/sql"
	"errors"
	"fmt"
	"sort"
	"strings"
	"sync"
	"time"
)

const teamSchema = `
CREATE TABLE IF NOT EXISTS team (
	name    TEXT PRIMARY KEY,          -- shares the ACCOUNT namespace
	title   TEXT NOT NULL DEFAULT '',  -- display name
	descr   TEXT NOT NULL DEFAULT '',  -- one sentence, public
	created TEXT NOT NULL,
	-- Provenance, not authority. Nothing resolves through this: the
	-- creator holds nothing the moment they stop being an owner. It
	-- exists so "who made this and why does it exist" has an answer a
	-- bare timestamp cannot give.
	created_by TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS team_member (
	team    TEXT NOT NULL,
	account TEXT NOT NULL,
	-- What this member gets on every repository under the team, ON TOP
	-- of the membership floor. Empty is a real state: the floor and
	-- nothing more.
	caps    TEXT NOT NULL DEFAULT '',
	-- Manages the TEAM -- its members, its repositories, its title --
	-- and nothing about any repository under it. A team that holds
	-- admin somewhere must not become a way to mint repository admins
	-- without a repository admin agreeing.
	owner   INTEGER NOT NULL DEFAULT 0,
	-- Listed on the team's public page. Default yes, Forgejo's rule,
	-- because the member list is the useful half of a public page --
	-- with an opt-out, because a member did not choose the team's
	-- visibility.
	listed  INTEGER NOT NULL DEFAULT 1,
	PRIMARY KEY (team, account)
);
CREATE INDEX IF NOT EXISTS team_member_by_account ON team_member (account, team);
CREATE TABLE IF NOT EXISTS team_repo (
	-- PRIMARY KEY on the repository, not the pair: RFC 0018's
	-- exclusivity, enforced by the schema.
	repo     TEXT PRIMARY KEY,
	team     TEXT NOT NULL,
	-- Who put it here. Recorded because the right to do so came from a
	-- capability held at that moment, which can later change hands.
	added_by TEXT NOT NULL DEFAULT '',
	added    TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS team_repo_by_team ON team_repo (team, repo);
-- An offer is a repository waiting on a team's consent (RFC 0018
-- §"The receiving half is an offer"). It carries no access: nothing
-- resolves through this table, and until an owner accepts, the
-- repository is exactly where it was.
CREATE TABLE IF NOT EXISTS team_repo_offer (
	-- PRIMARY KEY on the repository for the same reason team_repo has
	-- one: a repository may be offered to at most one team at a time,
	-- so two teams can never be answering the same question. Offering
	-- it elsewhere REPLACES the pending offer, which is what somebody
	-- correcting a mistyped team name means.
	repo       TEXT PRIMARY KEY,
	team       TEXT NOT NULL,
	offered_by TEXT NOT NULL,
	offered    TEXT NOT NULL,
	expires    TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS team_repo_offer_by_team ON team_repo_offer (team, repo);`

// TeamOfferTTL is how long a repository's offer to a team stands.
// Seven days, the span an account invite holds, for the reason RFC
// 0018 gives: a pending grant that never expires is a grant that lands
// on whoever inherits the team next.
const TeamOfferTTL = InviteTTL

// TeamOffer is one repository waiting on a team's consent.
type TeamOffer struct {
	Repo      string `json:"repo"`
	Team      string `json:"team"`
	OfferedBy string `json:"offeredBy"`
	Offered   string `json:"offered"`
	Expires   string `json:"expires"`
}

// OfferTeamRepo records a repository's offer to a team, replacing any
// offer that repository already had. It grants nothing.
func (d *DB) OfferTeamRepo(team, repo, by string) error {
	now := time.Now().UTC()
	_, err := d.w.Exec(`INSERT INTO team_repo_offer (repo, team, offered_by, offered, expires)
		VALUES (?, ?, ?, ?, ?)
		ON CONFLICT(repo) DO UPDATE SET team = excluded.team,
			offered_by = excluded.offered_by, offered = excluded.offered,
			expires = excluded.expires`,
		repo, team, by, now.Format(time.RFC3339), now.Add(TeamOfferTTL).Format(time.RFC3339))
	return err
}

// TeamOfferFor returns a repository's pending offer. An offer past its
// expiry is not pending: it is reported as absent and swept when a
// listing next runs, so a stale row can never be accepted.
func (d *DB) TeamOfferFor(repo string) (TeamOffer, bool, error) {
	var o TeamOffer
	err := d.db.QueryRow(`SELECT repo, team, offered_by, offered, expires
		FROM team_repo_offer WHERE repo = ?`, repo).
		Scan(&o.Repo, &o.Team, &o.OfferedBy, &o.Offered, &o.Expires)
	if err == sql.ErrNoRows {
		return TeamOffer{}, false, nil
	}
	if err != nil {
		return TeamOffer{}, false, err
	}
	if offerExpired(o.Expires) {
		return TeamOffer{}, false, nil
	}
	return o, true, nil
}

// TeamOffers lists a team's pending offers, oldest first — the order
// they were asked in.
func (d *DB) TeamOffers(team string) ([]TeamOffer, error) {
	if err := d.sweepTeamOffers(); err != nil {
		return nil, err
	}
	rows, err := d.db.Query(`SELECT repo, team, offered_by, offered, expires
		FROM team_repo_offer WHERE team = ? ORDER BY offered`, team)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []TeamOffer
	for rows.Next() {
		var o TeamOffer
		if err := rows.Scan(&o.Repo, &o.Team, &o.OfferedBy, &o.Offered, &o.Expires); err != nil {
			return nil, err
		}
		if offerExpired(o.Expires) {
			continue
		}
		out = append(out, o)
	}
	return out, rows.Err()
}

// DropTeamOffer removes a repository's offer — accepted, declined,
// withdrawn or expired, all the same row going away. RFC 0018 keeps no
// register of who declined whom.
func (d *DB) DropTeamOffer(repo string) error {
	_, err := d.w.Exec(`DELETE FROM team_repo_offer WHERE repo = ?`, repo)
	return err
}

// sweepTeamOffers deletes what has run out. Cheap, and on the listing
// path rather than a timer: the table holds one row per offered
// repository, and an offer nobody looks at harms nobody.
func (d *DB) sweepTeamOffers() error {
	_, err := d.w.Exec(`DELETE FROM team_repo_offer WHERE expires <= ?`,
		time.Now().UTC().Format(time.RFC3339))
	return err
}

func offerExpired(expires string) bool {
	t, err := time.Parse(time.RFC3339, expires)
	return err == nil && !time.Now().UTC().Before(t)
}

// teamFloor is what membership alone carries on every repository under
// a team (RFC 0018): read everything, participate freely, pollute
// nothing.
//
// Spelled out in letters rather than routed through a preset or a
// category, for two reasons the RFC records. "Membership means Reader"
// did not work when this was written — Reader was u3bBC78 and the u
// expanded through fossil's shipped reader category kptw, which is
// wiki editing and ticket editing. Reader has since been fixed to
// spell its own letters out (ticket 085786dd870e), so the floor and
// the preset now agree by design; the floor still stands alone,
// because the SECOND reason never depended on the first. A category
// cannot carry it either, because a PRIVATE
// repository has both categories empty by definition and teams are
// mostly used on private repositories: the floor is the entire access
// story there, so it has to stand on its own.
//
//	g h j o r z   clone, links, wiki read, code read, ticket read, archives
//	2             read the forum -- and landings, which are forum threads
//	8             see CI runs, checks and logs (quarry's own letter)
//	C             chat -- quarry-local, so not a store write
//
// Absent on purpose: every letter that writes the repository's own
// store. The companion half of the floor (3, B, 4) arrives with RFC
// 0017 and is not here yet.
const teamFloor = "ghjorz28C"

// Team is one team as a page sees it.
type Team struct {
	Name      string `json:"name"`
	Title     string `json:"title,omitempty"`
	Descr     string `json:"descr,omitempty"`
	Created   string `json:"created"`
	CreatedBy string `json:"createdBy,omitempty"`
	// Members and Repos are counts, folded by the listing so a table
	// does not need a query per row.
	//
	// Members counts the LISTED members only, because a count is read
	// beside the list it describes and every surface that shows the
	// list withholds an opt-out. Counting everybody made the number
	// disagree with the list next to it, which told a stranger that
	// somebody had opted out and how many had — on the public surface
	// the opt-out exists to govern. The rule is that the count
	// describes the list beside it.
	Members int `json:"members"`
	Repos   int `json:"repos"`
	// A team whose members have ALL opted out therefore reports zero
	// members publicly and is indistinguishable from an empty team.
	// That is required rather than merely tolerated: if "withheld"
	// were distinguishable from "absent", the count would disclose the
	// opt-out again by a longer route, which is the bug this comment
	// exists because of.
	//
	// Unlisted is how many members chose not to appear. It is filled
	// by the query and MUST be cleared for any caller who cannot
	// manage the team — it is the withheld fact itself, in a number.
	// Managers get it so their own page can say "4 members · 1
	// unlisted" rather than making them subtract, and so the opt-out
	// is visible to the people who administer it.
	Unlisted int `json:"unlisted,omitempty"`
}

// TeamMember is one person's place in a team.
type TeamMember struct {
	Account string `json:"account"`
	Caps    string `json:"caps,omitempty"`
	Owner   bool   `json:"owner"`
	Listed  bool   `json:"listed"`
}

// TeamRepo is one repository under a team.
type TeamRepo struct {
	Repo    string `json:"repo"`
	Team    string `json:"team"`
	AddedBy string `json:"addedBy,omitempty"`
	Added   string `json:"added"`
}

// teamName holds the shape a handle may take. The same alphabet as a
// login, because teams and accounts SHARE that namespace: a grant row
// names a string, and if that string could be either kind the forge
// would have to guess on every capability resolution.
func teamName(name string) error {
	switch {
	case name == "":
		return Refusal{"a team needs a name"}
	case len(name) > 64:
		return Refusal{fmt.Sprintf("team name is %d characters; the limit is 64", len(name))}
	case !validLogin(name):
		return Refusal{fmt.Sprintf("team name %q: use letters, digits, '.', '-' or '_', starting with a letter or digit", name)}
	}
	return nil
}

// AddTeam creates a team and makes its creator the first owner.
//
// Both halves in one call, because a team with no owner is a set of
// grants nobody can see or revoke — the worst thing an access model can
// contain — and only a site admin could repair it. A site admin making
// a team on somebody else's behalf hands it over afterwards, which is
// one deliberate step rather than a state the forge can be left in by
// accident.
func (d *DB) AddTeam(name, title, descr, by string) error {
	name = strings.TrimSpace(name)
	if err := teamName(name); err != nil {
		return err
	}
	if _, taken := d.AccountByName(name); taken {
		return Refusal{fmt.Sprintf("an account is already called %q", name)}
	}
	if _, taken := d.TeamByName(name); taken {
		return Refusal{fmt.Sprintf("a team is already called %q", name)}
	}
	// And a repository's name, because a team name becomes the first
	// segment of a namespaced path. Without this, creating a team is a
	// way to change where somebody else's existing URL resolves: make
	// a team called "quarry", put zit under it, and /r/quarry/zit —
	// which meant the repository quarry with "zit" beneath it — starts
	// naming zit instead. Team creation is an ordinary action for any
	// signed-in account (RFC 0018 T1), so that would be user-creatable
	// link re-pointing. The three pools have to be disjoint at the top
	// segment for the routing rule to be arithmetic rather than clever.
	if d.RepoNameTaken(name) {
		return Refusal{fmt.Sprintf("a repository is already called %q", name)}
	}
	if _, err := d.w.Exec(`INSERT INTO team (name, title, descr, created, created_by)
		VALUES (?, ?, ?, ?, ?)`,
		name, strings.TrimSpace(title), strings.TrimSpace(descr),
		time.Now().UTC().Format(time.RFC3339), by); err != nil {
		return err
	}
	d.teamsCreated()
	if by == "" {
		return nil
	}
	if _, found := d.AccountByName(by); !found {
		return nil
	}
	_, err := d.w.Exec(`INSERT INTO team_member (team, account, owner) VALUES (?, ?, 1)
		ON CONFLICT (team, account) DO UPDATE SET owner = 1`, name, by)
	return err
}

// TeamNameTaken reports whether a team holds this name — the account
// side of the shared-namespace check.
func (d *DB) TeamNameTaken(name string) bool {
	_, found := d.TeamByName(strings.TrimSpace(name))
	return found
}

// SetTeamText changes the display name and the public sentence. The
// handle never changes: it is what every grant row names.
func (d *DB) SetTeamText(name, title, descr string) error {
	_, err := d.w.Exec(`UPDATE team SET title = ?, descr = ? WHERE name = ?`,
		strings.TrimSpace(title), strings.TrimSpace(descr), name)
	return err
}

// DeleteTeam removes a team, its membership and its repository
// bindings. The repositories themselves are untouched: a team manages
// access to them and does not own them (RFC 0018), so deleting one must
// never be able to delete anybody's code.
// DeleteTeam dissolves a team. Every repository it held falls back to
// appearing under its owner.
//
// The fallback is the whole reason dissolving a team is safe: the team
// held the PATH, never the deed, so the repositories survive it and
// simply appear somewhere else. Without this they kept a path segment
// naming a team that no longer exists, and nothing could recover it --
// the re-seat migration only looks at repositories still in a team, and
// these have just left.
//
// One transaction, so a half-dissolved team cannot exist.
func (d *DB) DeleteTeam(name string) error {
	tx, err := d.w.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	if _, err := tx.Exec(`UPDATE repo SET namespace = owner, namespace_by = '', namespace_at = ?
		WHERE name IN (SELECT repo FROM team_repo WHERE team = ?)`,
		time.Now().UTC().Format(time.RFC3339), name); err != nil {
		return err
	}
	for _, q := range []string{
		`DELETE FROM team_repo WHERE team = ?`,
		`DELETE FROM team_member WHERE team = ?`,
		`DELETE FROM team WHERE name = ?`,
	} {
		if _, err := tx.Exec(q, name); err != nil {
			return err
		}
	}
	return tx.Commit()
}

// Teams lists every team with its member and repository counts.
func (d *DB) Teams() ([]Team, error) {
	return d.teams(`ORDER BY t.name`)
}

// TeamsFor lists the teams an account belongs to.
func (d *DB) TeamsFor(account string) ([]Team, error) {
	return d.teams(`WHERE EXISTS (SELECT 1 FROM team_member m
		WHERE m.team = t.name AND m.account = ?) ORDER BY t.name`, account)
}

func (d *DB) teams(where string, args ...any) ([]Team, error) {
	rows, err := d.db.Query(`SELECT t.name, t.title, t.descr, t.created, t.created_by,
			(SELECT count(*) FROM team_member m WHERE m.team = t.name AND m.listed = 1),
			(SELECT count(*) FROM team_repo r WHERE r.team = t.name),
			(SELECT count(*) FROM team_member m WHERE m.team = t.name AND m.listed = 0)
		FROM team t `+where, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Team
	for rows.Next() {
		var t Team
		if err := rows.Scan(&t.Name, &t.Title, &t.Descr, &t.Created, &t.CreatedBy,
			&t.Members, &t.Repos, &t.Unlisted); err != nil {
			return nil, err
		}
		out = append(out, t)
	}
	return out, rows.Err()
}

// TeamByName reads one team, counts included.
//
// The counts are filled here and not left at zero, because Team carries
// them and an unfilled field renders as a FACT: team.get returned
// "members": 0 beside a list holding one member, which is the same bug
// as a template printing an absent count as nothing. Two subqueries on
// a single-row read cost nothing next to being wrong.
func (d *DB) TeamByName(name string) (Team, bool) {
	var t Team
	err := d.db.QueryRow(`SELECT name, title, descr, created, created_by,
			(SELECT count(*) FROM team_member m WHERE m.team = team.name AND m.listed = 1),
			(SELECT count(*) FROM team_repo r WHERE r.team = team.name),
			(SELECT count(*) FROM team_member m WHERE m.team = team.name AND m.listed = 0)
		FROM team WHERE name = ?`, name).
		Scan(&t.Name, &t.Title, &t.Descr, &t.Created, &t.CreatedBy,
			&t.Members, &t.Repos, &t.Unlisted)
	if err != nil {
		return Team{}, false
	}
	return t, true
}

// SetTeamMember adds a person to a team or changes what they hold
// there. Idempotent: the surfaces that call it are a form and a
// checkbox.
func (d *DB) SetTeamMember(team, account, caps string, owner, listed bool) error {
	if _, found := d.TeamByName(team); !found {
		return Refusal{fmt.Sprintf("no team named %q", team)}
	}
	if _, found := d.AccountByName(account); !found {
		return Refusal{fmt.Sprintf("no account named %q", account)}
	}
	_, err := d.w.Exec(`INSERT INTO team_member (team, account, caps, owner, listed)
		VALUES (?, ?, ?, ?, ?)
		ON CONFLICT (team, account) DO UPDATE SET
			caps = excluded.caps, owner = excluded.owner, listed = excluded.listed`,
		team, account, caps, boolInt(owner), boolInt(listed))
	return err
}

// RemoveTeamMember takes somebody out of a team. Removing a person who
// is not in it is not an error: two owners on one page, or one
// double-click, must both end with them out.
func (d *DB) RemoveTeamMember(team, account string) error {
	_, err := d.w.Exec(`DELETE FROM team_member WHERE team = ? AND account = ?`, team, account)
	return err
}

// TeamMembers lists a team's people, owners first then by name.
// TeamMembers is every member. TeamMembersPage is the windowed read
// the overview uses; the People screen is where the whole roster lives.
func (d *DB) TeamMembers(team string) ([]TeamMember, error) {
	return d.TeamMembersPage(team, -1, 0)
}

// TeamMembersPage is one window of them; a negative limit means all.
func (d *DB) TeamMembersPage(team string, limit, offset int) ([]TeamMember, error) {
	if limit == 0 {
		return []TeamMember{}, nil
	}
	rows, err := d.db.Query(`SELECT account, caps, owner, listed FROM team_member
		WHERE team = ? ORDER BY owner DESC, account LIMIT ? OFFSET ?`, team, limit, offset)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []TeamMember
	for rows.Next() {
		var m TeamMember
		var owner, listed int
		if err := rows.Scan(&m.Account, &m.Caps, &owner, &listed); err != nil {
			return nil, err
		}
		m.Owner, m.Listed = owner == 1, listed == 1
		out = append(out, m)
	}
	return out, rows.Err()
}

// TeamMemberOf reads one person's row, if they have one.
func (d *DB) TeamMemberOf(team, account string) (TeamMember, bool) {
	var m TeamMember
	var owner, listed int
	err := d.db.QueryRow(`SELECT account, caps, owner, listed FROM team_member
		WHERE team = ? AND account = ?`, team, account).
		Scan(&m.Account, &m.Caps, &owner, &listed)
	if err != nil {
		return TeamMember{}, false
	}
	m.Owner, m.Listed = owner == 1, listed == 1
	return m, true
}

// IsTeamOwner reports whether an account manages a team.
func (d *DB) IsTeamOwner(team, account string) bool {
	m, found := d.TeamMemberOf(team, account)
	return found && m.Owner
}

// teamOwners counts a team's owners.
func (d *DB) teamOwners(team string) (int, error) {
	var n int
	err := d.db.QueryRow(`SELECT count(*) FROM team_member
		WHERE team = ? AND owner = 1`, team).Scan(&n)
	return n, err
}

// GuardTeamMember judges a membership change before it is written, the
// same way GuardCaps judges a capability change: a change that would
// leave the team with no owner is refused, unless the actor is a site
// admin, who is TOLD instead.
//
// Same shape and same reason. A team nobody can manage holds grants
// nobody can revoke, and the surface that produced that state would
// have looked like it worked. The site-admin exception is GuardCaps'
// too: a forge admin can always reach in, so refusing them would only
// mean doing it in two steps.
//
// owner is the standing the account would have AFTER the change;
// leaving is expressed separately, which is why removal asks this too.
func (d *DB) GuardTeamMember(actor, team, account string, owner, leaving bool) (warning string, err error) {
	before, err := d.teamOwners(team)
	if err != nil {
		return "", err
	}
	was := d.IsTeamOwner(team, account)
	after := before
	switch {
	case leaving && was:
		after--
	case !leaving && was && !owner:
		after--
	case !leaving && !was && owner:
		after++
	}
	if before == 0 || after > 0 {
		return "", nil
	}
	if me, _ := d.AccountByName(actor); me.Admin {
		return fmt.Sprintf("%s has no owner of its own now; only site admins can change it", team), nil
	}
	if actor == account {
		return "", Refusal{fmt.Sprintf("that would leave %s with no owner; make somebody else an owner first", team)}
	}
	return "", Refusal{fmt.Sprintf("that would leave %s with no owner; name another owner first", team)}
}

// SetTeamRepo puts a repository under a team, moving it if it was under
// another. The caller's right to do so is checked above this, at the
// op: it needs `a` on the repository (RFC 0018), and by records who
// held it at the time.
func (d *DB) SetTeamRepo(team, repo, by string) error {
	if _, found := d.TeamByName(team); !found {
		return Refusal{fmt.Sprintf("no team named %q", team)}
	}
	tx, err := d.w.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	now := time.Now().UTC().Format(time.RFC3339)
	if _, err := tx.Exec(`INSERT INTO team_repo (repo, team, added_by, added)
		VALUES (?, ?, ?, ?)
		ON CONFLICT (repo) DO UPDATE SET
			team = excluded.team, added_by = excluded.added_by, added = excluded.added`,
		repo, team, by, now); err != nil {
		return err
	}
	// The path segment follows the team, in the SAME transaction.
	//
	// team_repo is the authority for which team holds a repository;
	// repo.namespace is where the repository APPEARS, and a repository
	// in a team appears under the team. They were written in different
	// places, so putting a repository in a team moved the team row and
	// left the path naming the previous holder — a URL that quietly
	// stopped matching the truth beside it.
	if err := placeRepo(tx, repo, team, by, time.Now()); err != nil {
		return err
	}
	return tx.Commit()
}

// RemoveTeamRepo takes a repository out of whatever team holds it.
//
// The path segment falls back to the owner in the same transaction.
// This is the half RFC 0018 promised without saying so: a team can be
// dissolved without putting anybody's code at risk precisely because
// the deed never moved, and the repository simply appears under its
// owner again. A repository nobody holds falls back to no namespace,
// which is the forge-held state.
func (d *DB) RemoveTeamRepo(repo string) error {
	tx, err := d.w.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	if _, err := tx.Exec(`DELETE FROM team_repo WHERE repo = ?`, repo); err != nil {
		return err
	}
	// The placement record goes with it. namespace_by names who PUT a
	// repository where it is; once the segment has fallen back to the
	// owner nobody put it there, and leaving the previous placer's name
	// beside it describes a decision that no longer applies.
	// Back to its owner's namespace, through the one writer, so the
	// team it leaves is remembered as a place it was.
	var owner string
	if err := tx.QueryRow(`SELECT owner FROM repo WHERE name = ?`, repo).Scan(&owner); err != nil && !errors.Is(err, sql.ErrNoRows) {
		return err
	}
	if err := placeRepo(tx, repo, owner, "", time.Now()); err != nil {
		return err
	}
	return tx.Commit()
}

// TeamOfRepo answers which team holds a repository, or "" for none.
// The ordinary case is none.
func (d *DB) TeamOfRepo(repo string) string {
	var team string
	if d.db.QueryRow(`SELECT team FROM team_repo WHERE repo = ?`, repo).Scan(&team) != nil {
		return ""
	}
	return team
}

// TeamRepos lists the repositories under a team.
// TeamRepos is every repository a team holds. Team.Repos already
// carries the count, so a surface wanting a window uses TeamReposPage
// and reads the total from there.
func (d *DB) TeamRepos(team string) ([]TeamRepo, error) {
	return d.TeamReposPage(team, -1, 0)
}

// TeamReposPage is one window of them; a negative limit means all.
func (d *DB) TeamReposPage(team string, limit, offset int) ([]TeamRepo, error) {
	if limit == 0 {
		return []TeamRepo{}, nil
	}
	rows, err := d.db.Query(`SELECT repo, added_by, added FROM team_repo
		WHERE team = ? ORDER BY repo LIMIT ? OFFSET ?`, team, limit, offset)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []TeamRepo
	for rows.Next() {
		r := TeamRepo{Team: team}
		if err := rows.Scan(&r.Repo, &r.AddedBy, &r.Added); err != nil {
			return nil, err
		}
		out = append(out, r)
	}
	return out, rows.Err()
}

// anyTeams caches whether the forge has a single team.
//
// EffectiveCaps is the hottest path in the forge — every gate calls it,
// many times per page — and teamCaps would otherwise add a query to
// each call on every forge, including the overwhelming majority that
// have no teams at all. A cached flag makes the common case free
// instead of merely cheap.
//
// TRUE latches and is never re-checked: a forge that has a team does
// not go back to having none while the process lives, and DeleteTeam
// deliberately does not clear the flag — a redundant query on a forge
// that once had a team costs nothing.
//
// FALSE is cached for teamFlagTTL rather than for ever. Caching it
// permanently would be wrong on a multi-node forge, where another node
// can create the first team and this one would never notice; not
// caching it at all was the first version of this code, and the
// benchmark showed it re-querying on every single caps resolution,
// which is the cost the flag exists to remove. A few seconds of
// staleness on "does this forge have any teams yet" is the honest
// middle: the only consequence is that a brand-new team's grants take
// a moment to be seen on a node that did not create it.
type teamFlag struct {
	mu      sync.Mutex
	any     bool
	checked time.Time
}

// teamFlagTTL bounds how long a negative answer is trusted.
const teamFlagTTL = 5 * time.Second

func (d *DB) hasAnyTeam() bool {
	d.teamsExist.mu.Lock()
	defer d.teamsExist.mu.Unlock()
	if d.teamsExist.any {
		return true
	}
	if !d.teamsExist.checked.IsZero() && time.Since(d.teamsExist.checked) < teamFlagTTL {
		return false
	}
	var one int
	err := d.db.QueryRow(`SELECT 1 FROM team LIMIT 1`).Scan(&one)
	d.teamsExist.any = err == nil
	d.teamsExist.checked = time.Now()
	return d.teamsExist.any
}

// teamsCreated is called after a team is made, so this node's next caps
// resolution stops short-circuiting without waiting out the TTL.
func (d *DB) teamsCreated() {
	d.teamsExist.mu.Lock()
	d.teamsExist.any = true
	d.teamsExist.mu.Unlock()
}

// teamCaps is what a team gives one account on one repository: the
// membership floor plus their own team row, or "" when the repository
// is under no team or the account is in no team.
//
// One query, because EffectiveCaps runs on every request — and none at
// all on a forge with no teams, see hasAnyTeam. The floor is unioned
// for MEMBERSHIP ALONE: a member with an empty caps string still gets
// it, which is the point of RFC 0018's floor and the reason a page must
// be able to say "by membership" as a route in its own right.
func (d *DB) teamCaps(account, repo string) string {
	if account == "" || repo == "" || !d.hasAnyTeam() {
		return ""
	}
	var caps string
	err := d.db.QueryRow(`SELECT m.caps FROM team_repo r
		JOIN team_member m ON m.team = r.team
		WHERE r.repo = ? AND m.account = ?`, repo, account).Scan(&caps)
	if err != nil {
		return ""
	}
	return capUnion(teamFloor, caps)
}

// TeamPerson is one row of a team's People screen: somebody who
// reaches at least one repository under the team, and how.
type TeamPerson struct {
	Account string `json:"account"`
	// Member, Owner and Caps describe their team row, when they have
	// one. Caps empty with Member true is the membership floor alone.
	Member bool   `json:"member"`
	Owner  bool   `json:"owner"`
	Caps   string `json:"caps,omitempty"`
	// Direct names the repositories under this team where they hold a
	// string of their own, which the team cannot change — that is
	// edited on the repository. Sorted.
	Direct []TeamDirect `json:"direct,omitempty"`
}

// TeamDirect is one repository-specific grant somebody holds under the
// team, independent of the team.
type TeamDirect struct {
	Repo string `json:"repo"`
	Caps string `json:"caps"`
}

// Reaches reports whether this person reaches anything at all here.
func (p TeamPerson) Reaches() bool { return p.Member || len(p.Direct) > 0 }

// TeamPeople answers RFC 0018's one screen: everyone who reaches any
// repository under this team, with the route each of them came by.
//
// TWO SCOPED QUERIES rather than CapHolders per repository. CapHolders
// returns every account on the forge by design — the settings editor
// needs the ones who could hold a string as well as the ones who do —
// so looping it over a team's repositories would fold accounts times
// repositories rows to find the handful that matter. On this forge
// that is 3,100 accounts against a 20-repository team.
//
// So: the member rows, then only the repo_caps rows that exist for
// repositories under the team. Both are index reads over small sets.
// peopleUnion is the one population this screen is about: everybody who
// reaches any repository under the team, by either route. It is a
// subquery rather than two round trips so the ORDER and the LIMIT
// happen in SQLite — which is the whole point of paging this screen.
//
// A member appears with their flags; somebody holding only a direct
// string on one of the team's repositories appears with none. GROUP BY
// collapses the two routes for a person who has both, and MAX keeps the
// flags: a member with a direct grant is still a member.
const peopleUnion = `SELECT account, MAX(is_member) AS member, MAX(is_owner) AS owner FROM (
		SELECT account, 1 AS is_member, owner AS is_owner
			FROM team_member WHERE team = ?
		UNION ALL
		SELECT c.account, 0, 0
			FROM repo_caps c JOIN team_repo r ON r.repo = c.repo
			WHERE r.team = ? AND c.caps != ''
	) GROUP BY account`

// TeamPeopleCount is how many people the screen has to show, for the
// pager. Counting is not free, but it is one aggregate over the same
// subquery rather than materialising every row to call len() on it.
func (d *DB) TeamPeopleCount(team string) (int, error) {
	var n int
	err := d.db.QueryRow(`SELECT COUNT(*) FROM (`+peopleUnion+`)`, team, team).Scan(&n)
	return n, err
}

// TeamPeoplePage is one page of the People screen, ordered owners then
// members then everybody else, each by name.
//
// Paged in SQL, not in Go. The unpaged version below built the whole
// population in a map, sorted it and handed it over — which is one row
// per account that reaches the team, each carrying a sub-list per
// repository, so the cost grew with accounts AND repositories at once.
// A pager over that would have bounded what was RENDERED and not what
// was computed, which is the trap people.list documents in its own
// input struct. Here the order and the limit are the database's, and
// the two follow-up queries ask only about the accounts on this page.
func (d *DB) TeamPeoplePage(team string, limit, offset int) ([]TeamPerson, error) {
	if limit <= 0 {
		return []TeamPerson{}, nil
	}
	rows, err := d.db.Query(`SELECT account, member, owner FROM (`+peopleUnion+`)
		ORDER BY owner DESC, member DESC, account ASC
		LIMIT ? OFFSET ?`, team, team, limit, offset)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []TeamPerson
	order := map[string]int{}
	for rows.Next() {
		var p TeamPerson
		if err := rows.Scan(&p.Account, &p.Member, &p.Owner); err != nil {
			return nil, err
		}
		order[p.Account] = len(out)
		out = append(out, p)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	if len(out) == 0 {
		return []TeamPerson{}, nil
	}
	// The two detail reads, scoped to this page. A placeholder list
	// rather than a join back into the union: the accounts are already
	// in hand, and at most `limit` of them.
	marks := make([]string, len(out))
	args := make([]any, 0, len(out)+1)
	args = append(args, team)
	for i, p := range out {
		marks[i] = "?"
		args = append(args, p.Account)
	}
	in := "(" + strings.Join(marks, ",") + ")"

	capRows, err := d.db.Query(`SELECT account, caps FROM team_member
		WHERE team = ? AND account IN `+in, args...)
	if err != nil {
		return nil, err
	}
	defer capRows.Close()
	for capRows.Next() {
		var account, caps string
		if err := capRows.Scan(&account, &caps); err != nil {
			return nil, err
		}
		if i, ok := order[account]; ok {
			out[i].Caps = caps
		}
	}
	if err := capRows.Err(); err != nil {
		return nil, err
	}

	dirRows, err := d.db.Query(`SELECT c.account, c.repo, c.caps
		FROM repo_caps c JOIN team_repo r ON r.repo = c.repo
		WHERE r.team = ? AND c.caps != '' AND c.account IN `+in+`
		ORDER BY c.account, c.repo`, args...)
	if err != nil {
		return nil, err
	}
	defer dirRows.Close()
	for dirRows.Next() {
		var account, repo, caps string
		if err := dirRows.Scan(&account, &repo, &caps); err != nil {
			return nil, err
		}
		if i, ok := order[account]; ok {
			out[i].Direct = append(out[i].Direct, TeamDirect{Repo: repo, Caps: caps})
		}
	}
	return out, dirRows.Err()
}

// RepoNameTaken reports whether a repository of this name is
// registered. It exists so the account and team guards can ask without
// importing the repository half's error handling: a read that fails is
// answered as "taken", which refuses a creation rather than allowing
// one the routing layer could not then disambiguate.
func (d *DB) RepoNameTaken(name string) bool {
	var one int
	err := d.db.QueryRow(`SELECT 1 FROM repo WHERE name = ?`, name).Scan(&one)
	if errors.Is(err, sql.ErrNoRows) {
		return false
	}
	return true
}

// TeamCount is how many teams exist, for the explore tab's badge and
// the directory's pager. Counted, never len() of a paged listing — the
// badge and the list are different questions and a windowed list
// answers the wrong one.
func (d *DB) TeamCount() (int, error) {
	var n int
	err := d.db.QueryRow(`SELECT COUNT(*) FROM team`).Scan(&n)
	return n, err
}

// TeamOwnerCount is how many owners a team has.
//
// A page must not count them from its own rows. The People screen
// withholds the last owner's Remove because the guard would refuse it,
// and owners sort first — so page one happens to hold them all and a
// count taken from the page happens to be right. That is a coincidence
// of the sort order, not a fact about the team, and a safety control
// should not rest on one.
func (d *DB) TeamOwnerCount(team string) (int, error) {
	var n int
	err := d.db.QueryRow(`SELECT COUNT(*) FROM team_member
		WHERE team = ? AND owner = 1`, team).Scan(&n)
	return n, err
}

// TeamListed answers "did these people opt out", for one page of them.
//
// One query rather than a TeamMemberOf per row: the People screen has
// to withhold an opt-out from anybody who cannot manage the team, and
// asking per row would put the work back in proportion to the page
// while the page exists to bound it. Accounts not in the team are
// simply absent from the result, which reads as "no membership to
// withhold" — the caller is asking about a mixed population.
//
// Listed is deliberately NOT a field on TeamPerson. It would then be
// serialised by every op that returns one, and the opt-out would leak
// to exactly the readers it is kept from unless every one of them
// remembered to clear it.
func (d *DB) TeamListed(team string, accounts []string) (map[string]bool, error) {
	out := map[string]bool{}
	if len(accounts) == 0 {
		return out, nil
	}
	marks := make([]string, len(accounts))
	args := make([]any, 0, len(accounts)+1)
	args = append(args, team)
	for i, a := range accounts {
		marks[i] = "?"
		args = append(args, a)
	}
	rows, err := d.db.Query(`SELECT account, listed FROM team_member
		WHERE team = ? AND account IN (`+strings.Join(marks, ",")+`)`, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		var account string
		var listed bool
		if err := rows.Scan(&account, &listed); err != nil {
			return nil, err
		}
		out[account] = listed
	}
	return out, rows.Err()
}

// TeamPeople is the whole population, unpaged. Kept for callers that
// genuinely want all of it; every SURFACE should use TeamPeoplePage.
func (d *DB) TeamPeople(team string) ([]TeamPerson, error) {
	by := map[string]*TeamPerson{}
	get := func(account string) *TeamPerson {
		if p, ok := by[account]; ok {
			return p
		}
		p := &TeamPerson{Account: account}
		by[account] = p
		return p
	}

	members, err := d.TeamMembers(team)
	if err != nil {
		return nil, err
	}
	for _, m := range members {
		p := get(m.Account)
		p.Member, p.Owner, p.Caps = true, m.Owner, m.Caps
	}

	// Direct grants on the team's repositories. A non-member with a
	// string on one of them belongs on this screen — the page asks
	// "who reaches these repositories", and an answer that hid them
	// would be answering a different question.
	rows, err := d.db.Query(`SELECT c.account, c.repo, c.caps
		FROM repo_caps c
		JOIN team_repo r ON r.repo = c.repo
		WHERE r.team = ? AND c.caps != ''
		ORDER BY c.account, c.repo`, team)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	for rows.Next() {
		var account, repo, caps string
		if err := rows.Scan(&account, &repo, &caps); err != nil {
			return nil, err
		}
		p := get(account)
		p.Direct = append(p.Direct, TeamDirect{Repo: repo, Caps: caps})
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	out := make([]TeamPerson, 0, len(by))
	for _, p := range by {
		out = append(out, *p)
	}
	// Owners first, then members, then everybody else, each by name:
	// the order somebody scanning for "who runs this" needs.
	sort.Slice(out, func(i, j int) bool {
		a, b := out[i], out[j]
		if a.Owner != b.Owner {
			return a.Owner
		}
		if a.Member != b.Member {
			return a.Member
		}
		return a.Account < b.Account
	})
	return out, nil
}