package pgstore import ( "context" "encoding/json" "errors" "fmt" "github.com/jackc/pgx/v5" ) // events.go: the frame history a book's event stream is resumed from. // // Frames are minted by the WRITER, inside the transaction that caused them, and not by the // connection that sends them. That is forced rather than chosen: `Last-Event-ID` has to mean the // same thing however a frame was carried (canon §EventEnvelope), so two people watching one book // must see one frame under one id — which a per-connection counter cannot give and a stored one // gives for free. // // It does NOT make the reporting database an event store. What is kept is a short live buffer, // pruned as it grows: enough to continue a client that blinked, never enough to replay a history. // A client that presents an id older than the buffer is told `resync_required` and re-reads, which // is the same answer the contract gives after a wholesale replacement. // Frame names, matching the contract's event table. Only HISTORY frames appear here: `hello`, `end`, // `resync_required` and `session_ended` belong to a CONNECTION, carry the id of the last history // frame and consume none of their own. (The fourth arrived with canon 0.8.0 — a revocation or an // expired session ends the stream; see httpapi.eventSessionEnded.) const ( FrameStatus = "status" FrameProgress = "progress" FrameChapter = "chapter" FrameNote = "note" FrameBank = "bank" ) // bufferFrames is how many frames of one book are kept. It bounds the table rather than promising a // window: the contract says explicitly that the buffer's size is not declared and that a client MUST // NOT depend on any frame being resent. const bufferFrames = 512 // Frame is one stored frame. type Frame struct { Position int64 Event string // Data is the frame's payload exactly as it goes on the wire, built in the transaction that // caused it. Re-deriving it at send time would answer a later state under an earlier id. Data json.RawMessage } // emitFrame appends one frame inside the caller's transaction. // // The caller passes the payload WITHOUT the two book-scope numbers: every frame carries them // (canon §EventBase), so they are stamped here from the row this transaction has already moved — // which is also why this must be called AFTER the write it announces and never before. func emitFrame(ctx context.Context, tx pgx.Tx, bookID, event string, payload map[string]any) error { var position, revision int64 var structure int err := tx.QueryRow(ctx, ` update books set event_position = event_position + 1 where id = $1 returning event_position, revision, structure_version`, bookID). Scan(&position, &revision, &structure) if errors.Is(err, pgx.ErrNoRows) { return nil // the book is gone; nothing is watching it } if err != nil { return fmt.Errorf("pgstore: mint frame position: %w", err) } if payload == nil { payload = map[string]any{} } payload["revision"] = revision payload["structure_version"] = structure data, err := json.Marshal(payload) if err != nil { return fmt.Errorf("pgstore: encode frame: %w", err) } if _, err := tx.Exec(ctx, ` insert into book_events (book_id, position, event, data) values ($1, $2, $3, $4)`, bookID, position, event, data); err != nil { return fmt.Errorf("pgstore: append frame: %w", err) } // Pruned here rather than by a sweep: the buffer is bounded per book by construction, and a // retention pass that has to be scheduled is a second thing to get wrong. if _, err := tx.Exec(ctx, `delete from book_events where book_id = $1 and position <= $2`, bookID, position-bufferFrames); err != nil { return fmt.Errorf("pgstore: prune frames: %w", err) } return nil } // StreamState is what a stream needs to know about a book before and between frames. type StreamState struct { // Position is the book's last history frame, or 0 when it has produced none. History numbering // starts at 1, so 0 can never collide with a real frame and is what a connection frame of a book // with no history carries. Position int64 Revision int64 StructureVersion int // AtRest is "no run is live and no intake is in flight" — the condition the contract ends a // stream on, and the one under which a presented id at or past Position is answered 204. // // ⚠ A materialization still owed is intake in flight. The tree lands a moment AFTER the parse // commits, and a run's text a moment after the run closes; calling those moments rest sent `end` // and then answered the browser's own reconnect 204, so the client stopped watching exactly when // the chapters were about to appear. AtRest bool // Oldest is the oldest frame still buffered, or 0 when nothing is. A client presenting an id // below it cannot be continued and is told to re-sync. Oldest int64 } // nothingIsRunning is the canon's "no run live and no intake in flight" (§streamBookEvents), over a // `books b`. One string because both readers want the SAME condition and must not drift: the stream // ends on it, and the materializer takes a book only while it holds (BooksOwedReadModel). What the // two differ on is the debt beside it, which is not part of this. const nothingIsRunning = `b.status not in ('uploading', 'parsing') and not exists (select 1 from runs r where r.book_id = b.id and r.finished_at is null)` // ReadStream reports a book's stream state, or ErrNoBook when the caller may not see it. func (s *Store) ReadStream(ctx context.Context, userID, bookID string) (StreamState, error) { const q = ` select b.event_position, b.revision, b.structure_version, ` + nothingIsRunning + ` and b.read_model_owed_at is null, coalesce((select min(position) from book_events e where e.book_id = b.id), 0) from books b where b.id = $1 and b.owner_id = $2` var out StreamState err := s.pool.QueryRow(ctx, q, bookID, userID). Scan(&out.Position, &out.Revision, &out.StructureVersion, &out.AtRest, &out.Oldest) if errors.Is(err, pgx.ErrNoRows) { return StreamState{}, ErrNoBook } if err != nil { return StreamState{}, fmt.Errorf("pgstore: read stream state: %w", err) } return out, nil } // ReadFrames returns the buffered frames after a position, oldest first. func (s *Store) ReadFrames(ctx context.Context, bookID string, after int64, limit int) ([]Frame, error) { rows, err := s.pool.Query(ctx, ` select position, event, data from book_events where book_id = $1 and position > $2 order by position limit $3`, bookID, after, limit) if err != nil { return nil, fmt.Errorf("pgstore: read frames: %w", err) } defer rows.Close() var out []Frame for rows.Next() { var f Frame if err := rows.Scan(&f.Position, &f.Event, &f.Data); err != nil { return nil, fmt.Errorf("pgstore: scan frame: %w", err) } out = append(out, f) } return out, rows.Err() }