From 59c3cf259dfd61907710d2bcf01da4d43ddc826e Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 08:38:54 -0400 Subject: [PATCH] Add store helpers: transactions, ULIDs and time conventions - Store.WithTx runs fn on the writer, committing on nil and rolling back on error, panic or goroutine exit; panics propagate unchanged. - NewID returns a monotonic ULID string (oklog/ulid/v2, crypto/rand), strictly increasing within the process. - Timestamp stores UTC RFC 3339 TEXT at fixed millisecond precision. - LocalDate stores YYYY-MM-DD TEXT and rejects invalid or non-canonical dates. Co-Authored-By: Claude Opus 5.5 --- go.mod | 1 + go.sum | 3 + internal/store/id.go | 27 +++++++++ internal/store/id_test.go | 25 ++++++++ internal/store/time.go | 108 ++++++++++++++++++++++++++++++++++ internal/store/time_test.go | 113 ++++++++++++++++++++++++++++++++++++ internal/store/tx.go | 37 ++++++++++++ internal/store/tx_test.go | 84 +++++++++++++++++++++++++++ 8 files changed, 398 insertions(+) create mode 100644 internal/store/id.go create mode 100644 internal/store/id_test.go create mode 100644 internal/store/time.go create mode 100644 internal/store/time_test.go create mode 100644 internal/store/tx.go create mode 100644 internal/store/tx_test.go diff --git a/go.mod b/go.mod index 3c014a7..a517797 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/go-chi/chi/v5 v5.3.1 + github.com/oklog/ulid/v2 v2.1.2 modernc.org/sqlite v1.59.0 ) diff --git a/go.sum b/go.sum index 1d524f4..3a07785 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,9 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oklog/ulid/v2 v2.1.2 h1:IEclFb9JNvzYA6MW2SCxbLzcHTVsfqm3PrqGQJH5zec= +github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= diff --git a/internal/store/id.go b/internal/store/id.go new file mode 100644 index 0000000..28bafde --- /dev/null +++ b/internal/store/id.go @@ -0,0 +1,27 @@ +package store + +import ( + "crypto/rand" + "sync" + "time" + + "github.com/oklog/ulid/v2" +) + +var ( + idMu sync.Mutex + idEntropy = ulid.Monotonic(rand.Reader, 0) + idLastMS uint64 +) + +// NewID returns a new ULID as its canonical 26-character string. IDs from +// this process sort strictly in creation order, including within one +// millisecond and across a backwards wall-clock step. It panics if the +// monotonic entropy overflows within a millisecond. +func NewID() string { + idMu.Lock() + defer idMu.Unlock() + ms := max(ulid.Timestamp(time.Now()), idLastMS) + idLastMS = ms + return ulid.MustNew(ms, idEntropy).String() +} diff --git a/internal/store/id_test.go b/internal/store/id_test.go new file mode 100644 index 0000000..14f5f7d --- /dev/null +++ b/internal/store/id_test.go @@ -0,0 +1,25 @@ +package store + +import ( + "testing" + + "github.com/oklog/ulid/v2" +) + +func TestNewIDStrictlyIncreasing(t *testing.T) { + const n = 10000 + prev := NewID() + for range n { + id := NewID() + if len(id) != ulid.EncodedSize { + t.Fatalf("NewID() = %q, want %d characters", id, ulid.EncodedSize) + } + if _, err := ulid.ParseStrict(id); err != nil { + t.Fatalf("NewID() = %q: %v", id, err) + } + if id <= prev { + t.Fatalf("NewID() = %q, not after previous %q", id, prev) + } + prev = id + } +} diff --git a/internal/store/time.go b/internal/store/time.go new file mode 100644 index 0000000..00be284 --- /dev/null +++ b/internal/store/time.go @@ -0,0 +1,108 @@ +package store + +import ( + "database/sql/driver" + "errors" + "fmt" + "time" +) + +// timestampLayout is RFC 3339 in UTC with fixed millisecond precision, so +// stored timestamps sort lexicographically in time order. +const timestampLayout = "2006-01-02T15:04:05.000Z" + +// Timestamp is an instant stored as UTC RFC 3339 TEXT with millisecond +// precision. Sub-millisecond precision is dropped on write. Use +// sql.Null[Timestamp] for a nullable column. +type Timestamp struct { + time.Time +} + +// Now returns the current instant as a Timestamp, in UTC at millisecond +// precision, so it equals its stored form. +func Now() Timestamp { + return Timestamp{time.Now().UTC().Truncate(time.Millisecond)} +} + +// String returns the stored TEXT form. +func (t Timestamp) String() string { + return t.UTC().Format(timestampLayout) +} + +// Value implements driver.Valuer. The zero Timestamp is an error. +func (t Timestamp) Value() (driver.Value, error) { + if t.IsZero() { + return nil, errors.New("store: zero Timestamp") + } + return t.String(), nil +} + +// Scan implements sql.Scanner. It accepts only TEXT in the stored form. +func (t *Timestamp) Scan(src any) error { + s, ok := src.(string) + if !ok { + return fmt.Errorf("store: scan Timestamp: want TEXT, got %T", src) + } + parsed, err := time.Parse(timestampLayout, s) + if err != nil { + return fmt.Errorf("store: scan Timestamp %q: %w", s, err) + } + t.Time = parsed + return nil +} + +// localDateLayout is the household-local calendar date form. +const localDateLayout = "2006-01-02" + +// LocalDate is a household-local calendar date with no time or zone, stored +// as YYYY-MM-DD TEXT. The zero LocalDate is invalid and cannot be stored. +type LocalDate struct { + year int + month time.Month + day int +} + +// ParseLocalDate parses a YYYY-MM-DD date, rejecting any other form and any +// date that does not exist on the calendar. +func ParseLocalDate(s string) (LocalDate, error) { + t, err := time.Parse(localDateLayout, s) + if err != nil { + return LocalDate{}, fmt.Errorf("store: invalid date %q: %w", s, err) + } + if t.Format(localDateLayout) != s { + return LocalDate{}, fmt.Errorf("store: invalid date %q: want YYYY-MM-DD", s) + } + return LocalDate{t.Year(), t.Month(), t.Day()}, nil +} + +// String returns the YYYY-MM-DD form. +func (d LocalDate) String() string { + return fmt.Sprintf("%04d-%02d-%02d", d.year, int(d.month), d.day) +} + +// IsZero reports whether d is the zero LocalDate. +func (d LocalDate) IsZero() bool { + return d == LocalDate{} +} + +// Value implements driver.Valuer. The zero LocalDate is an error. +func (d LocalDate) Value() (driver.Value, error) { + if d.IsZero() { + return nil, errors.New("store: zero LocalDate") + } + return d.String(), nil +} + +// Scan implements sql.Scanner. It accepts only YYYY-MM-DD TEXT. +func (d *LocalDate) Scan(src any) error { + s, ok := src.(string) + if !ok { + return fmt.Errorf("store: scan LocalDate: want TEXT, got %T", src) + } + parsed, err := ParseLocalDate(s) + if err != nil { + return fmt.Errorf("store: scan LocalDate: %w", err) + } + *d = parsed + return nil +} diff --git a/internal/store/time_test.go b/internal/store/time_test.go new file mode 100644 index 0000000..5c77225 --- /dev/null +++ b/internal/store/time_test.go @@ -0,0 +1,113 @@ +package store + +import ( + "context" + "testing" + "time" +) + +func openTypesTestStore(t *testing.T) *Store { + t.Helper() + s := openTestStore(t) + if _, err := s.Writer.Exec("CREATE TABLE v (val TEXT)"); err != nil { + t.Fatal(err) + } + return s +} + +// roundTrip writes in through the writer, then reads it back into out and +// returns the stored SQLite type and raw text. +func roundTrip(t *testing.T, s *Store, in, out any) (typ, raw string) { + t.Helper() + ctx := context.Background() + if _, err := s.Writer.ExecContext(ctx, "DELETE FROM v"); err != nil { + t.Fatal(err) + } + if _, err := s.Writer.ExecContext(ctx, "INSERT INTO v (val) VALUES (?)", in); err != nil { + t.Fatal(err) + } + if err := s.Reader.QueryRowContext(ctx, "SELECT typeof(val), val, val FROM v").Scan(&typ, &raw, out); err != nil { + t.Fatal(err) + } + return typ, raw +} + +func TestTimestampRoundTrip(t *testing.T) { + s := openTypesTestStore(t) + zone := time.FixedZone("EDT", -4*60*60) + in := Timestamp{time.Date(2026, 10, 3, 8, 37, 5, 123456789, zone)} + + var out Timestamp + typ, raw := roundTrip(t, s, in, &out) + if typ != "text" || raw != "2026-10-03T12:37:05.123Z" { + t.Fatalf("stored %s %q, want text %q", typ, raw, "2026-10-03T12:37:05.123Z") + } + if want := in.Truncate(time.Millisecond); !out.Equal(want) { + t.Fatalf("read back %v, want %v", out, want) + } + if out.Location() != time.UTC { + t.Fatalf("read back location %v, want UTC", out.Location()) + } + + now := Now() + roundTrip(t, s, now, &out) + if out != now { + t.Fatalf("Now() round trip = %#v, want %#v", out, now) + } +} + +func TestTimestampRejects(t *testing.T) { + if _, err := (Timestamp{}).Value(); err == nil { + t.Error("zero Timestamp Value succeeded") + } + for _, src := range []any{nil, int64(0), []byte("2026-10-03T12:37:05.123Z"), "2026-10-03T12:37:05Z", "2026-10-03T08:37:05.123-04:00", "garbage"} { + var ts Timestamp + if err := ts.Scan(src); err == nil { + t.Errorf("Scan(%#v) succeeded", src) + } + } +} + +func TestLocalDateRoundTrip(t *testing.T) { + s := openTypesTestStore(t) + for _, str := range []string{"2026-10-03", "2028-02-29", "0001-01-01", "9999-12-31"} { + in, err := ParseLocalDate(str) + if err != nil { + t.Fatalf("ParseLocalDate(%q): %v", str, err) + } + if in.String() != str { + t.Fatalf("String() = %q, want %q", in.String(), str) + } + var out LocalDate + typ, raw := roundTrip(t, s, in, &out) + if typ != "text" || raw != str { + t.Fatalf("stored %s %q, want text %q", typ, raw, str) + } + if out != in { + t.Fatalf("read back %v, want %v", out, in) + } + } +} + +func TestLocalDateRejectsInvalid(t *testing.T) { + for _, str := range []string{ + "", "2026-02-30", "2026-02-29", "2026-13-01", "2026-00-10", "2026-10-00", + "2026-10-32", "2026-1-03", "2026-10-3", "26-10-03", "2026/10/03", + "2026-10-03T00:00:00Z", " 2026-10-03", "20261003", "not-a-date", + } { + if d, err := ParseLocalDate(str); err == nil { + t.Errorf("ParseLocalDate(%q) = %v, want error", str, d) + } + var d LocalDate + if err := d.Scan(str); err == nil { + t.Errorf("Scan(%q) succeeded", str) + } + } + if _, err := (LocalDate{}).Value(); err == nil { + t.Error("zero LocalDate Value succeeded") + } + var d LocalDate + if err := d.Scan(nil); err == nil { + t.Error("Scan(nil) succeeded") + } +} diff --git a/internal/store/tx.go b/internal/store/tx.go new file mode 100644 index 0000000..bb53661 --- /dev/null +++ b/internal/store/tx.go @@ -0,0 +1,37 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// WithTx runs fn in a transaction on the writer. It commits when fn returns +// nil and rolls back when fn returns an error, panics or exits the goroutine. +// A panic is not recovered: it propagates unchanged after the rollback. +func (s *Store) WithTx(ctx context.Context, fn func(tx *sql.Tx) error) error { + tx, err := s.Writer.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("store: begin transaction: %w", err) + } + finished := false + defer func() { + if !finished { + _ = tx.Rollback() // fn panicked; the panic carries on regardless + } + }() + + if err := fn(tx); err != nil { + finished = true + if rbErr := tx.Rollback(); rbErr != nil { + return errors.Join(err, fmt.Errorf("store: roll back transaction: %w", rbErr)) + } + return err + } + finished = true + if err := tx.Commit(); err != nil { + return fmt.Errorf("store: commit transaction: %w", err) + } + return nil +} diff --git a/internal/store/tx_test.go b/internal/store/tx_test.go new file mode 100644 index 0000000..a8c196e --- /dev/null +++ b/internal/store/tx_test.go @@ -0,0 +1,84 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "testing" +) + +func openTxTestStore(t *testing.T) *Store { + t.Helper() + s := openTestStore(t) + if _, err := s.Writer.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); err != nil { + t.Fatal(err) + } + return s +} + +func countRows(t *testing.T, s *Store) int { + t.Helper() + var n int + if err := s.Reader.QueryRow("SELECT count(*) FROM t").Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +func insertRow(tx *sql.Tx) error { + _, err := tx.Exec("INSERT INTO t (id) VALUES (1)") + return err +} + +func TestWithTxCommitsOnNil(t *testing.T) { + s := openTxTestStore(t) + if err := s.WithTx(context.Background(), insertRow); err != nil { + t.Fatal(err) + } + if n := countRows(t, s); n != 1 { + t.Fatalf("rows after commit = %d, want 1", n) + } +} + +func TestWithTxRollsBackOnError(t *testing.T) { + s := openTxTestStore(t) + errBoom := errors.New("boom") + err := s.WithTx(context.Background(), func(tx *sql.Tx) error { + if err := insertRow(tx); err != nil { + t.Fatal(err) + } + return errBoom + }) + if !errors.Is(err, errBoom) { + t.Fatalf("WithTx error = %v, want %v", err, errBoom) + } + if n := countRows(t, s); n != 0 { + t.Fatalf("rows after rollback = %d, want 0", n) + } +} + +func TestWithTxRollsBackOnPanic(t *testing.T) { + s := openTxTestStore(t) + const value = "kaboom" + func() { + defer func() { + if p := recover(); p != value { + t.Fatalf("recovered %v, want panic %q to propagate", p, value) + } + }() + _ = s.WithTx(context.Background(), func(tx *sql.Tx) error { + if err := insertRow(tx); err != nil { + t.Fatal(err) + } + panic(value) + }) + t.Fatal("WithTx returned instead of panicking") + }() + if n := countRows(t, s); n != 0 { + t.Fatalf("rows after panic = %d, want 0", n) + } + // The single writer connection must have been released. + if err := s.WithTx(context.Background(), insertRow); err != nil { + t.Fatalf("WithTx after panic: %v", err) + } +}