Files
Pete/internal/storage/tables.go
prosolis 4b3e5fe4c5 games: the felt other people can sit at, and the version that settles the race
Phase B foundation for the multiplayer casino: the shared-table storage layer,
the SSE fan-out, and the lock that only ever pretends to be the authority.

- game_tables/game_seats/game_chat, plus a nullable table_id on game_live_hands
  so occupancy stays one row per player — the same primary key that stops a
  second solo hand stops a second seat. No second uniqueness domain, no split
  brain, no cash-out-to-zero while sitting on a pot.
- The money model the plan sketched turned out simpler than it drew: chips cross
  the border only at sit-down and get-up, so a hand settles by moving the pot
  *within* the state blob and credits nobody. That deletes the payout ledger
  the design called for — there is no money write to make idempotent, only a
  state write conditional on the version. A replayed settle affects zero rows.
- CommitTable/SitDown/LeaveTable each one transaction with the state write in it;
  the version column is the concurrency authority and the striped in-memory lock
  is only an optimisation over it, because a mutex does not survive a redeploy.
- The SSE hub is a dumb byte fan-out: non-blocking sends (a stalled phone must
  not hold the table lock and freeze the clock for the room) and never a DB
  touch after the first read (holding the one connection open bricks the app).
- DueTables/PushDeadlines for the turn clock to come; Chat keeps the hand_no it
  was said during, because at a money table collusion looks like chat.

Storage and hub tested, including the version race and the never-block publish.
No handlers wired yet, so nothing a player can see has changed.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 15:43:39 -07:00

685 lines
23 KiB
Go

package storage
import (
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"strings"
)
// Shared tables: the casino with more than one person at it.
//
// The money model is the thing to understand first, because everything else
// follows from it, and it is *not* the one the plan sketched.
//
// **Chips cross into a table when you sit down, and back out when you get up.
// Nothing in between touches game_chips at all.** Your buy-in leaves your stack
// and becomes a stack inside the engine's own state; antes, bets, pots and
// payouts are all moves within that blob; and getting up is the single write
// that turns what is in front of you back into chips.
//
// This is how hold'em already worked as a solo session, and generalising it is
// what makes a pot safe. The obvious alternative — settle each hand by crediting
// every winner's game_chips row — puts a real money write on the end of every
// hand, and a crash between the credit and the state write pays the winner twice.
// Here a settle credits nobody: it is a state write, conditional on the version,
// and a replay of it affects zero rows and rolls back. The pot cannot be paid
// twice because it is never *paid* at all, only moved.
//
// So the money invariant across a table's whole life is:
//
// sum(seat stacks in the blob) + pot == sum(game_seats.staked) - rake taken
//
// and the only two statements that move chips across the border are the stake in
// SitDown and the award in LeaveTable — each of them one transaction, each of
// them carrying the state write that justifies it.
//
// The other half is concurrency. A table has two writers where a solo game had
// one: an HTTP move, and a turn clock acting for whoever walked away. The version
// column is the authority — every state write is conditional on the version its
// writer read — and the striped mutex in the web layer is only an optimisation on
// top. Correctness has to live in the database, because a mutex does not survive
// a redeploy: during a drain, two processes hold two different mutexes over the
// same row and both of them believe they are alone.
var (
// ErrStaleTable means somebody else wrote the table first. The caller's read is
// out of date; it must reload and decide again. This is not an error condition
// so much as the normal outcome of a race, and it is what a 409 is made of.
ErrStaleTable = errors.New("games: the table moved on")
// ErrNoSuchTable means there is no table with that id.
ErrNoSuchTable = errors.New("games: no such table")
// ErrSeatTaken means somebody sat down there between the read and the write.
ErrSeatTaken = errors.New("games: that seat is taken")
)
// Table is a felt other people can sit at.
//
// State is the engine's State, serialized whole — the same blob game_live_hands
// holds for a solo game, and for the same reason: the deck is in it, so it never
// leaves the server, and a hand survives a redeploy.
type Table struct {
ID string
Game string
Tier string
State []byte
Seed1 uint64
Seed2 uint64
Phase string
// HandNo, with the id, is the identity of one hand. It is what the audit trail
// keys on now that a seed no longer reproduces a hand: at a shared table the
// cards fall the way they do because of the order the others acted, not just
// the seed, so "deal it again from seed1/seed2" stopped being a true story the
// moment there was a second player.
HandNo int64
// Version is the concurrency authority. Read it, write against it, and a write
// that finds it moved is a write that lost the race.
Version int64
// Deadline is the unix second by which the seat to act has to act, or 0 for no
// clock at all. Only a *human* to act sets one: bots resolve inside ApplyMove
// and there is nobody to wait for.
Deadline int64
CreatedAt int64
UpdatedAt int64
}
// Seat is one chair. A seat with no MatrixUser is a bot, which is what makes solo
// play just "a table nobody else has joined yet" rather than a second mode.
type Seat struct {
Seat int
MatrixUser string // "" for a bot
Name string
// Staked is what this player brought and has not yet taken home. The chips are
// off their game_chips stack and inside the table blob, where the idle reaper
// cannot see them — so this is the row that says they exist.
Staked int64
Away bool
LastSeen int64
}
// Bot reports whether nobody is sitting here.
func (s Seat) Bot() bool { return s.MatrixUser == "" }
// NewTableID mints a table id. Short enough to put in a URL, random enough that
// nobody guesses their way onto somebody else's felt.
func NewTableID() (string, error) {
b := make([]byte, 9)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("games: mint table id: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// OpenTable creates a table and seats it — bots in every chair nobody has taken.
func OpenTable(t Table, seats []Seat) error {
now := nowUnix()
tx, err := Get().Begin()
if err != nil {
return fmt.Errorf("games: begin open table: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
if _, err := tx.Exec(
`INSERT INTO game_tables (id, game, tier, state, seed1, seed2, phase, hand_no, version, deadline, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`,
t.ID, t.Game, t.Tier, string(t.State), int64(t.Seed1), int64(t.Seed2),
t.Phase, t.HandNo, t.Deadline, now, now,
); err != nil {
return fmt.Errorf("games: open table: %w", err)
}
for _, s := range seats {
if err := upsertSeat(tx, t.ID, s, now); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("games: commit open table: %w", err)
}
return nil
}
// upsertSeat writes a seat row inside an open transaction, bots included.
func upsertSeat(tx *sql.Tx, tableID string, s Seat, now int64) error {
var user any
if s.MatrixUser != "" {
user = s.MatrixUser
}
if _, err := tx.Exec(
`INSERT INTO game_seats (table_id, seat, matrix_user, name, staked, away, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(table_id, seat) DO UPDATE SET
matrix_user = excluded.matrix_user, name = excluded.name,
staked = excluded.staked, away = excluded.away, last_seen = excluded.last_seen`,
tableID, s.Seat, user, s.Name, s.Staked, boolInt(s.Away), now,
); err != nil {
return fmt.Errorf("games: seat: %w", err)
}
return nil
}
func boolInt(b bool) int64 {
if b {
return 1
}
return 0
}
// LoadTable reads a table and everyone at it.
func LoadTable(id string) (Table, []Seat, error) {
var t Table
var state string
var s1, s2 int64
err := Get().QueryRow(
`SELECT id, game, tier, state, seed1, seed2, phase, hand_no, version, deadline, created_at, updated_at
FROM game_tables WHERE id = ?`, id,
).Scan(&t.ID, &t.Game, &t.Tier, &state, &s1, &s2, &t.Phase, &t.HandNo, &t.Version, &t.Deadline, &t.CreatedAt, &t.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Table{}, nil, ErrNoSuchTable
}
if err != nil {
return Table{}, nil, fmt.Errorf("games: load table: %w", err)
}
t.State, t.Seed1, t.Seed2 = []byte(state), uint64(s1), uint64(s2)
seats, err := tableSeats(id)
if err != nil {
return Table{}, nil, err
}
return t, seats, nil
}
// tableSeats reads the chairs, in seat order.
func tableSeats(id string) ([]Seat, error) {
rows, err := Get().Query(
`SELECT seat, matrix_user, name, staked, away, last_seen
FROM game_seats WHERE table_id = ? ORDER BY seat`, id)
if err != nil {
return nil, fmt.Errorf("games: table seats: %w", err)
}
defer rows.Close()
var out []Seat
for rows.Next() {
var s Seat
var user sql.NullString
var away int64
if err := rows.Scan(&s.Seat, &user, &s.Name, &s.Staked, &away, &s.LastSeen); err != nil {
return nil, fmt.Errorf("games: scan seat: %w", err)
}
s.MatrixUser, s.Away = user.String, away != 0
out = append(out, s)
}
return out, rows.Err()
}
// TableSummary is a table as the lobby lists it: enough to decide whether to sit
// down, and nothing that would give away a card.
type TableSummary struct {
ID string `json:"id"`
Game string `json:"game"`
Tier string `json:"tier"`
Phase string `json:"phase"`
Humans int `json:"humans"`
Seats int `json:"seats"`
UpdatedAt int64 `json:"updated_at"`
}
// LobbyTables lists the live tables, most recently played first. A game of "" is
// all of them.
func LobbyTables(game string, limit int) ([]TableSummary, error) {
if limit <= 0 {
limit = 50
}
q := `SELECT t.id, t.game, t.tier, t.phase, t.updated_at,
COUNT(s.seat),
COUNT(s.matrix_user)
FROM game_tables t
LEFT JOIN game_seats s ON s.table_id = t.id`
args := []any{}
if game != "" {
q += ` WHERE t.game = ?`
args = append(args, game)
}
q += ` GROUP BY t.id ORDER BY t.updated_at DESC LIMIT ?`
args = append(args, limit)
rows, err := Get().Query(q, args...)
if err != nil {
return nil, fmt.Errorf("games: lobby: %w", err)
}
defer rows.Close()
var out []TableSummary
for rows.Next() {
var s TableSummary
if err := rows.Scan(&s.ID, &s.Game, &s.Tier, &s.Phase, &s.UpdatedAt, &s.Seats, &s.Humans); err != nil {
return nil, fmt.Errorf("games: scan lobby row: %w", err)
}
out = append(out, s)
}
return out, rows.Err()
}
// ---- writing a table back --------------------------------------------------
// TableCommit is one write-back of a shared table.
//
// There is no payout field, and its absence is the design. A hand ending at a
// shared table moves chips *within* the blob — the pot becomes somebody's stack —
// so settling one credits nobody and mints nothing. What it writes is the state,
// the audit rows, and whatever the seats now look like. The version makes it
// exactly-once: a settle that runs twice loses the race with itself.
type TableCommit struct {
// Table carries the new state and the version that was *read*. The write is
// conditional on that version and bumps it.
Table Table
// Seats to rewrite — a stack that changed hands, a seat that came back from
// away. Seats not named here are left alone.
Seats []Seat
// Audit is the per-seat record of a hand that just ended. Empty mid-hand.
Audit []Hand
}
// CommitTable writes a table back, and the hand it just finished with it, in one
// transaction.
//
// The version check is the whole safety property, and it is why this can be
// called by the turn clock and an HTTP move at the same instant without either
// having to trust the other. Whoever gets there first bumps the version; the
// loser's UPDATE matches zero rows, the transaction rolls back, and it comes back
// ErrStaleTable with nothing written — no half-settled hand, no audit row for a
// hand that did not happen.
//
// Nothing in here may call Get().Exec. The pool runs at MaxOpenConns(1), so a
// bare Exec inside an open transaction waits forever for the connection that this
// transaction is holding — and takes the news app down with it. See CommitHand.
func CommitTable(c TableCommit) error {
now := nowUnix()
tx, err := Get().Begin()
if err != nil {
return fmt.Errorf("games: begin commit table: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
if err := saveTable(tx, c.Table, now); err != nil {
return err
}
for _, s := range c.Seats {
if err := upsertSeat(tx, c.Table.ID, s, now); err != nil {
return err
}
}
for _, h := range c.Audit {
if err := recordHand(tx, h, now); err != nil {
return err
}
// Playing a hand is the most deliberate thing a player does. Keep the reaper
// off them — their chips are inside the table blob, where it cannot see them
// anyway, but their game_chips row is what it reads.
if h.MatrixUser == "" {
continue
}
if _, err := tx.Exec(
`UPDATE game_chips SET last_played = ?, updated_at = ? WHERE matrix_user = ?`,
now, now, h.MatrixUser,
); err != nil {
return fmt.Errorf("games: touch session: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("games: commit table: %w", err)
}
return nil
}
// saveTable is the conditional state write: it lands only if the version is still
// the one the caller read.
func saveTable(tx *sql.Tx, t Table, now int64) error {
res, err := tx.Exec(
`UPDATE game_tables SET state = ?, phase = ?, hand_no = ?, seed1 = ?, seed2 = ?,
deadline = ?, version = version + 1, updated_at = ?
WHERE id = ? AND version = ?`,
string(t.State), t.Phase, t.HandNo, int64(t.Seed1), int64(t.Seed2),
t.Deadline, now, t.ID, t.Version,
)
if err != nil {
return fmt.Errorf("games: save table: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrStaleTable
}
return nil
}
// ---- sitting down and getting up -------------------------------------------
// Sit is one player taking one chair, with the table state that has them in it.
type Sit struct {
Table Table // the new state, and the version that was read
Seat Seat // MatrixUser, Name and the chair; Staked is the buy-in
BuyIn int64
}
// SitDown moves a player's chips onto a table and puts them in a seat — the first
// of the only two statements in the casino that cross the chip/table border.
//
// It is one transaction and every step of it can refuse:
//
// - the chips leave in the same statement that checks they are there, so two
// joins fired at once cannot spend the same chip;
// - the occupancy claim is game_live_hands' primary key, exactly as it is for a
// solo hand, so a player cannot be at two tables (or at a table and in a solo
// game) at once, and a double-clicked Join is a 409;
// - the seat is taken only if a bot is sitting in it, so two players racing for
// the last chair cannot both win;
// - and the state write is conditional on the version, so the engine's idea of
// who is at the table cannot drift from the seat rows.
//
// Any of those failing rolls back the buy-in with it.
func SitDown(s Sit) error {
if s.BuyIn <= 0 {
return ErrBadAmount
}
now := nowUnix()
tx, err := Get().Begin()
if err != nil {
return fmt.Errorf("games: begin sit: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
res, err := tx.Exec(
`UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ?
WHERE matrix_user = ? AND chips >= ?`,
s.BuyIn, now, now, s.Seat.MatrixUser, s.BuyIn,
)
if err != nil {
return fmt.Errorf("games: stake buy-in: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrInsufficientChips
}
// The occupancy claim. The state column is empty on purpose: the cards live in
// game_tables, and this row exists to be a primary key.
res, err = tx.Exec(
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, table_id, updated_at)
VALUES (?, ?, '', 0, 0, ?, ?)
ON CONFLICT(matrix_user) DO NOTHING`,
s.Seat.MatrixUser, s.Table.Game, s.Table.ID, now,
)
if err != nil {
return fmt.Errorf("games: claim seat: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrHandInProgress
}
// Take the chair, but only out of a bot's hands.
res, err = tx.Exec(
`UPDATE game_seats SET matrix_user = ?, name = ?, staked = ?, away = 0, last_seen = ?
WHERE table_id = ? AND seat = ? AND matrix_user IS NULL`,
s.Seat.MatrixUser, s.Seat.Name, s.BuyIn, now, s.Table.ID, s.Seat.Seat,
)
if err != nil {
return fmt.Errorf("games: take seat: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrSeatTaken
}
if err := saveTable(tx, s.Table, now); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("games: commit sit: %w", err)
}
return nil
}
// Leave is one player getting up, with the table state that no longer has them.
type Leave struct {
Table Table // the new state, and the version that was read
Seat int
MatrixUser string
// Bot is who takes the chair over. A table always has a full complement, so
// getting up hands the seat back to the house rather than leaving a hole.
Bot string
// Amount is what is in front of them: everything they are taking home. It may
// be more than they brought, or nothing at all.
Amount int64
// Audit, if the leaving itself settles something worth recording.
Audit []Hand
}
// LeaveTable turns what is in front of a player back into chips — the second and
// last statement that crosses the chip/table border.
//
// One transaction, and the state write is in it. As two statements this is a
// double-pay waiting to happen: award 1,240 chips, fail the state write, and the
// player reloads to find their seat still there with 1,240 in front of it. They
// get up again, and again.
func LeaveTable(l Leave) error {
now := nowUnix()
tx, err := Get().Begin()
if err != nil {
return fmt.Errorf("games: begin leave: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
if err := saveTable(tx, l.Table, now); err != nil {
return err
}
if err := upsertSeat(tx, l.Table.ID, Seat{Seat: l.Seat, Name: l.Bot}, now); err != nil {
return err
}
if _, err := tx.Exec(
`DELETE FROM game_live_hands WHERE matrix_user = ? AND table_id = ?`,
l.MatrixUser, l.Table.ID,
); err != nil {
return fmt.Errorf("games: release seat claim: %w", err)
}
if err := award(tx, l.MatrixUser, l.Amount, now); err != nil {
return err
}
for _, h := range l.Audit {
if err := recordHand(tx, h, now); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("games: commit leave: %w", err)
}
return nil
}
// TableOf reports which table a player is sitting at, if any. Read off the
// occupancy claim, so it agrees with the cash-out check by construction.
func TableOf(user string) (string, error) {
var id sql.NullString
err := Get().QueryRow(
`SELECT table_id FROM game_live_hands WHERE matrix_user = ?`, user,
).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNoLiveHand
}
if err != nil {
return "", fmt.Errorf("games: table of: %w", err)
}
return id.String, nil
}
// CloseTable deletes a table nobody is sitting at. Called when the last human
// gets up: a felt with six bots on it and nobody watching is not a game, it is a
// row that the lobby would advertise forever.
func CloseTable(id string) error {
tx, err := Get().Begin()
if err != nil {
return fmt.Errorf("games: begin close table: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
var humans int
if err := tx.QueryRow(
`SELECT COUNT(*) FROM game_seats WHERE table_id = ? AND matrix_user IS NOT NULL`, id,
).Scan(&humans); err != nil {
return fmt.Errorf("games: count humans: %w", err)
}
if humans > 0 {
return nil // somebody is still playing; the table stays
}
for _, q := range []string{
`DELETE FROM game_seats WHERE table_id = ?`,
`DELETE FROM game_chat WHERE table_id = ?`,
`DELETE FROM game_tables WHERE id = ?`,
} {
if _, err := tx.Exec(q, id); err != nil {
return fmt.Errorf("games: close table: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("games: commit close table: %w", err)
}
return nil
}
// ---- the turn clock --------------------------------------------------------
// TableRef is a table the clock has found expired: which one, and at what version
// it was seen.
//
// The version is the point. The clock acts only if it is *still* that version by
// the time it takes the lock, because otherwise: Bob's raise lands in the same
// second his clock expires, the action passes to Cara, and the clock — still
// holding its scan-time belief that the seat to act has run out of time — folds
// Cara, who had twenty-five seconds left. That is a one-second window that recurs
// on every single turn of every hand.
type TableRef struct {
ID string
Version int64
}
// DueTables lists the tables whose clock has run out.
//
// It closes the rows before returning, and it must: the caller is about to take a
// table lock and open a transaction, and holding a *sql.Rows across that means
// holding the only connection in the pool while waiting for it. That is not a
// slow query, it is a deadlock.
func DueTables(now int64) ([]TableRef, error) {
rows, err := Get().Query(
`SELECT id, version FROM game_tables WHERE deadline > 0 AND deadline <= ?`, now)
if err != nil {
return nil, fmt.Errorf("games: due tables: %w", err)
}
defer rows.Close()
var out []TableRef
for rows.Next() {
var r TableRef
if err := rows.Scan(&r.ID, &r.Version); err != nil {
return nil, fmt.Errorf("games: scan due table: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// PushDeadlines shoves every live clock out by a grace period. Called once on
// boot: a deploy takes a table's clock with it, and without this the first tick
// after a restart wakes up to find every deadline in the casino already expired
// and auto-folds all of them at once.
func PushDeadlines(grace int64) error {
if _, err := Get().Exec(
`UPDATE game_tables SET deadline = ? WHERE deadline > 0 AND deadline < ?`,
nowUnix()+grace, nowUnix()+grace,
); err != nil {
return fmt.Errorf("games: push deadlines: %w", err)
}
return nil
}
// ---- chat ------------------------------------------------------------------
// ChatLine is one thing somebody said at the felt.
type ChatLine struct {
ID int64 `json:"id"`
HandNo int64 `json:"hand_no"`
Name string `json:"name"`
Body string `json:"body"`
SaidAt int64 `json:"said_at"`
// Mine is filled in by the web layer, per reader. It is not in the database.
Mine bool `json:"mine,omitempty"`
}
// MaxChatLen is where a message stops. Long enough for a table read, short enough
// that nobody pastes a novel onto the felt.
const MaxChatLen = 240
// Say records a line of chat and returns it. Its hand_no is stamped from the
// table, which is what makes the log answer the only question chat at a money
// table ever really raises: what was said, during which hand.
func Say(tableID, user, name, body string) (ChatLine, error) {
body = strings.TrimSpace(body)
if body == "" {
return ChatLine{}, ErrBadAmount
}
if len(body) > MaxChatLen {
body = body[:MaxChatLen]
}
var handNo int64
if err := Get().QueryRow(`SELECT hand_no FROM game_tables WHERE id = ?`, tableID).Scan(&handNo); errors.Is(err, sql.ErrNoRows) {
return ChatLine{}, ErrNoSuchTable
} else if err != nil {
return ChatLine{}, fmt.Errorf("games: chat hand no: %w", err)
}
now := nowUnix()
res, err := Get().Exec(
`INSERT INTO game_chat (table_id, hand_no, matrix_user, name, body, said_at)
VALUES (?, ?, ?, ?, ?, ?)`,
tableID, handNo, user, name, body, now,
)
if err != nil {
return ChatLine{}, fmt.Errorf("games: say: %w", err)
}
id, _ := res.LastInsertId()
return ChatLine{ID: id, HandNo: handNo, Name: name, Body: body, SaidAt: now}, nil
}
// Chat reads the last few lines said at a table, oldest first.
func Chat(tableID string, limit int) ([]ChatLine, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := Get().Query(
`SELECT id, hand_no, name, body, said_at FROM game_chat
WHERE table_id = ? ORDER BY id DESC LIMIT ?`, tableID, limit)
if err != nil {
return nil, fmt.Errorf("games: chat: %w", err)
}
defer rows.Close()
var out []ChatLine
for rows.Next() {
var c ChatLine
if err := rows.Scan(&c.ID, &c.HandNo, &c.Name, &c.Body, &c.SaidAt); err != nil {
return nil, fmt.Errorf("games: scan chat: %w", err)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Read newest-first so the LIMIT takes the right end; hand them back in the
// order they were said.
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, nil
}