Skip to main content

internal/infra/pg/feedbackstore.go

internal/infra/pg · 373 lines · 16 declarations · source

Declarations

const DefaultFeedbackPage, MaxFeedbackPage, MaxFeedbackNote and 1 more

const (
DefaultFeedbackPage = 20
MaxFeedbackPage = 100
MaxFeedbackNote = 16384
MaxFeedbackObject = 1024
)

What a caller may hold open at once, and how much of it they may send. The list bound is the record page's, because a client that can page records can page the feedback about them with the same loop; the note and object bounds are the correction's, because a promotion hands exactly these two values to one.

source

var ErrInvalidFeedback, ErrFeedbackNotFound, ErrFeedbackPromoted

var (
ErrInvalidFeedback = errors.New("invalid feedback")
ErrFeedbackNotFound = errors.New("feedback not found")
// ErrFeedbackPromoted is separate from ErrFeedbackNotFound so a second promotion is answered
// with what actually happened. Folding it into "not found" would tell a caller retrying after a
// timeout that their feedback vanished, when it succeeded.
ErrFeedbackPromoted = errors.New("feedback was already promoted")
)

source

type FeedbackStore

type FeedbackStore struct {
pool *pgxpool.Pool
schema Schema
records *RecordStore
}

FeedbackStore holds reports about records that assert nothing.

It owns no opinion about truth. The one operation that changes what the system believes — Promote — does it by running the correction or the retraction a caller could have run by hand, through RecordStore, in the promotion's own transaction. So this type can be read in full without learning a second set of rules for how a fact comes to exist.

source

func NewFeedbackStore

func NewFeedbackStore(pool *pgxpool.Pool, schema Schema, records *RecordStore) *FeedbackStore

source

type Feedback

type Feedback struct {
ID string `json:"id"`
RecordID string `json:"record_id"`
Note string `json:"note"`
ProposedObject *string `json:"proposed_object,omitempty"`
RecordedBy string `json:"recorded_by"`
RecordedAt time.Time `json:"recorded_at"`
PromotedBy *string `json:"promoted_by,omitempty"`
PromotedAt *time.Time `json:"promoted_at,omitempty"`
// ReplacementID is the record a promoted correction produced. Absent on a promoted retraction,
// which withdraws without replacing, and on anything not yet promoted.
ReplacementID *string `json:"replacement_record_id,omitempty"`
}

Feedback is what was reported, and what became of it.

source

type FeedbackRequest

type FeedbackRequest struct {
RecordID string `json:"record_id"`
Note string `json:"note"`
ProposedObject *string `json:"proposed_object,omitempty"`
}

FeedbackRequest records one report. The credential is taken from the grant and never from here: a body that could name its own author would make attribution a claim rather than a fact.

source

type FeedbackCursor

type FeedbackCursor struct {
RecordedAt time.Time `json:"recorded_at"`
ID string `json:"id"`
}

source

type FeedbackPage

type FeedbackPage struct {
Feedback []Feedback `json:"feedback"`
Next *FeedbackCursor `json:"next,omitempty"`
}

source

type PromotionRequest

type PromotionRequest struct {
ID string `json:"id"`
// ExpectedVersion is the target record's version, not the feedback's. Feedback does not change
// after it is written, so there is nothing about it to be stale about — but the record it
// disputes moves, and promoting against a version the reporter never saw would apply somebody's
// judgement to a claim they did not read. The same optimistic check a hand-written correction
// makes, required at the same place.
ExpectedVersion string `json:"expected_version"`
}

PromotionRequest names one feedback to act on.

source

type Promotion

type Promotion struct {
ID string `json:"id"`
RecordID string `json:"record_id"`
// Operation is "record.correct" or "record.retract" — the ledger's own name for what ran, so a
// caller reading this and a reviewer reading the audit table are looking at one identifier.
Operation string `json:"operation"`
ReplacementID *string `json:"replacement_record_id,omitempty"`
// Version is the replacement's version for a correction, and the withdrawn record's closing
// version for a retraction. Either way it is what the caller sends next.
Version string `json:"version"`
PromotedAt time.Time `json:"promoted_at"`
}

Promotion says what the promotion did, in the vocabulary of the operation it performed.

source

method FeedbackStore.Record

func (s *FeedbackStore) Record(ctx context.Context, scope, principal string, request FeedbackRequest) (Feedback, error)

Record writes one report and registers it for erasure.

What it deliberately does not do

It writes no fact, advances no watermark and touches no entity. That is the property the whole feature exists for: a caller reporting a doubt must be able to do so without changing an answer, and a test names it.

How erasure reaches it

By copying the target record's own registrations: one row per registration the fact has, with the same source observation and the same data subject. So feedback is reachable by exactly the erasures that reach the record it is about — a claim that is true by construction rather than by two pieces of code agreeing about which observation counts as the source.

It is declared `survives_sharing = false` where the fact is true, and that difference is deliberate. A fact two people contributed to is kept when one of them leaves, because it is also the other's. The feedback about it is not: one person wrote those words, and a second contributor to the record does not make them theirs to keep.

source

func boundedText

func boundedText(s string, min, max int) bool

source

method FeedbackStore.List

func (s *FeedbackStore) List(ctx context.Context, scope, recordID string, openOnly bool, after *FeedbackCursor, limit int) (FeedbackPage, error)

List pages feedback newest first, optionally narrowed to one record or to what is still open.

The cursor is (recorded_at, feedback_id) rather than an offset, for the reason the record page gives: an OFFSET rescans what it already returned and shifts every later page when a row ahead of it is deleted, which an erasure does. The id breaks ties so two reports written in the same microsecond cannot hide each other.

source

const feedbackListSQL

const feedbackListSQL = `SELECT f.feedback_id::text,f.target_fact_id::text,f.note,
f.proposed_object,f.recorded_by::text,f.recorded_at,f.promoted_by::text,f.promoted_at,f.promotion_fact_id::text
FROM {schema}.memory_feedback f WHERE {where}
ORDER BY f.recorded_at DESC,f.feedback_id DESC LIMIT $2`

feedbackListSQL is the shape every list runs, with its filters rendered into {where}.

Separate from List so a plan test can EXPLAIN exactly what the store sends. A plan test holding a query written beside it rather than the one that runs proves the index works for a statement nothing issues, which is the drift a measured plan exists to rule out.

source

func feedbackListQuery

func feedbackListQuery(scope, recordID string, openOnly bool, after *FeedbackCursor, limit int) (string, []any, error)

feedbackListQuery renders the filters and binds them. Identifiers are fixed text; everything a caller supplied is a parameter, so a filter cannot become part of the statement.

source

method FeedbackStore.Promote

func (s *FeedbackStore) Promote(ctx context.Context, scope, principal string, request PromotionRequest) (Promotion, error)

Promote merges one feedback into the graph, as the operation the feedback describes.

A proposed object makes it a correction of the target; no proposed object makes it a retraction. That branch is the entire difference between the two outcomes, and it is read from the single nullable column the writer set — so a caller cannot ask for one and get the other, and there is no third path by which a fact can come into existence.

Why it is one transaction

The correction and the promotion mark commit together. Two transactions would leave a window in which the graph holds the correction and the feedback still reads as open, and the next promotion would apply it again — withdrawing a record nobody disputed. The feedback row is taken FOR UPDATE before anything else, so two concurrent promotions of one feedback serialise and the second finds it promoted rather than promoting it twice.

source