Skip to content
quarry Artifact [e1d7e6a975]
Login

Artifact [e1d7e6a975]

Artifact e1d7e6a975a2257262732d9c83e399c1ebc1deda6bd8efd14eb029fe71a1b9bb:


package index

import (
	"craftdesign.group/quarry/internal/idn"
	"database/sql"
	"fmt"
	"sort"
	"strconv"
	"strings"
	"time"
)

// RFC 0013. Who touched what, when — drawn from the folds the index
// already keeps, under one rule that the renderers do not get a vote on:
//
// A check-in with a non-primary parent (checkin_parent.ord > 0) is a
// merge, and its filechange rows are the ABSORBED BRANCH'S work, not
// the merger's. On fossil-family stores merges are ~10% of check-ins
// and carry ~60% of all file touches; counted naively they inflate some
// people's footprint by 2-20x. Here they are marked inherited: they
// ride along in Touches so a client can ghost them, and they count in
// nothing — not the row's file count, not the author's, not a day's
// lead directory. The merge itself is still a check-in, because it
// happened.
//
// The rule lives here rather than in a renderer so the SVG, the canvas
// and a third-party client cannot disagree about the numbers.

// activityWindowDays is the recent window the server picks when the
// caller names no Since: long enough to show a rhythm, short enough
// that the tree axis stays drawable.
const activityWindowDays = 900

// activityWindowFloor is the volume that window is widened to reach. A
// quiet repository's 900 days can be a handful of dots on an empty
// field; extending backwards costs a cheap indexed scan and turns it
// into a picture.
const activityWindowFloor = 2000

// activityMaxCheckins is the most check-ins one window may hold,
// whoever names the window. The sample keeps every touch of every
// check-in in memory, so an unbounded window on a mirror of a million
// check-ins took the forge down (2026-11-10). A var so a test can
// lower it.
var activityMaxCheckins = 50000

// activitySlots bounds how many windows are sampled at once: the
// budget above bounds one request, this bounds a burst of them.
var activitySlots = make(chan struct{}, 2)

// ActivityQuery narrows the window and the population. Empty Branch or
// User means all of them; empty Since asks the server to pick (see
// Activity's Window). A non-empty Since is clamped to the repository's
// first check-in (RFC 0013 §4) — asking for a window that starts before
// the history began still gets a window, just not an empty stretch of
// one.
type ActivityQuery struct {
	Branch string
	User   string
	Since  string
	Until  string
}

// ActivityAuthor is one person with their footprint and their colour.
// Checkins counts every check-in they made, merges included. Files
// counts only paths they authored — see the merge rule above.
type ActivityAuthor struct {
	Name     string
	Checkins int
	Files    int
	Hue      int
	Chroma   int // 0 vivid, 1 soft: identity, like Hue
	Shade    int
}

// ActivityRow is one check-in. Day is days since the window's Since;
// Hour is the check-in's own UTC hour, never a guessed zone. Author
// indexes Activity.Authors. Files is the AUTHORED file count, so a pure
// merge reports zero.
type ActivityRow struct {
	Day    int
	Hour   int
	Author int
	Merge  int
	Files  int
	Hash   string
	Branch string
}

// ActivityTouch is one (check-in, leaf path) pair. Path indexes
// Activity.Paths; Inherited is 1 when the touch arrived through a merge.
type ActivityTouch struct {
	Day       int
	Path      int
	Author    int
	Inherited int
}

// Activity is the windowed sample two views are drawn from. Window is
// "given" when the caller named Since and "picked" when the server
// chose it.
type Activity struct {
	Since   string
	Until   string
	Authors []ActivityAuthor
	Paths   []string
	Rows    []ActivityRow
	Touches []ActivityTouch
	Window  string
}

// ActivityDay is one day of the slice. N is check-ins, Merges how many
// of them were merges, Authors how many distinct people. Top is every
// author of the day as an [author, n] pair, ranked by check-ins — the
// whole list, because a person filter asks each day whether THIS person
// was on it and a truncated list would ghost a busy fourth author's day
// (RFC 0013 §7). Path indexes ActivityDays.Paths and is -1 on a day
// whose only touches were inherited.
type ActivityDay struct {
	N       int
	Merges  int
	Top     [][2]int
	Authors int
	Path    int
}

// ActivityDays is the whole history folded to days — the slice's
// source. Paths is the top-level directories ActivityDay.Path indexes
// ("" for files at the repository root).
type ActivityDays struct {
	// Pending says the history is still being counted in the background
	// (a very large store's first read); everything else is empty.
	Pending  bool
	First    string
	Last     string
	Authors  []ActivityAuthor
	Days     map[string]ActivityDay
	Paths    []string
	Releases map[string]string
}

// hue maps a login to a colour. Deliberately a pure function of the
// name and nothing else: hue is who, never what kind or what state, so
// every renderer that knows the login draws the same person the same
// way without asking anybody. It is the identicon's hue, so the person
// is the same colour on every surface of the forge.
func hue(name string) int { return idn.Hue(name) }

// hueGap is how close two hues may be before a reader stops telling
// them apart on a scatter of small dots.
const hueGap = 14

// shadeRanks is how far down the ranking the collision check runs.
// Below it the dots are too few to confuse anybody, and stepping every
// tail author's lightness would spend the whole range on people nobody
// is looking for.
const shadeRanks = 24

// assignShades steps the lightness of authors whose hue collides with a
// higher-ranked one, so neighbours in the legend stay distinguishable.
// as must already be in rank order.
func assignShades(as []ActivityAuthor) {
	n := min(len(as), shadeRanks)
	for i := range n {
		collisions := 0
		for j := range i {
			d := as[i].Hue - as[j].Hue
			if d < 0 {
				d = -d
			}
			if d > 180 {
				d = 360 - d
			}
			if d <= hueGap {
				collisions++
			}
		}
		as[i].Shade = min(collisions, 3)
	}
}

// day is the ISO date an index timestamp falls on. Dates are stored as
// the artifact's own string ("2026-06-01T09:00:00.000"), so the day is
// its first ten bytes and no parse is needed.
func day(date string) string {
	if len(date) < 10 {
		return date
	}
	return date[:10]
}

// hourOf is the UTC hour of an index timestamp, positions 12-13 of
// either the T-separated or space-separated form.
func hourOf(date string) int {
	if len(date) < 13 {
		return 0
	}
	h, err := strconv.Atoi(date[11:13])
	if err != nil || h < 0 || h > 23 {
		return 0
	}
	return h
}

// dayBounds turns two ISO days into the half-open string range a date
// comparison can use against the stored timestamps. A bare day sorts
// before every timestamp on it, which is what makes ">= since" and
// "< the day after until" exact without a substr() that no index can
// serve.
func dayBounds(since, until string) (string, string) {
	hi := until
	if t, err := time.Parse("2006-01-02", until); err == nil {
		hi = t.AddDate(0, 0, 1).Format("2006-01-02")
	}
	return since, hi
}

// daysBetween counts calendar days from since to date. Both are ISO
// days; a malformed one lands on day zero rather than failing the
// whole query.
func daysBetween(since, date string) int {
	a, err := time.Parse("2006-01-02", since)
	if err != nil {
		return 0
	}
	b, err := time.Parse("2006-01-02", date)
	if err != nil {
		return 0
	}
	return int(b.Sub(a) / (24 * time.Hour))
}

// topLevel is the directory a day is attributed to: the first path
// segment, or "" for a file at the root.
func topLevel(path string) string {
	if i := strings.IndexByte(path, '/'); i >= 0 {
		return path[:i]
	}
	return ""
}

// dirOf is a path's directory, used for the tree axis order.
// sweepFiles is where a check-in stops being work on files and becomes
// a sweep — a vendored import, a licence re-stamp, a mass rename. Files
// touched only by sweeps do not get tree-axis rows of their own.
const sweepFiles = 40

func dirOf(path string) string {
	if i := strings.LastIndexByte(path, '/'); i >= 0 {
		return path[:i]
	}
	return ""
}

// mergeSet is every check-in that has a non-primary parent. Loaded once
// and tested in Go rather than asked as a correlated EXISTS: the touch
// scan visits a filechange row per (check-in, path), and on a 40k-
// check-in store re-asking the parent table 200,000 times is most of
// the query's cost. Merges are ~10% of check-ins, so the set is small.
func (ix *Index) mergeSet() (map[string]bool, error) {
	rows, err := ix.db.Query(`SELECT DISTINCT child FROM checkin_parent WHERE ord > 0`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[string]bool{}
	for rows.Next() {
		var child string
		if err := rows.Scan(&child); err != nil {
			return nil, err
		}
		out[child] = true
	}
	return out, rows.Err()
}

// mergeSetIn is mergeSet for the check-ins dated in [lo, hi): what one
// window needs, read through checkin_by_date and the parent table's key
// instead of every merge in the repository's history.
func (ix *Index) mergeSetIn(lo, hi string) (map[string]bool, error) {
	rows, err := ix.db.Query(`SELECT DISTINCT p.child FROM checkin c
		JOIN checkin_parent p ON p.child = c.hash AND p.ord > 0
		WHERE c.date >= ? AND c.date < ?`, lo, hi)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := map[string]bool{}
	for rows.Next() {
		var child string
		if err := rows.Scan(&child); err != nil {
			return nil, err
		}
		out[child] = true
	}
	return out, rows.Err()
}

// rankAuthors orders people by check-ins (name breaks ties, so two
// equal counts do not swap places between two requests), colours them
// and returns the name -> index map the rows carry instead of strings.
func rankAuthors(checkins, files map[string]int) ([]ActivityAuthor, map[string]int) {
	out := make([]ActivityAuthor, 0, len(checkins))
	for name, n := range checkins {
		out = append(out, ActivityAuthor{Name: name, Checkins: n, Files: files[name], Hue: hue(name), Chroma: idn.Chroma(name)})
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Checkins != out[j].Checkins {
			return out[i].Checkins > out[j].Checkins
		}
		return out[i].Name < out[j].Name
	})
	assignShades(out)
	idx := make(map[string]int, len(out))
	for i, a := range out {
		idx[a.Name] = i
	}
	return out, idx
}

// filters builds the shared branch/user predicate. c is the checkin
// table's alias.
func (q ActivityQuery) filters(c string) (string, []any) {
	var sb strings.Builder
	var args []any
	if q.Branch != "" {
		sb.WriteString(" AND " + c + ".branch = ?")
		args = append(args, q.Branch)
	}
	if q.User != "" {
		sb.WriteString(" AND " + c + ".user = ?")
		args = append(args, q.User)
	}
	return sb.String(), args
}

// pickWindow decides the dates when the caller named none. Two rules,
// in this order: the last activityWindowDays days of the repository's
// OWN history (not of the wall clock — a store that went quiet a year
// ago still has a story), widened backwards if that holds fewer than
// activityWindowFloor check-ins, and clamped to the first check-in so
// day zero is a day something happened. The clamp applies whether the
// window was picked or the caller named a Since: an explicit Since
// earlier than the repository's first check-in still lands on it,
// rather than opening a window with nothing in the first stretch of it.
//
// A non-empty Since or Until that fails to parse as "2006-01-02" is an
// error, not a silent fallback — callers that already validated the
// query (the op layer) will never trip this, but pickWindow does not
// rely on that being true.
func (ix *Index) pickWindow(q ActivityQuery) (since, until string, err error) {
	if q.Since != "" {
		if _, perr := time.Parse("2006-01-02", q.Since); perr != nil {
			return "", "", fmt.Errorf("activity: malformed since %q: %w", q.Since, perr)
		}
	}
	if q.Until != "" {
		if _, perr := time.Parse("2006-01-02", q.Until); perr != nil {
			return "", "", fmt.Errorf("activity: malformed until %q: %w", q.Until, perr)
		}
	}
	cond, args := q.filters("c")
	var first, last string
	err = ix.db.QueryRow(`SELECT COALESCE(min(c.date), ''), COALESCE(max(c.date), '')
		FROM checkin c WHERE 1=1`+cond, args...).Scan(&first, &last)
	if err != nil || last == "" {
		return "", "", err
	}
	first, last = day(first), day(last)
	if q.Until != "" {
		last = q.Until
	}
	if q.Since != "" {
		since = q.Since
		if since < first {
			since = first
		}
		return since, last, nil
	}
	since = first
	if t, perr := time.Parse("2006-01-02", last); perr == nil {
		since = t.AddDate(0, 0, -(activityWindowDays - 1)).Format("2006-01-02")
	}
	// The date of the activityWindowFloor'th newest check-in: the point
	// the window has to reach back to for the chart to have a shape.
	// Absent (fewer check-ins than the floor) means "show everything".
	_, hi := dayBounds(first, last)
	pivotArgs := append([]any{hi}, args...)
	var pivot string
	perr := ix.db.QueryRow(`SELECT c.date FROM checkin c WHERE c.date < ?`+cond+
		` ORDER BY c.date DESC LIMIT 1 OFFSET ?`,
		append(pivotArgs, activityWindowFloor-1)...).Scan(&pivot)
	if perr != nil || pivot == "" {
		since = first
	} else if p := day(pivot); p < since {
		since = p
	}
	if since < first {
		since = first
	}
	return since, last, nil
}

// activityMemoKeep is how many windows each repository remembers. The
// page asks for one window per view, and a whole-history window on a
// large mirror holds tens of megabytes, so the memory is kept small.
const activityMemoKeep = 2

type activityMemo struct {
	q   ActivityQuery
	gen uint64
	a   Activity
}

// activityCall is one computation of a window, which requests for the
// same window wait on rather than repeat.
type activityCall struct {
	done    chan struct{}
	a       Activity
	err     error
	waiters int
}

// Activity is the windowed sample behind the core sample chart: one row
// per check-in, one touch per (check-in, leaf path).
//
// The answer is remembered until a fold lands, and requests for a window
// already being computed wait for it, so a burst of requests for one
// page costs one query. The slices of the result are shared with the
// memory and with other callers: read them, never write them.
func (ix *Index) Activity(q ActivityQuery) (Activity, error) {
	gen := ix.foldGeneration()
	ix.activityMu.Lock()
	for i, m := range ix.activityMemo {
		if m.q == q && m.gen == gen {
			copy(ix.activityMemo[1:i+1], ix.activityMemo[:i])
			ix.activityMemo[0] = m
			ix.activityMu.Unlock()
			return m.a, nil
		}
	}
	if c, ok := ix.activityCalls[q]; ok {
		c.waiters++
		ix.activityMu.Unlock()
		<-c.done
		return c.a, c.err
	}
	c := &activityCall{done: make(chan struct{})}
	if ix.activityCalls == nil {
		ix.activityCalls = map[ActivityQuery]*activityCall{}
	}
	ix.activityCalls[q] = c
	ix.activityMu.Unlock()

	// A panic in the query becomes the error every waiter gets, so no
	// request hangs on a computation that will never finish.
	func() {
		defer func() {
			if p := recover(); p != nil {
				c.a, c.err = Activity{}, fmt.Errorf("activity: %v", p)
			}
		}()
		c.a, c.err = ix.activity(q)
	}()

	// Kept only if no fold landed during the query, as the day-fold is.
	fresh := ix.foldGeneration() == gen
	ix.activityMu.Lock()
	delete(ix.activityCalls, q)
	if c.err == nil && fresh {
		kept := []activityMemo{{q: q, gen: gen, a: c.a}}
		for _, m := range ix.activityMemo {
			if m.q != q && m.gen == gen && len(kept) < activityMemoKeep {
				kept = append(kept, m)
			}
		}
		ix.activityMemo = kept
	}
	ix.activityMu.Unlock()
	close(c.done)
	return c.a, c.err
}

// foldGeneration is the current fold generation, which every memo
// stamps its answer with.
func (ix *Index) foldGeneration() uint64 {
	ix.countMu.Lock()
	defer ix.countMu.Unlock()
	return ix.foldGen
}

func (ix *Index) activity(q ActivityQuery) (Activity, error) {
	if ix.activityRun != nil {
		ix.activityRun()
	}
	out := Activity{Window: "picked"}
	if q.Since != "" {
		out.Window = "given"
	}
	since, until, err := ix.pickWindow(q)
	if err != nil {
		return Activity{}, err
	}
	if since == "" || until == "" {
		// An empty repository still answers with a shape, so a caller
		// renders an empty chart rather than an error page.
		out.Since, out.Until = q.Since, q.Until
		return out, nil
	}
	activitySlots <- struct{}{}
	defer func() { <-activitySlots }()

	cond, fargs := q.filters("c")
	lo, hi := dayBounds(since, until)
	// Over the budget the window starts on the day of the budget'th
	// newest check-in in it, and says it was clamped.
	var pivot string
	perr := ix.db.QueryRow(`SELECT c.date FROM checkin c WHERE c.date >= ? AND c.date < ?`+cond+
		` ORDER BY c.date DESC LIMIT 1 OFFSET ?`,
		append(append([]any{lo, hi}, fargs...), activityMaxCheckins-1)...).Scan(&pivot)
	if perr == nil && pivot != "" {
		// One check-in past the pivot means the window is over budget.
		var over int
		if err := ix.db.QueryRow(`SELECT count(*) FROM (SELECT 1 FROM checkin c WHERE c.date >= ? AND c.date < ?`+cond+
			` LIMIT ?)`, append(append([]any{lo, hi}, fargs...), activityMaxCheckins+1)...).Scan(&over); err != nil {
			return Activity{}, err
		}
		if over > activityMaxCheckins {
			// The day after the pivot's, so the window never exceeds
			// the budget by the rest of the pivot's day.
			if t, err := time.Parse("2006-01-02", day(pivot)); err == nil {
				since = t.AddDate(0, 0, 1).Format("2006-01-02")
				if since > until {
					since = until
				}
				out.Window = "clamped"
				lo, hi = dayBounds(since, until)
			}
		}
	}
	out.Since, out.Until = since, until
	args := append([]any{lo, hi}, fargs...)

	isMerge, err := ix.mergeSetIn(lo, hi)
	if err != nil {
		return Activity{}, err
	}
	rows, err := ix.db.Query(`SELECT c.hash, c.date, c.user, c.branch
		FROM checkin c WHERE c.date >= ? AND c.date < ?`+cond+
		` ORDER BY c.date, c.hash`, args...)
	if err != nil {
		return Activity{}, err
	}
	defer rows.Close()
	type row struct {
		user  string
		merge bool
	}
	meta := map[string]row{}
	checkins := map[string]int{}
	for rows.Next() {
		var hash, date, user, branch string
		if err := rows.Scan(&hash, &date, &user, &branch); err != nil {
			return Activity{}, err
		}
		merge := isMerge[hash]
		m := 0
		if merge {
			m = 1
		}
		out.Rows = append(out.Rows, ActivityRow{
			Day: daysBetween(since, day(date)), Hour: hourOf(date),
			Merge: m, Hash: hash, Branch: branch,
		})
		meta[hash] = row{user: user, merge: merge}
		checkins[user]++
	}
	if err := rows.Err(); err != nil {
		return Activity{}, err
	}

	// Leaves only: the directory rows exist so a directory's last
	// change is one lookup, and a directory is not a file.
	trows, err := ix.db.Query(`SELECT f.checkin, f.path
		FROM filechange f JOIN checkin c ON c.hash = f.checkin
		WHERE f.dir = 0 AND c.date >= ? AND c.date < ?`+cond, args...)
	if err != nil {
		return Activity{}, err
	}
	defer trows.Close()
	touched := map[string][]string{}
	files := map[string]int{}
	for trows.Next() {
		var hash, path string
		if err := trows.Scan(&hash, &path); err != nil {
			return Activity{}, err
		}
		m, ok := meta[hash]
		if !ok {
			continue
		}
		touched[hash] = append(touched[hash], path)
		if !m.merge {
			files[m.user]++
		}
	}
	if err := trows.Err(); err != nil {
		return Activity{}, err
	}

	// A path earns its own row only when someone worked on it as a
	// file: a non-merge check-in of fewer than sweepFiles paths touched
	// it. Everything else — files that only ever arrived by merge, or
	// only inside a sweep that dropped a vendored tree or re-stamped
	// every header — folds into one row per directory, named by the
	// directory with a trailing slash. Without the fold one zlib
	// upgrade claims three hundred rows and the axis is mostly blank.
	authored := map[string]bool{}
	paths := map[string]bool{}
	fold := map[string]string{}
	for hash, ps := range touched {
		if meta[hash].merge || len(ps) >= sweepFiles {
			continue
		}
		for _, p := range ps {
			authored[p] = true
		}
	}
	// A folded path rolls up to the highest directory under which
	// nothing was authored, so a vendored tree is one row, not one
	// per subdirectory.
	live := map[string]bool{}
	for p := range authored {
		for d := dirOf(p); d != ""; d = dirOf(d) {
			live[d] = true
		}
	}
	for _, ps := range touched {
		for _, p := range ps {
			if authored[p] {
				paths[p] = true
			} else if _, ok := fold[p]; !ok {
				top := dirOf(p)
				for d := top; d != "" && !live[d]; d = dirOf(d) {
					top = d
				}
				fold[p] = top + "/"
				paths[fold[p]] = true
			}
		}
	}

	var authorIdx map[string]int
	out.Authors, authorIdx = rankAuthors(checkins, files)

	// Tree-axis order: by directory, then by the path folded to lower
	// case, so a directory's files are contiguous and case does not
	// scatter them. Decided here, not in a renderer, so both views and
	// every client agree about what is next to what.
	out.Paths = make([]string, 0, len(paths))
	for p := range paths {
		out.Paths = append(out.Paths, p)
	}
	sort.Slice(out.Paths, func(i, j int) bool {
		di, dj := dirOf(out.Paths[i]), dirOf(out.Paths[j])
		if di != dj {
			return di < dj
		}
		li, lj := strings.ToLower(out.Paths[i]), strings.ToLower(out.Paths[j])
		if li != lj {
			return li < lj
		}
		return out.Paths[i] < out.Paths[j]
	})
	pathIdx := make(map[string]int, len(out.Paths))
	for i, p := range out.Paths {
		pathIdx[p] = i
	}

	for i := range out.Rows {
		r := &out.Rows[i]
		m := meta[r.Hash]
		r.Author = authorIdx[m.user]
		ps := touched[r.Hash]
		sort.Strings(ps)
		inherited := 0
		if m.merge {
			inherited = 1
		} else {
			r.Files = len(ps)
		}
		last := -1
		for _, p := range ps {
			if f, ok := fold[p]; ok {
				p = f
			}
			// Sorted paths keep a directory's folded files adjacent,
			// so one check-in touches a folded row once.
			if idx := pathIdx[p]; idx != last {
				out.Touches = append(out.Touches, ActivityTouch{
					Day: r.Day, Path: idx, Author: r.Author, Inherited: inherited,
				})
				last = idx
			}
		}
	}
	return out, nil
}

// ActivityDays is the whole history folded to one entry per day — the
// slice's source, and the one thing here worth memoizing: it is a scan
// of every check-in and every filechange row, it is asked for on the
// repository home page, and it only moves when a fold does. After a
// fold it may lag by one background recompute; it never makes a reader
// wait for one.
func (ix *Index) ActivityDays(branch string) (ActivityDays, error) {
	ix.countMu.Lock()
	if got, ok := ix.activityDays[branch]; ok {
		out := copyActivityDays(*got)
		stale := ix.activityGen[branch] != ix.foldGen
		ix.countMu.Unlock()
		// Stale-while-revalidate: a fold has landed since this was
		// computed, so it is served as it is and refreshed off the
		// request path. The home page never waits on the scan once a
		// repository has been read.
		if stale {
			ix.rewarmActivityDays([]string{branch})
		}
		return out, nil
	}
	ix.countMu.Unlock()
	// Never computed. On a very large store the scan is seconds long
	// (12 s on the Linux kernel), longer than any page may take, so it is
	// started off the request path and this reader gets an empty chart
	// marked Pending; the next one gets the real thing. A small store
	// still answers at once, which is what every other caller expects.
	if ix.largeHistory() {
		ix.rewarmActivityDays([]string{branch})
		return ActivityDays{Pending: true, Days: map[string]ActivityDay{}, Releases: map[string]string{}}, nil
	}
	// The first reader of a small repository pays the scan.
	got, err := ix.refreshActivityDays(branch)
	if err != nil {
		return ActivityDays{}, err
	}
	// A copy, not the memoized value itself: the memo must survive
	// whatever the caller does to the map/slice headers it got back.
	return copyActivityDays(got), nil
}

// refreshActivityDays scans and memoises one branch's day-fold, stamped
// with the fold generation it read before the scan. The scan runs with
// countMu released: holding it would block Counts, Summary and
// EventKinds behind one branch's fold. A fold landing mid-scan leaves
// the stored answer stamped stale, so the next reader starts another
// refresh; an answer older than the one already stored is dropped.
// WarmActivityDays computes and memoizes branch's chart now, whatever
// the store's size: the boot-time warm, which is off any request path.
func (ix *Index) WarmActivityDays(branch string) error {
	_, err := ix.refreshActivityDays(branch)
	return err
}

// largeHistoryCheckins is the size past which a full-history scan is
// kept off the request path.
const largeHistoryCheckins = 200000

// largeHistory reports whether the store holds more check-ins than a
// request may scan. max(rowid) answers from the b-tree's last page.
func (ix *Index) largeHistory() bool {
	var n sql.NullInt64
	if err := ix.db.QueryRow(`SELECT max(rowid) FROM checkin`).Scan(&n); err != nil {
		return false
	}
	return n.Int64 > largeHistoryCheckins
}

func (ix *Index) refreshActivityDays(branch string) (ActivityDays, error) {
	ix.countMu.Lock()
	gen := ix.foldGen
	ix.countMu.Unlock()
	got, err := ix.computeActivityDays(branch)
	if err != nil {
		return ActivityDays{}, err
	}
	ix.countMu.Lock()
	if prev, ok := ix.activityGen[branch]; !ok || gen >= prev {
		if ix.activityDays == nil {
			ix.activityDays = map[string]*ActivityDays{}
			ix.activityGen = map[string]uint64{}
		}
		ix.activityDays[branch] = &got
		ix.activityGen[branch] = gen
	}
	ix.countMu.Unlock()
	return got, nil
}

// copyActivityDays deep-copies the maps and slices an ActivityDays
// carries, so a value handed to a caller shares no mutable state with
// the memo ActivityDays keeps for the next request.
func copyActivityDays(a ActivityDays) ActivityDays {
	out := a
	if a.Authors != nil {
		out.Authors = append([]ActivityAuthor(nil), a.Authors...)
	}
	if a.Paths != nil {
		out.Paths = append([]string(nil), a.Paths...)
	}
	if a.Releases != nil {
		out.Releases = make(map[string]string, len(a.Releases))
		for k, v := range a.Releases {
			out.Releases[k] = v
		}
	}
	if a.Days != nil {
		out.Days = make(map[string]ActivityDay, len(a.Days))
		for k, v := range a.Days {
			d := v
			if v.Top != nil {
				d.Top = append([][2]int(nil), v.Top...)
			}
			out.Days[k] = d
		}
	}
	return out
}

func (ix *Index) computeActivityDays(branch string) (ActivityDays, error) {
	out := ActivityDays{
		Days:     map[string]ActivityDay{},
		Releases: map[string]string{},
	}
	q := ActivityQuery{Branch: branch}
	cond, args := q.filters("c")

	isMerge, err := ix.mergeSet()
	if err != nil {
		return ActivityDays{}, err
	}
	rows, err := ix.db.Query(`SELECT c.hash, c.date, c.user
		FROM checkin c WHERE 1=1`+cond, args...)
	if err != nil {
		return ActivityDays{}, err
	}
	defer rows.Close()
	// Per day: how many check-ins each person made, and how many of the
	// day's check-ins were merges.
	perDay := map[string]map[string]int{}
	merges := map[string]int{}
	checkins := map[string]int{}
	for rows.Next() {
		var hash, date, user string
		if err := rows.Scan(&hash, &date, &user); err != nil {
			return ActivityDays{}, err
		}
		d := day(date)
		if out.First == "" || d < out.First {
			out.First = d
		}
		if d > out.Last {
			out.Last = d
		}
		if perDay[d] == nil {
			perDay[d] = map[string]int{}
		}
		perDay[d][user]++
		checkins[user]++
		if isMerge[hash] {
			merges[d]++
		}
	}
	if err := rows.Err(); err != nil {
		return ActivityDays{}, err
	}

	// Authored touches only. A merge absorbs whole directories at once,
	// so counting them would make the day's directory "wherever the
	// branch that landed had been working", every time.
	trows, err := ix.db.Query(`SELECT f.checkin, c.date, c.user, f.path
		FROM filechange f JOIN checkin c ON c.hash = f.checkin
		WHERE f.dir = 0`+cond, args...)
	if err != nil {
		return ActivityDays{}, err
	}
	defer trows.Close()
	dirDay := map[string]map[string]int{}
	files := map[string]int{}
	for trows.Next() {
		var hash, date, user, path string
		if err := trows.Scan(&hash, &date, &user, &path); err != nil {
			return ActivityDays{}, err
		}
		if isMerge[hash] {
			continue
		}
		d := day(date)
		if dirDay[d] == nil {
			dirDay[d] = map[string]int{}
		}
		dirDay[d][topLevel(path)]++
		files[user]++
	}
	if err := trows.Err(); err != nil {
		return ActivityDays{}, err
	}

	var authorIdx map[string]int
	out.Authors, authorIdx = rankAuthors(checkins, files)

	// Paths is every top-level directory the history touched, named
	// once and referenced by index; a slice with six thousand cells
	// should not carry six thousand copies of "src".
	dirs := map[string]bool{}
	for _, m := range dirDay {
		for d := range m {
			dirs[d] = true
		}
	}
	out.Paths = make([]string, 0, len(dirs))
	for d := range dirs {
		out.Paths = append(out.Paths, d)
	}
	sort.Strings(out.Paths)
	dirIdx := make(map[string]int, len(out.Paths))
	for i, d := range out.Paths {
		dirIdx[d] = i
	}

	for d, byUser := range perDay {
		entry := ActivityDay{Merges: merges[d], Authors: len(byUser), Path: -1}
		type pair struct {
			name string
			n    int
		}
		ranked := make([]pair, 0, len(byUser))
		for name, n := range byUser {
			entry.N += n
			ranked = append(ranked, pair{name, n})
		}
		sort.Slice(ranked, func(i, j int) bool {
			if ranked[i].n != ranked[j].n {
				return ranked[i].n > ranked[j].n
			}
			return ranked[i].name < ranked[j].name
		})
		for _, p := range ranked {
			entry.Top = append(entry.Top, [2]int{authorIdx[p.name], p.n})
		}
		best, bestN := "", 0
		for dir, n := range dirDay[d] {
			if n > bestN || (n == bestN && best != "" && dir < best) {
				best, bestN = dir, n
			}
		}
		if bestN > 0 {
			entry.Path = dirIdx[best]
		}
		out.Days[d] = entry
	}

	// Releases: the symbolic tags a reader recognises as a version,
	// applied (sign '+') rather than cancelled.
	rrows, err := ix.db.Query(`SELECT c.date, t.name
		FROM checkin_tag t JOIN checkin c ON c.hash = t.checkin
		WHERE t.sign = '+' AND (t.name = 'sym-release' OR t.name LIKE 'sym-version-%')`+
		cond+` ORDER BY c.date`, args...)
	if err != nil {
		return ActivityDays{}, err
	}
	defer rrows.Close()
	for rrows.Next() {
		var date, name string
		if err := rrows.Scan(&date, &name); err != nil {
			return ActivityDays{}, err
		}
		// A release day usually carries both tags; the version is
		// the one with a number in it, so it wins whichever came first.
		d := day(date)
		name = strings.TrimPrefix(name, "sym-")
		if prev, seen := out.Releases[d]; !seen || (prev == "release" && name != "release") {
			out.Releases[d] = name
		}
	}
	return out, rrows.Err()
}