package db
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// IndexStamp records which revision of a space's approved branch the global
// bleve index currently reflects.
//
// It is the reconciler's staleness comparison, and it is why a crash between
// merge and reindex is a defined repair rather than a bespoke one: stamp rev
// != approved head means reindex. The index is a pure cache, so a missing or
// wrong stamp costs a rebuild, never data.
type IndexStamp struct {
SpaceID int
Rev string
IndexedAt time.Time
}
// GetIndexStamp returns the space's index stamp. Returns ErrNotFound when the
// space has never been indexed — which the reconciler must treat as "stale",
// not as "up to date"; that is exactly why this is an error rather than a zero
// value.
func (s *Store) GetIndexStamp(ctx context.Context, spaceID int) (*IndexStamp, error) {
const q = `SELECT space_id, rev, indexed_at FROM index_stamp WHERE space_id = $1`
var st IndexStamp
err := s.q.QueryRowContext(ctx, q, spaceID).Scan(&st.SpaceID, &st.Rev, &st.IndexedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get index stamp space=%d: %w", spaceID, err)
}
return &st, nil
}
// SetIndexStamp records that the index now reflects rev for this space. Called
// after a rebuild completes, never before: a stamp written ahead of the rebuild
// would make a crash look like a fresh index.
func (s *Store) SetIndexStamp(ctx context.Context, spaceID int, rev string) (*IndexStamp, error) {
if rev == "" {
return nil, fmt.Errorf("set index stamp space=%d: rev is required", spaceID)
}
const q = `
INSERT INTO index_stamp (space_id, rev, indexed_at)
VALUES ($1, $2, $3)
ON CONFLICT (space_id) DO UPDATE
SET rev = EXCLUDED.rev, indexed_at = EXCLUDED.indexed_at
RETURNING space_id, rev, indexed_at`
var st IndexStamp
err := s.q.QueryRowContext(ctx, q, spaceID, rev, time.Now().UTC()).
Scan(&st.SpaceID, &st.Rev, &st.IndexedAt)
if err != nil {
return nil, fmt.Errorf("set index stamp space=%d: %w", spaceID, err)
}
return &st, nil
}
// GetDigestMark returns when the owner last looked at the digest of
// policy-merged content. Returns ErrNotFound if they never have.
//
// Auto-merged content that never appears in any view is write-only and rots
// invisibly — the exact failure this service exists to prevent, just relocated.
// This one timestamp is the whole of "what landed since you last looked".
func (s *Store) GetDigestMark(ctx context.Context, owner string) (time.Time, error) {
const q = `SELECT seen_at FROM digest_mark WHERE owner = $1`
var seen time.Time
err := s.q.QueryRowContext(ctx, q, owner).Scan(&seen)
if errors.Is(err, sql.ErrNoRows) {
return time.Time{}, ErrNotFound
}
if err != nil {
return time.Time{}, fmt.Errorf("get digest mark %q: %w", owner, err)
}
return seen, nil
}
// SetDigestMark moves the owner's "last looked at" timestamp. seenAt is passed
// in rather than defaulted to now() so the caller can mark the digest as of the
// moment it rendered the page, not the moment the write happened — anything
// that lands in between must still show up next time.
func (s *Store) SetDigestMark(ctx context.Context, owner string, seenAt time.Time) error {
if owner == "" {
return fmt.Errorf("set digest mark: owner is required")
}
if seenAt.IsZero() {
return fmt.Errorf("set digest mark %q: seen_at is required", owner)
}
const q = `
INSERT INTO digest_mark (owner, seen_at)
VALUES ($1, $2)
ON CONFLICT (owner) DO UPDATE SET seen_at = EXCLUDED.seen_at`
if _, err := s.q.ExecContext(ctx, q, owner, seenAt.UTC()); err != nil {
return fmt.Errorf("set digest mark %q: %w", owner, err)
}
return nil
}