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 }