package beads
import (
"context"
"regexp"
"sort"
"strings"
"time"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
)
// --- which database owns "<prefix>-<id>" --------------------------------------
//
// A global-tracker issue that says "blocked by artifacts-nex.2" is naming a row
// in another database, and without this the reader has to work out which one and
// go there by hand. Two levels of tracker only pay for themselves if they point
// at each other.
//
// This file answers the one question that needs several databases: given an
// id-shaped string in some text, which database — of the ones this caller may
// browse — owns it. Whether the answer becomes a link, and what that link looks
// like, is the renderer's business; this package renders nothing.
//
// Authorization is not here, exactly as it is not in ReadyAcross: handing this
// function a database is the statement that the caller may read it. A database
// the caller may not browse is one the caller's index has never heard of, which
// is what makes an id belonging to it plain text and not a hint that it exists.
// prefixKey is the config row every beads database stores its own id prefix in
// ("global", "artifacts", "sr-ht-dolt", …). It sits in the same config table the
// memories do, beside compact_tier2_days and the rest of the tracker's settings.
const prefixKey = "issue_prefix"
// PrefixCache is the prefix index's projection cache: one prefix per database,
// gated on the head hash and expiring on ReadyCacheTTL — /ready's bounds and not
// a second set (see cache.go). The zero value is usable, so a holder that has no
// constructor can carry one as a field.
type PrefixCache struct {
projectionCache[string]
}
// PrefixIndex is prefix → the database that owns it, over the databases one
// caller may browse. It is built per caller and must not be shared between them:
// what it holds is precisely the set of databases that caller is allowed to know
// exists.
type PrefixIndex struct {
byPrefix map[string]ReadyDatabase
// Failed lists the databases that could not be read. It is here for the
// caller's log and for nothing else: a database that could not be read costs
// the ids it owns their link, and a page may not say more than that.
Failed []ReadyFailure
}
// Reference is one id found in a text: where it sits, what it says, and which
// database owns it.
type Reference struct {
Start, End int // byte offsets of the id within the scanned text
ID string // the id exactly as written
Database ReadyDatabase
}
// idPattern matches an id-shaped token: a prefix of one or more lowercase
// alphanumeric segments joined by hyphens, then a hyphen, then the suffix bd
// generates — `[0-9a-z]+` with optional dotted parts, which is the `46c.2`
// subtask form.
//
// The suffix carries no hyphen, so the last hyphen of a token is always the
// prefix/suffix boundary and the split below is unambiguous even for a prefix
// that is itself hyphenated ("sr-ht-dolt-44n.6" is sr-ht-dolt's 44n.6, never
// sr-ht's dolt-44n.6). The word boundaries keep a token from being found inside
// a longer word, so "Xartifacts-46c" names nothing.
//
// The token being id-shaped is not what makes it an id: only a prefix the index
// knows makes it one. Everything else is left as it was written.
var idPattern = regexp.MustCompile(`\b[0-9a-z_]+(?:-[0-9a-z_]+)*-[0-9a-z]+(?:\.[0-9a-z]+)*\b`)
// PrefixesAcross builds the index over the databases it is handed: for each, the
// issue_prefix its config names.
//
// It is bounded exactly as ReadyAcross is — the same head-hash gate, the same
// TTL, the same ceiling — because it is the same read pattern: N stores opened
// for one request. It is cheaper per database, though: the projection is one row
// of a dozen-row table, and the fingerprint is not asked for separately, since a
// config table carrying issue_prefix is itself the statement that this is a bd
// tracker. A database with no such row simply owns no prefix, and that answer is
// cached like any other.
//
// now is the clock the TTL is measured against, passed in rather than read here
// for the reason ReadyAcross takes one: this package reads no hidden clock.
//
// It returns no error. A database that cannot be opened or read costs the ids it
// owns their link and nothing else; it lands in Failed for the caller's log.
func PrefixesAcross(
ctx context.Context,
dbs []ReadyDatabase,
open ReadyOpener,
cache *PrefixCache,
now time.Time,
) *PrefixIndex {
index := &PrefixIndex{byPrefix: map[string]ReadyDatabase{}}
if len(dbs) > ReadyMaxDatabases {
// The first Max in the order the caller listed them. The caller puts the
// database whose page this is first, so the one prefix a page cannot do
// without is the one the ceiling can never drop.
dbs = dbs[:ReadyMaxDatabases]
}
// A prefix two databases both claim is dropped rather than awarded to
// whichever was listed first: linking to one of two candidates would be a
// guess rendered as a fact, and the id is readable as text either way.
ambiguous := map[string]bool{}
for _, d := range dbs {
prefix, err := databasePrefix(ctx, d, open, cache, now)
if err != nil {
index.Failed = append(index.Failed, ReadyFailure{Database: d, Err: err})
continue
}
if prefix == "" {
continue
}
if ambiguous[prefix] {
continue
}
if _, taken := index.byPrefix[prefix]; taken {
ambiguous[prefix] = true
delete(index.byPrefix, prefix)
continue
}
index.byPrefix[prefix] = d
}
return index
}
// databasePrefix returns one database's issue prefix, from the cache when the
// head has not moved and by reading config otherwise.
//
// The order is the whole point of the gate: open, list branches, and only then
// consult the cache. Opening a session and reading the branch list is cheap;
// reading rows is not, and on a hit no row is touched.
func databasePrefix(
ctx context.Context,
d ReadyDatabase,
open ReadyOpener,
cache *PrefixCache,
now time.Time,
) (string, error) {
sess, err := open(ctx, d)
if err != nil {
return "", err
}
defer sess.Close()
branches, err := sess.Branches(ctx)
if err != nil {
return "", err
}
ref := browse.DefaultBranch(branches)
if ref == "" {
// A store with no branches carries no config either: nothing to read and
// nothing to cache.
return "", nil
}
head := headHashOf(branches, ref)
if prefix, ok := cache.lookup(d.ID, head, now); ok {
return prefix, nil
}
prefix, err := readPrefix(ctx, sess, ref)
if err != nil {
return "", err
}
cache.store(d.ID, head, now, prefix)
return prefix, nil
}
// readPrefix reads the issue_prefix row out of a database's config table. A
// missing table — this is not a bd tracker — is no prefix, the treatment every
// optional table gets here.
func readPrefix(ctx context.Context, sess BrowseSession, ref string) (string, error) {
rows, _, err := readRowsOptional(ctx, sess, ref, memoryTable)
if err != nil {
return "", err
}
if rows == nil {
return "", nil
}
cols := indexCols(rows.Columns)
for _, r := range rowsOf(rows) {
if cell(cols, r, "key") != prefixKey {
continue
}
// Lowercased because that is the case the ids themselves are written in,
// and the index is looked up by what the text says.
return strings.ToLower(strings.TrimSpace(cell(cols, r, "value"))), nil
}
return "", nil
}
// Lookup returns the database owning prefix. A nil index — no database was
// readable, or the caller did not build one for this page — knows no prefix,
// which is the same answer as an unknown one: not linked.
func (ix *PrefixIndex) Lookup(prefix string) (ReadyDatabase, bool) {
if ix == nil {
return ReadyDatabase{}, false
}
d, ok := ix.byPrefix[prefix]
return d, ok
}
// Prefixes lists the prefixes the index knows, in order. It exists for the
// caller's log line and for tests.
func (ix *PrefixIndex) Prefixes() []string {
if ix == nil {
return nil
}
out := make([]string, 0, len(ix.byPrefix))
for p := range ix.byPrefix {
out = append(out, p)
}
sort.Strings(out)
return out
}
// Scan returns every id in text whose prefix this index knows, in order and
// non-overlapping.
//
// It answers where the ids are and nothing about how they should be rendered:
// the caller escapes the text it was handed and wraps these ranges, which is the
// only order in which stored text can become HTML safely.
//
// An id-shaped token whose prefix the index does not know yields nothing at all,
// which is what makes an id in a database the caller may not browse
// indistinguishable from one that matches nothing.
func (ix *PrefixIndex) Scan(text string) []Reference {
if ix == nil || len(ix.byPrefix) == 0 || text == "" {
return nil
}
var out []Reference
for _, m := range idPattern.FindAllStringIndex(text, -1) {
token := text[m[0]:m[1]]
cut := strings.LastIndex(token, "-")
if cut <= 0 {
continue
}
d, ok := ix.byPrefix[token[:cut]]
if !ok {
continue
}
out = append(out, Reference{Start: m[0], End: m[1], ID: token, Database: d})
}
return out
}