~bigbes/sr-ht-spec

ref: 2a7569f43a772345c1bc739089cbad2efae0e278 sr-ht-spec/db/stamps.go -rw-r--r-- 3.6 KiB
2a7569f4 — Eugene Blikh feat: web — the Phase 2 read plane UI and its SCSS entry 27 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
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
}