oficina / sheets Public

Skeleton: the platform contract and a health check

Module amadan.net/oficina/sheets, pinned to rastrillo v0.20.0 (v0.19.0
is not on the proxy as of today). internal/sheets.New(db *gorm.DB)
and (*App).Router() are the seam every later task mounts on; the only
route so far is GET /healthz, TDD'd against a nil *gorm.DB.

cmd/sheets/main.go wires the app's own db.Open (rastrillo/db, for
*gorm.DB) into rastrillo.Resolve+Serve rather than Run: rastrillo
v0.20.0's actual Resolve/Run signatures (Resolve(Options) (Options,
error); Run(Options) error; no bare handler argument) differ from the
plan's from-memory sketch, and SKILL.md is explicit that an app
opening its own database handle uses Resolve+Serve, not Run, to avoid
opening the DB file twice. GET /healthz and GET /api/version above the
app's own mux, plus the --socket/--db activation contract (no $PORT),
come from Serve itself.

CGO_ENABLED=0 go build/vet/test all pass; smoke-tested the built
binary over a real unix socket.
Paul Campbell 2e676e5893ea460a856ee385ccc7279d1047e055 parent c734d37
5 files changed, +211 −0
  • cmd/sheets/main.go +68 −0
  • go.mod +26 −0
  • go.sum +73 −0
  • internal/sheets/app.go +27 −0
  • internal/sheets/app_test.go +17 −0
diff --git a/cmd/sheets/main.go b/cmd/sheets/main.go
new file mode 100644
index 0000000..696ace1
--- /dev/null
+++ b/cmd/sheets/main.go
@@ -0,0 +1,68 @@
+// Command sheets is the Sheets instance binary.
+//
+// The platform activation contract has no $PORT: the platform (or a
+// developer) invokes this binary as
+//
+// sheets --socket <path> --db <path>
+//
+// and rastrillo.Resolve/Serve own that contract end to end — activation
+// argv, LISTEN_FDS, $STATE_DIRECTORY, GET /healthz, GET /api/version,
+// SIGTERM drain, baseline security headers. This file only wires the
+// app's own *gorm.DB handle (via rastrillo's db package) into
+// sheets.New, because the app needs GORM, not the raw *sql.DB
+// rastrillo.Options.Router would hand back.
+//
+// rastrillo.Resolve/Run's shape at v0.20.0 differs from a zero-argument
+// Resolve()/Run(handler) pair: Resolve takes and returns an Options, and
+// an app that opens its own database handle (as this one does, for
+// gorm) uses Resolve+Serve directly rather than Run — see
+// $(go env GOMODCACHE)/github.com/carlosframework/rastrillo@v0.20.0/SKILL.md
+// and run.go's doc comment on Resolve.
+package main
+
+import (
+ "log/slog"
+ "net/http"
+ "os"
+
+ "github.com/carlosframework/rastrillo"
+ rdb "github.com/carlosframework/rastrillo/db"
+
+ "amadan.net/oficina/sheets/internal/sheets"
+)
+
+func main() {
+ logger := slog.Default()
+
+ opts, err := rastrillo.Resolve(rastrillo.Options{Logger: logger})
+ if err != nil {
+ logger.Error("resolve", "err", err)
+ os.Exit(1)
+ }
+
+ d, err := rdb.Open(opts.DBPath, logger)
+ if err != nil {
+ logger.Error("open db", "err", err)
+ os.Exit(1)
+ }
+ defer d.Close()
+
+ app := sheets.New(d.G)
+
+ // rastrillo.Options.Mux wants a *http.ServeMux; the app's own
+ // router is a chi.Router (an http.Handler), so it mounts under one.
+ mux := http.NewServeMux()
+ mux.Handle("/", app.Router())
+ opts.Mux = mux
+
+ // The app opened its own database handle above (db.Open already
+ // Pings eagerly, which satisfies Serve's boot-materialization duty
+ // for a hibernate route's activator), so DBPath is blanked here —
+ // otherwise Serve would open a second connection to the same file.
+ opts.DBPath = ""
+
+ if err := rastrillo.Serve(opts); err != nil {
+ logger.Error("serve", "err", err)
+ os.Exit(1)
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..8dc0d7a
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,26 @@
+module amadan.net/oficina/sheets
+
+go 1.26.5
+
+require (
+ github.com/carlosframework/rastrillo v0.20.0
+ github.com/go-chi/chi/v5 v5.3.2
+ gorm.io/gorm v1.31.2
+)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/text v0.20.0 // indirect
+ gorm.io/plugin/dbresolver v1.6.2 // indirect
+ modernc.org/libc v1.74.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+ modernc.org/sqlite v1.55.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..7d0ab14
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,73 @@
+github.com/carlosframework/rastrillo v0.20.0 h1:FrLru/36zxjf1+vPS/Jd5IVjgJy2OHsrMbvt38qvReU=
+github.com/carlosframework/rastrillo v0.20.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
+github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
+github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
+github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+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/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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
+golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
+gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
+gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
+gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
+modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
+modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
+modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
+modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
+modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/sheets/app.go b/internal/sheets/app.go
new file mode 100644
index 0000000..5a4b266
--- /dev/null
+++ b/internal/sheets/app.go
@@ -0,0 +1,27 @@
+// Package sheets is the Sheets instance app: one process, one SQLite file,
+// one team.
+package sheets
+
+import (
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+)
+
+// App holds the wiring. One per process.
+type App struct {
+ db *gorm.DB
+}
+
+// New builds an App. db may be nil in tests that do not touch storage.
+func New(db *gorm.DB) *App { return &App{db: db} }
+
+// Router returns the mounted handler.
+func (a *App) Router() http.Handler {
+ r := chi.NewRouter()
+ r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ return r
+}
diff --git a/internal/sheets/app_test.go b/internal/sheets/app_test.go
new file mode 100644
index 0000000..29905ae
--- /dev/null
+++ b/internal/sheets/app_test.go
@@ -0,0 +1,17 @@
+package sheets
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestHealthz(t *testing.T) {
+ app := New(nil)
+ req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
+ rec := httptest.NewRecorder()
+ app.Router().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("healthz = %d, want 200", rec.Code)
+ }
+}