ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
254 lines
7.7 KiB
Go
254 lines
7.7 KiB
Go
package db
|
|
|
|
// cold-digest §1/§2.3/§5: persistence for cold-node compression — the round
|
|
// counter, cold_since_round stamps, content versions, digest nodes and the
|
|
// covers edges that are the source of truth for "which digest folds node X".
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// BumpRound advances this exploration's planner-round counter and returns the
|
|
// new value (§2.3). Called once per planner wake-up.
|
|
func (s *ExplorationStore) BumpRound() (int64, error) {
|
|
var r int64
|
|
err := s.db.QueryRow(`UPDATE explorations SET round_no = round_no + 1 WHERE id=$1 RETURNING round_no`, s.expID).Scan(&r)
|
|
return r, err
|
|
}
|
|
|
|
// RoundNo returns the current planner-round counter.
|
|
func (s *ExplorationStore) RoundNo() (int64, error) {
|
|
var r int64
|
|
err := s.db.QueryRow(`SELECT round_no FROM explorations WHERE id=$1`, s.expID).Scan(&r)
|
|
return r, err
|
|
}
|
|
|
|
// ColdStamps returns cold_since_round for every foldable node (intent/fact):
|
|
// id → *round (nil when the node is hot / unstamped). Used to compute the ≥R
|
|
// debounce and to know which stamps to set/clear this round.
|
|
func (s *ExplorationStore) ColdStamps() (map[int64]*int64, error) {
|
|
rows, err := s.db.Query(`SELECT id, cold_since_round FROM exploration_nodes
|
|
WHERE exploration_id=$1 AND kind IN ('intent','fact')`, s.expID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := map[int64]*int64{}
|
|
for rows.Next() {
|
|
var id int64
|
|
var cs sql.NullInt64
|
|
if err := rows.Scan(&id, &cs); err != nil {
|
|
return nil, err
|
|
}
|
|
if cs.Valid {
|
|
v := cs.Int64
|
|
out[id] = &v
|
|
} else {
|
|
out[id] = nil
|
|
}
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// StampOp is one cold_since_round change: Set stamps Round; !Set clears it.
|
|
type StampOp struct {
|
|
ID int64
|
|
Set bool
|
|
Round int64
|
|
}
|
|
|
|
// ApplyStampOps writes a batch of cold_since_round changes in one transaction.
|
|
func (s *ExplorationStore) ApplyStampOps(ops []StampOp) error {
|
|
if len(ops) == 0 {
|
|
return nil
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
for _, o := range ops {
|
|
if o.Set {
|
|
if _, err := tx.Exec(`UPDATE exploration_nodes SET cold_since_round=$1 WHERE id=$2 AND exploration_id=$3`, o.Round, o.ID, s.expID); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if _, err := tx.Exec(`UPDATE exploration_nodes SET cold_since_round=NULL WHERE id=$1 AND exploration_id=$2`, o.ID, s.expID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ContentVersions returns id → content_version for all nodes (§5.3 signature).
|
|
func (s *ExplorationStore) ContentVersions() (map[int64]int, error) {
|
|
rows, err := s.db.Query(`SELECT id, content_version FROM exploration_nodes WHERE exploration_id=$1`, s.expID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := map[int64]int{}
|
|
for rows.Next() {
|
|
var id int64
|
|
var v int
|
|
if err := rows.Scan(&id, &v); err != nil {
|
|
return nil, err
|
|
}
|
|
out[id] = v
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ActiveDigests returns the exploration's live digest nodes (state='active'),
|
|
// oldest first.
|
|
func (s *ExplorationStore) ActiveDigests() ([]*Node, error) {
|
|
rows, err := s.db.Query(`SELECT `+nodeCols+` FROM exploration_nodes
|
|
WHERE exploration_id=$1 AND kind=$2 AND state=$3 ORDER BY id`, s.expID, KindDigest, StateDigestActive)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanNodes(rows)
|
|
}
|
|
|
|
// AddDigest writes one digest node and its covers edges (digest→member) in a
|
|
// single transaction. payload is the digest body + member_ids + generation +
|
|
// signature (see cold-digest §1). Returns the new digest id.
|
|
func (s *ExplorationStore) AddDigest(payload map[string]any, memberIDs []int64) (int64, error) {
|
|
raw, _ := json.Marshal(payload)
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer tx.Rollback()
|
|
var id int64
|
|
if err := tx.QueryRow(`
|
|
INSERT INTO exploration_nodes(exploration_id, kind, payload, priority, state, origin)
|
|
VALUES ($1, $2, $3, 0, $4, 'compactor') RETURNING id`,
|
|
s.expID, KindDigest, string(raw), StateDigestActive).Scan(&id); err != nil {
|
|
return 0, err
|
|
}
|
|
for _, m := range memberIDs {
|
|
if m == id {
|
|
continue
|
|
}
|
|
if _, err := tx.Exec(`
|
|
INSERT INTO exploration_edges(exploration_id, src_id, rel, dst_id) VALUES ($1,$2,$3,$4)
|
|
ON CONFLICT (exploration_id, src_id, rel, dst_id) DO NOTHING`, s.expID, id, RelCovers, m); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
return id, tx.Commit()
|
|
}
|
|
|
|
// CoveredMembers maps member id → covering digest id, for ACTIVE digests only
|
|
// (§6.1 point 1). A node with no entry is not currently folded. Should a member
|
|
// carry covers edges from two digests (a torn major write), the lowest digest id
|
|
// wins deterministically — callers dedupe on this.
|
|
func (s *ExplorationStore) CoveredMembers() (map[int64]int64, error) {
|
|
rows, err := s.db.Query(`
|
|
SELECT e.dst_id, e.src_id
|
|
FROM exploration_edges e
|
|
JOIN exploration_nodes d ON d.id=e.src_id AND d.exploration_id=e.exploration_id
|
|
WHERE e.exploration_id=$1 AND e.rel=$2 AND d.kind=$3 AND d.state=$4
|
|
ORDER BY e.src_id`, s.expID, RelCovers, KindDigest, StateDigestActive)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := map[int64]int64{}
|
|
for rows.Next() {
|
|
var member, digest int64
|
|
if err := rows.Scan(&member, &digest); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, seen := out[member]; !seen { // first (lowest digest id) wins
|
|
out[member] = digest
|
|
}
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// DigestMembers returns the member ids a digest covers (covers edges), sorted.
|
|
func (s *ExplorationStore) DigestMembers(digestID int64) ([]int64, error) {
|
|
rows, err := s.db.Query(`SELECT dst_id FROM exploration_edges
|
|
WHERE exploration_id=$1 AND src_id=$2 AND rel=$3`, s.expID, digestID, RelCovers)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var ids []int64
|
|
for rows.Next() {
|
|
var id int64
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
return ids, rows.Err()
|
|
}
|
|
|
|
// SupersedeDigests retires digest nodes (state→superseded) AND removes their
|
|
// covers edges, atomically, so the active-coverage set (CoveredMembers) never
|
|
// double-counts a member during a major recompaction (§5.1). The digest node
|
|
// itself is kept (node_detail can still resolve it).
|
|
func (s *ExplorationStore) SupersedeDigests(ids []int64) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
for _, id := range ids {
|
|
if _, err := tx.Exec(`DELETE FROM exploration_edges
|
|
WHERE exploration_id=$1 AND src_id=$2 AND rel=$3`, s.expID, id, RelCovers); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(`UPDATE exploration_nodes SET state=$1 WHERE id=$2 AND exploration_id=$3 AND kind=$4`,
|
|
StateDigestSuperseded, id, s.expID, KindDigest); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// NodeAssets maps each of the given node ids → the asset ids it is anchored to
|
|
// (exploration_anchors). Used to group cold digests by asset (§6.2 index).
|
|
func (s *ExplorationStore) NodeAssets(ids []int64) (map[int64][]int64, error) {
|
|
out := map[int64][]int64{}
|
|
if len(ids) == 0 {
|
|
return out, nil
|
|
}
|
|
placeholders := make([]string, len(ids))
|
|
args := make([]any, 0, len(ids)+1)
|
|
args = append(args, s.expID)
|
|
for i, id := range ids {
|
|
placeholders[i] = fmt.Sprintf("$%d", i+2)
|
|
args = append(args, id)
|
|
}
|
|
rows, err := s.db.Query(`SELECT a.node_id, a.asset_id
|
|
FROM exploration_anchors a
|
|
JOIN exploration_nodes n ON n.id=a.node_id
|
|
WHERE n.exploration_id=$1 AND a.node_id IN (`+strings.Join(placeholders, ",")+`)`, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var node, asset int64
|
|
if err := rows.Scan(&node, &asset); err != nil {
|
|
return nil, err
|
|
}
|
|
out[node] = append(out[node], asset)
|
|
}
|
|
return out, rows.Err()
|
|
}
|