feat(retention): felis-api 定时清理过期会话、验证码、挑战、绑定码等表,审计按 [audit] retention 保留,新增 felis db audit-export 导出归档

This commit is contained in:
Lemon-miaow committed 2026-09-25 15:53:34 +08:00
1 parent 38288e1c60
commit 084ba1ed9e
9 files changed
+695 -21

No files matched your search

+63
View File
@@ -9,7 +9,9 @@ import (
"net"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/BurntSushi/toml"
)
@@ -25,6 +27,7 @@ type Config struct {
Archive ArchiveConfig `toml:"archive"`
Offsite OffsiteConfig `toml:"offsite"`
SMTP SMTPConfig `toml:"smtp"`
Audit AuditConfig `toml:"audit"`
// AuthSources is the [[auth_source]] array-of-tables: the third-party Yggdrasil
// roots the Felis-nano hasJoined multiplexer federates over, in priority order
// (config order = priority, so array-of-tables not a map — a map would lose order
@@ -256,6 +259,63 @@ type ArchiveS3Config struct {
SecretKeyRef string `toml:"secret_key_ref"`
}
// AuditConfig is the [audit] table. Retention is how long felis-api keeps audit
// rows before deleting them ("365d", "18mo", or "forever" to keep every row);
// empty means DefaultAuditRetention. Export what must outlive it with
// `felis db audit-export` first.
type AuditConfig struct {
Retention string `toml:"retention"`
}
// DefaultAuditRetention keeps a year of audit rows; MinAuditRetention is the
// shortest an install may set, since the manual-backup cooldown and an incident
// investigation both read recent rows.
const (
DefaultAuditRetention = 365 * 24 * time.Hour
MinAuditRetention = 30 * 24 * time.Hour
)
// RetentionPeriod resolves Retention: 0 keeps every row.
func (a AuditConfig) RetentionPeriod() (time.Duration, error) {
switch v := strings.TrimSpace(a.Retention); v {
case "":
return DefaultAuditRetention, nil
case "forever":
return 0, nil
default:
d, err := ParseSpan(v)
if err != nil {
return 0, fmt.Errorf("config: [audit] retention %q must be a span such as 365d or 18mo, or forever", a.Retention)
}
if d < MinAuditRetention {
return 0, fmt.Errorf("config: [audit] retention %q is shorter than the 30d minimum", a.Retention)
}
return d, nil
}
}
// ParseSpan parses the human spans felis.toml uses for retention periods:
// "3mo" (months of 30 days), "15d" (days), or any time.ParseDuration unit ("12h").
func ParseSpan(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
switch {
case strings.HasSuffix(s, "mo"):
n, err := strconv.Atoi(strings.TrimSuffix(s, "mo"))
if err != nil {
return 0, err
}
return time.Duration(n) * 30 * 24 * time.Hour, nil
case strings.HasSuffix(s, "d"):
n, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
if err != nil {
return 0, err
}
return time.Duration(n) * 24 * time.Hour, nil
default:
return time.ParseDuration(s)
}
}
// OffsiteConfig is the [offsite] table: the S3-compatible bucket, away from
// this machine, that `felis offsite sync` (felis-offsite.timer on the host)
// copies every world archive and the newest database bundles into, encrypted
@@ -488,6 +548,9 @@ func (c *Config) Validate() error {
if err := c.Offsite.validate(); err != nil {
return err
}
if _, err := c.Audit.RetentionPeriod(); err != nil {
return err
}
if h := c.Auth.ClientIPHeader; strings.ContainsAny(h, " :\t\r\n") {
return fmt.Errorf("config: [auth] client_ip_header %q must be a bare header name such as CF-Connecting-IP or X-Forwarded-For", h)
}
+48
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"felis.lolicon.best/internal/config"
)
@@ -695,3 +696,50 @@ prefix = "site-a"
}
}
}
// TestAuditRetention: [audit] retention resolves to a year when unset, to 0 for
// "forever", and to the named span otherwise; a span under 30 days or one that
// does not parse fails at load.
func TestAuditRetention(t *testing.T) {
const head = `
[server]
root_domain = "mc.example.net"
[database]
url = "postgres://felis@db/felis"
`
const day = 24 * time.Hour
for _, tc := range []struct {
name, table string
want time.Duration
}{
{"unset", "", 365 * day},
{"empty", "[audit]\nretention = \"\"\n", 365 * day},
{"forever", "[audit]\nretention = \"forever\"\n", 0},
{"days", "[audit]\nretention = \"90d\"\n", 90 * day},
{"months", "[audit]\nretention = \"18mo\"\n", 540 * day},
{"hours", "[audit]\nretention = \"720h\"\n", 30 * day},
{"exactly the minimum", "[audit]\nretention = \"30d\"\n", 30 * day},
} {
t.Run(tc.name, func(t *testing.T) {
cfg, err := config.Load(writeTOML(t, head+tc.table))
if err != nil {
t.Fatalf("Load: %v", err)
}
got, err := cfg.Audit.RetentionPeriod()
if err != nil || got != tc.want {
t.Fatalf("RetentionPeriod = %v, %v; want %v", got, err, tc.want)
}
})
}
for _, tc := range []struct{ value, wantErr string }{
{"29d", `config: [audit] retention "29d" is shorter than the 30d minimum`},
{"719h", `config: [audit] retention "719h" is shorter than the 30d minimum`},
{"a year", `config: [audit] retention "a year" must be a span such as 365d or 18mo, or forever`},
{"Forever", `config: [audit] retention "Forever" must be a span such as 365d or 18mo, or forever`},
} {
_, err := config.Load(writeTOML(t, head+"[audit]\nretention = \""+tc.value+"\"\n"))
if err == nil || err.Error() != tc.wantErr {
t.Errorf("retention %q: err = %v, want %q", tc.value, err, tc.wantErr)
}
}
}
+215
View File
@@ -0,0 +1,215 @@
//go:build pgint
package pgint
import (
"bytes"
"context"
"regexp"
"testing"
"time"
"felis.lolicon.best/internal/retention"
)
func mustExec(t *testing.T, q string, args ...any) {
t.Helper()
if _, err := db.Exec(q, args...); err != nil {
t.Fatalf("%s: %v", q, err)
}
}
func rowExists(t *testing.T, q string, args ...any) bool {
t.Helper()
var ok bool
if err := db.QueryRow(`SELECT EXISTS (`+q+`)`, args...).Scan(&ok); err != nil {
t.Fatalf("%s: %v", q, err)
}
return ok
}
// Prune deletes each sign-in row a month after it was spent (expired, used or
// revoked) and keeps the ones spent more recently and the live ones; audit rows
// go once they are older than the policy's retention.
func TestPruneDropsRowsSpentLongerThanGrace(t *testing.T) {
ctx := context.Background()
now := mustNow()
old := now.Add(-31 * 24 * time.Hour) // spent past the 30-day grace
recent := now.Add(-29 * 24 * time.Hour) // spent inside it
future := now.Add(time.Hour)
u := newUser(t, "user", "prune")
src1, src2, src3 := newUser(t, "user", "mig1"), newUser(t, "user", "mig2"), newUser(t, "user", "mig3")
tag := suffix(t)
id := func(s string) string { return s + "-" + tag }
for _, r := range []struct {
name string
expires, used, rev any
}{
{"s-expired-old", old, nil, nil},
{"s-revoked-old", future, nil, old},
{"s-expired-recent", recent, nil, nil},
{"s-revoked-recent", future, nil, recent},
{"s-live", future, nil, nil},
} {
mustExec(t, `INSERT INTO sessions (token_hash, user_id, expires_at, revoked_at) VALUES ($1, $2, $3, $4)`,
id(r.name), u.ID, r.expires, r.rev)
}
for _, r := range []struct {
name string
expires, used any
}{
{"expired-old", old, nil},
{"used-old", future, old},
{"expired-recent", recent, nil},
{"used-recent", future, recent},
{"live", future, nil},
} {
mustExec(t, `INSERT INTO email_otps (id, user_id, email, code_hash, purpose, expires_at, consumed_at)
VALUES ($1, $2, '[email protected]', 'h', 'login_email', $3, $4)`, id("otp-"+r.name), u.ID, r.expires, r.used)
mustExec(t, `INSERT INTO webauthn_challenges (id, user_id, purpose, session_data, expires_at, consumed_at)
VALUES ($1, $2, 'passkey_register', '\x00', $3, $4)`, id("wc-"+r.name), u.ID, r.expires, r.used)
mustExec(t, `INSERT INTO webauthn_discoverable_challenges (id, session_data, expires_at, consumed_at)
VALUES ($1, '\x00', $2, $3)`, id("wd-"+r.name), r.expires, r.used)
mustExec(t, `INSERT INTO setup_tokens (token_hash, user_id, expires_at, consumed_at) VALUES ($1, $2, $3, $4)`,
id("st-"+r.name), u.ID, r.expires, r.used)
mustExec(t, `INSERT INTO op_login_requests (id, user_id, email, expires_at, consumed_at)
VALUES ($1, $2, '[email protected]', $3, $4)`, id("op-"+r.name), u.ID, r.expires, r.used)
}
for _, r := range []struct {
name string
expires time.Time
}{{"lc-old", old}, {"lc-recent", recent}, {"lc-live", future}} {
mustExec(t, `INSERT INTO account_link_codes (code, mc_uuid, expires_at) VALUES ($1, $2, $3)`,
id(r.name), testUUID(t), r.expires)
}
// A migration abandoned a month ago goes; one touched recently stays, and a
// redeemed one is the record of the move and stays however old it is.
mustExec(t, `INSERT INTO account_migrations (id, source_user_id, state, created_at, updated_at) VALUES ($1, $2, 'initiated', $3, $3)`,
id("mig-stale"), src1.ID, old)
mustExec(t, `INSERT INTO account_migrations (id, source_user_id, state, created_at, updated_at) VALUES ($1, $2, 'confirmed', $3, $4)`,
id("mig-fresh"), src2.ID, old, recent)
mustExec(t, `INSERT INTO account_migrations (id, source_user_id, state, redeemed_at, created_at, updated_at) VALUES ($1, $2, 'redeemed', $3, $3, $3)`,
id("mig-redeemed"), src3.ID, old)
mustExec(t, `INSERT INTO otp_failure_windows (user_id, purpose, window_start, failures) VALUES ($1, 'login_email', $2, 3)`, u.ID, old)
mustExec(t, `INSERT INTO otp_failure_windows (user_id, purpose, window_start, failures) VALUES ($1, 'onboard_email', $2, 3)`, u.ID, recent)
mustExec(t, `INSERT INTO audit_logs (actor, source, action, request_id, created_at) VALUES ('pgint', 'external', 'prune.test', $1, $2)`,
id("audit-61d"), now.Add(-61*24*time.Hour))
mustExec(t, `INSERT INTO audit_logs (actor, source, action, request_id, created_at) VALUES ('pgint', 'external', 'prune.test', $1, $2)`,
id("audit-59d"), now.Add(-59*24*time.Hour))
if _, err := retention.Prune(ctx, db, now, retention.Policy{Audit: 60 * 24 * time.Hour}); err != nil {
t.Fatalf("Prune: %v", err)
}
for _, c := range []struct {
q, key string
want bool
}{
{`SELECT 1 FROM sessions WHERE token_hash = $1`, "s-expired-old", false},
{`SELECT 1 FROM sessions WHERE token_hash = $1`, "s-revoked-old", false},
{`SELECT 1 FROM sessions WHERE token_hash = $1`, "s-expired-recent", true},
{`SELECT 1 FROM sessions WHERE token_hash = $1`, "s-revoked-recent", true},
{`SELECT 1 FROM sessions WHERE token_hash = $1`, "s-live", true},
{`SELECT 1 FROM account_link_codes WHERE code = $1`, "lc-old", false},
{`SELECT 1 FROM account_link_codes WHERE code = $1`, "lc-recent", true},
{`SELECT 1 FROM account_link_codes WHERE code = $1`, "lc-live", true},
{`SELECT 1 FROM account_migrations WHERE id = $1`, "mig-stale", false},
{`SELECT 1 FROM account_migrations WHERE id = $1`, "mig-fresh", true},
{`SELECT 1 FROM account_migrations WHERE id = $1`, "mig-redeemed", true},
{`SELECT 1 FROM audit_logs WHERE request_id = $1`, "audit-61d", false},
{`SELECT 1 FROM audit_logs WHERE request_id = $1`, "audit-59d", true},
} {
if got := rowExists(t, c.q, id(c.key)); got != c.want {
t.Errorf("%s: present = %v, want %v", c.key, got, c.want)
}
}
for _, table := range []struct{ name, q, prefix string }{
{"email_otps", `SELECT 1 FROM email_otps WHERE id = $1`, "otp-"},
{"webauthn_challenges", `SELECT 1 FROM webauthn_challenges WHERE id = $1`, "wc-"},
{"webauthn_discoverable_challenges", `SELECT 1 FROM webauthn_discoverable_challenges WHERE id = $1`, "wd-"},
{"setup_tokens", `SELECT 1 FROM setup_tokens WHERE token_hash = $1`, "st-"},
{"op_login_requests", `SELECT 1 FROM op_login_requests WHERE id = $1`, "op-"},
} {
for _, c := range []struct {
name string
want bool
}{{"expired-old", false}, {"used-old", false}, {"expired-recent", true}, {"used-recent", true}, {"live", true}} {
if got := rowExists(t, table.q, id(table.prefix+c.name)); got != c.want {
t.Errorf("%s %s: present = %v, want %v", table.name, c.name, got, c.want)
}
}
}
if rowExists(t, `SELECT 1 FROM otp_failure_windows WHERE user_id = $1 AND purpose = 'login_email'`, u.ID) {
t.Error("an OTP failure window that began a month ago survived")
}
if !rowExists(t, `SELECT 1 FROM otp_failure_windows WHERE user_id = $1 AND purpose = 'onboard_email'`, u.ID) {
t.Error("an OTP failure window inside the grace was deleted")
}
}
// With no audit retention every audit row stays, and a table with more spent rows
// than one DELETE takes is emptied of them in the same run.
func TestPruneKeepsAuditForeverAndDrainsLargeBacklogs(t *testing.T) {
ctx := context.Background()
now := mustNow()
tag := suffix(t)
mustExec(t, `INSERT INTO audit_logs (actor, source, action, request_id, created_at) VALUES ('pgint', 'external', 'prune.test', $1, $2)`,
"ancient-"+tag, now.Add(-3000*24*time.Hour))
mustExec(t, `INSERT INTO account_link_codes (code, mc_uuid, expires_at)
SELECT $1 || n, gen_random_uuid(), $2 FROM generate_series(1, 5003) AS n`, "bulk-"+tag+"-", now.Add(-40*24*time.Hour))
res, err := retention.Prune(ctx, db, now, retention.Policy{})
if err != nil {
t.Fatalf("Prune: %v", err)
}
if !rowExists(t, `SELECT 1 FROM audit_logs WHERE request_id = $1`, "ancient-"+tag) {
t.Error("an audit row was deleted with retention off")
}
if _, ok := res["audit_logs"]; ok {
t.Errorf("audit_logs was pruned with retention off: %v", res)
}
var left int
if err := db.QueryRow(`SELECT count(*) FROM account_link_codes WHERE code LIKE $1`, "bulk-"+tag+"-%").Scan(&left); err != nil {
t.Fatal(err)
}
if left != 0 {
t.Errorf("%d of 5003 spent bind codes left after one run", left)
}
if res["account_link_codes"] < 5003 {
t.Errorf("account_link_codes deleted = %d, want at least 5003", res["account_link_codes"])
}
}
var idRE = regexp.MustCompile(`"id":\d+`)
// ExportAudit writes the rows inside [since, until) as JSON lines, oldest first,
// with the optional columns left out when empty.
func TestExportAuditWritesTheWindow(t *testing.T) {
ctx := context.Background()
u := newUser(t, "user", "export")
tag := suffix(t)
base := time.Date(2031, 3, 4, 0, 0, 0, 0, time.UTC)
mustExec(t, `INSERT INTO audit_logs (actor, source, action, request_id, created_at) VALUES ('before', 'external', 'x.before', $1, $2)`,
"exp-"+tag, base.Add(-time.Second))
mustExec(t, `INSERT INTO audit_logs (actor, actor_user_id, source, action, server_name, request_id, client_ip, user_agent, payload, created_at)
VALUES ('[email protected]', $1, 'external', 'server.patch', 'survival', $2, '192.0.2.7', 'curl/8', '{"display_name":"Survival"}', $3)`,
u.ID, "exp-"+tag, base.Add(2*time.Hour))
mustExec(t, `INSERT INTO audit_logs (actor, source, action, created_at) VALUES ('internal', 'internal:velocity', 'server.wake', $1)`,
base.Add(time.Hour))
mustExec(t, `INSERT INTO audit_logs (actor, source, action, request_id, created_at) VALUES ('after', 'external', 'x.after', $1, $2)`,
"exp-"+tag, base.Add(24*time.Hour))
var buf bytes.Buffer
n, err := retention.ExportAudit(ctx, db, base, base.Add(24*time.Hour), &buf)
if err != nil {
t.Fatalf("ExportAudit: %v", err)
}
// Row ids are serial; blank them to compare the rest literally.
got := idRE.ReplaceAllString(buf.String(), `"id":0`)
want := `{"id":0,"created_at":"2031-03-04T01:00:00Z","actor":"internal","source":"internal:velocity","action":"server.wake"}` + "\n" +
`{"id":0,"created_at":"2031-03-04T02:00:00Z","actor":"[email protected]","actor_user_id":"` + u.ID + `","source":"external","action":"server.patch","server_name":"survival","request_id":"exp-` + tag + `","client_ip":"192.0.2.7","user_agent":"curl/8","payload":{"display_name":"Survival"}}` + "\n"
if n != 2 || got != want {
t.Fatalf("ExportAudit wrote %d rows:\n%s\nwant 2:\n%s", n, got, want)
}
}
+203
View File
@@ -0,0 +1,203 @@
// Package retention deletes the control-plane rows that stopped mattering, so
// the sign-in tables and the audit trail do not grow for as long as an install
// runs.
//
// Every sign-in door leaves a row behind: a session per login, an email code or
// passkey challenge per attempt, a bind code per /felis link, an op-login request
// per staff sign-in, a setup token per invite. Each is spent once it expires,
// is used, or is revoked, and a spent row only holds a hash nothing accepts any
// more. Prune drops those rows Grace after they were spent: long enough that an
// incident can still line up a session's device and address with the audit
// trail, short enough that the tables stay the size of what is live.
//
// Audit rows are kept for the install's [audit] retention and then deleted, oldest
// first. `felis db audit-export` (ExportAudit) writes the rows to a file first
// for an install that must keep them longer.
package retention
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"time"
)
// Grace is how long a spent sign-in row outlives the moment it was spent.
const Grace = 30 * 24 * time.Hour
// batch bounds one DELETE, so a first run on an install that never pruned stays
// clear of the pool's statement timeout and holds its row locks briefly.
const batch = 5000
// sweep is one table's spent rows: every statement takes the cutoff as $1 and a
// row limit as $2, and deletes at most $2 rows spent before the cutoff.
type sweep struct {
table string
where string
}
// sweeps name each sign-in table's spent rows. A session is spent once it
// expired or was revoked; a single-use code or challenge once it expired or was
// redeemed; a bind code once it expired (redeeming deletes it); a migration that
// never completed once nobody touched it for Grace (a redeemed one is the record
// of which account moved where and is kept); an OTP failure window once it began
// Grace ago (a window lasts a day).
var sweeps = []sweep{
{"sessions", "expires_at < $1 OR revoked_at < $1"},
{"email_otps", "expires_at < $1 OR consumed_at < $1"},
{"webauthn_challenges", "expires_at < $1 OR consumed_at < $1"},
{"webauthn_discoverable_challenges", "expires_at < $1 OR consumed_at < $1"},
{"setup_tokens", "expires_at < $1 OR consumed_at < $1"},
{"op_login_requests", "expires_at < $1 OR consumed_at < $1"},
{"account_link_codes", "expires_at < $1"},
{"account_migrations", "state <> 'redeemed' AND updated_at < $1"},
{"otp_failure_windows", "window_start < $1"},
}
// Policy is what Prune keeps.
type Policy struct {
// Audit is how long audit rows are kept; 0 keeps every row.
Audit time.Duration
}
// Result counts the rows Prune deleted, by table.
type Result map[string]int64
// Total is the number of rows deleted across all tables.
func (r Result) Total() int64 {
var n int64
for _, v := range r {
n += v
}
return n
}
// Prune deletes the spent sign-in rows and the audit rows past the policy's
// retention. It stops at the first failing table and returns what it deleted
// so far with the error; the next run picks up from there.
func Prune(ctx context.Context, db *sql.DB, now time.Time, p Policy) (Result, error) {
res := Result{}
for _, s := range sweeps {
n, err := deleteBatched(ctx, db, s.table, s.where, now.Add(-Grace))
res[s.table] = n
if err != nil {
return res, fmt.Errorf("prune %s: %w", s.table, err)
}
}
if p.Audit > 0 {
n, err := deleteBatched(ctx, db, "audit_logs", "created_at < $1", now.Add(-p.Audit))
res["audit_logs"] = n
if err != nil {
return res, fmt.Errorf("prune audit_logs: %w", err)
}
}
return res, nil
}
func deleteBatched(ctx context.Context, db *sql.DB, table, where string, cutoff time.Time) (int64, error) {
q := fmt.Sprintf(`DELETE FROM %[1]s WHERE ctid IN (SELECT ctid FROM %[1]s WHERE %[2]s LIMIT $2)`, table, where)
var total int64
for {
r, err := db.ExecContext(ctx, q, cutoff, batch)
if err != nil {
return total, err
}
n, err := r.RowsAffected()
if err != nil {
return total, err
}
total += n
if n < batch {
return total, nil
}
}
}
// Loop runs Prune a minute after it starts and then every interval until ctx
// ends, logging what each run deleted.
func Loop(ctx context.Context, db *sql.DB, p Policy, every time.Duration, log *slog.Logger) {
t := time.NewTimer(time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
res, err := Prune(ctx, db, time.Now(), p)
if err != nil {
log.Error("retention: prune failed", "err", err, "deleted", res.Total())
} else if res.Total() > 0 {
args := []any{"deleted", res.Total()}
for _, s := range sweeps {
if res[s.table] > 0 {
args = append(args, s.table, res[s.table])
}
}
if res["audit_logs"] > 0 {
args = append(args, "audit_logs", res["audit_logs"])
}
log.Info("retention: pruned spent rows", args...)
}
t.Reset(every)
}
}
// AuditRow is one audit_logs row as ExportAudit writes it.
type AuditRow struct {
ID int64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
Actor string `json:"actor"`
ActorUserID string `json:"actor_user_id,omitempty"`
Source string `json:"source"`
Action string `json:"action"`
ServerName string `json:"server_name,omitempty"`
RequestID string `json:"request_id,omitempty"`
ClientIP string `json:"client_ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
Payload json.RawMessage `json:"payload,omitempty"`
}
// ExportAudit writes the audit rows created in [since, until) to w as JSON lines,
// oldest first; a zero bound is open. It returns how many rows it wrote.
func ExportAudit(ctx context.Context, db *sql.DB, since, until time.Time, w io.Writer) (int, error) {
const q = `SELECT id, created_at, actor, COALESCE(actor_user_id, ''), source, action,
COALESCE(server_name, ''), COALESCE(request_id, ''), COALESCE(host(client_ip), ''),
COALESCE(user_agent, ''), COALESCE(payload::text, '')
FROM audit_logs
WHERE ($1::timestamptz IS NULL OR created_at >= $1) AND ($2::timestamptz IS NULL OR created_at < $2)
ORDER BY created_at, id`
rows, err := db.QueryContext(ctx, q, nullTime(since), nullTime(until))
if err != nil {
return 0, err
}
defer rows.Close()
enc := json.NewEncoder(w)
n := 0
for rows.Next() {
var (
r AuditRow
payload string
)
if err := rows.Scan(&r.ID, &r.CreatedAt, &r.Actor, &r.ActorUserID, &r.Source, &r.Action,
&r.ServerName, &r.RequestID, &r.ClientIP, &r.UserAgent, &payload); err != nil {
return n, err
}
r.CreatedAt = r.CreatedAt.UTC()
if payload != "" {
r.Payload = json.RawMessage(payload)
}
if err := enc.Encode(r); err != nil {
return n, err
}
n++
}
return n, rows.Err()
}
func nullTime(t time.Time) sql.NullTime {
return sql.NullTime{Time: t, Valid: !t.IsZero()}
}