From 1bc2f27ed1988532b6a893e052dba43e79b6a8dc Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Mon, 9 Feb 2026 15:24:41 +0200 Subject: [PATCH 01/31] feat(data): add shared sql contract tokens and naming --- modkit/data/sqlmodule/tokens.go | 65 ++++++++++++++++++++++++++++ modkit/data/sqlmodule/tokens_test.go | 52 ++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 modkit/data/sqlmodule/tokens.go create mode 100644 modkit/data/sqlmodule/tokens_test.go diff --git a/modkit/data/sqlmodule/tokens.go b/modkit/data/sqlmodule/tokens.go new file mode 100644 index 0000000..cf26d2b --- /dev/null +++ b/modkit/data/sqlmodule/tokens.go @@ -0,0 +1,65 @@ +// Package sqlmodule defines shared SQL contract tokens and dialect values. +package sqlmodule + +import ( + "fmt" + "strings" + "unicode" + + "github.com/go-modkit/modkit/modkit/module" +) + +const ( + // TokenDB resolves the shared SQL database handle provider. + TokenDB module.Token = "database.db" + // TokenDialect resolves the SQL dialect provider. + TokenDialect module.Token = "database.dialect" +) + +// Dialect identifies the SQL engine family for a database provider. +type Dialect string + +const ( + // DialectPostgres identifies PostgreSQL providers. + DialectPostgres Dialect = "postgres" + // DialectSQLite identifies SQLite providers. + DialectSQLite Dialect = "sqlite" + // DialectMySQL identifies MySQL providers. + DialectMySQL Dialect = "mysql" +) + +// Tokens contains provider tokens for a SQL module instance. +type Tokens struct { + DB module.Token + Dialect module.Token +} + +// InvalidNameError reports invalid SQL module instance names. +type InvalidNameError struct { + Name string + Reason string +} + +func (e *InvalidNameError) Error() string { + return fmt.Sprintf("invalid sql module name: %q reason=%s", e.Name, e.Reason) +} + +// NamedTokens returns deterministic tokens for a SQL module instance name. +func NamedTokens(name string) (Tokens, error) { + if name == "" { + return Tokens{DB: TokenDB, Dialect: TokenDialect}, nil + } + + if strings.TrimSpace(name) == "" { + return Tokens{}, &InvalidNameError{Name: name, Reason: "name is empty after trim"} + } + + if strings.IndexFunc(name, unicode.IsSpace) >= 0 { + return Tokens{}, &InvalidNameError{Name: name, Reason: "name must not contain spaces"} + } + + return Tokens{ + DB: module.Token("database." + name + ".db"), + Dialect: module.Token("database." + name + ".dialect"), + }, nil +} diff --git a/modkit/data/sqlmodule/tokens_test.go b/modkit/data/sqlmodule/tokens_test.go new file mode 100644 index 0000000..29fac4c --- /dev/null +++ b/modkit/data/sqlmodule/tokens_test.go @@ -0,0 +1,52 @@ +package sqlmodule + +import ( + "errors" + "testing" +) + +func TestNamedTokens_DefaultName(t *testing.T) { + tokens, err := NamedTokens("") + if err != nil { + t.Fatalf("NamedTokens(\"\") error = %v", err) + } + + if tokens.DB != TokenDB { + t.Fatalf("DB token = %q, want %q", tokens.DB, TokenDB) + } + if tokens.Dialect != TokenDialect { + t.Fatalf("dialect token = %q, want %q", tokens.Dialect, TokenDialect) + } +} + +func TestNamedTokens_Namespace(t *testing.T) { + tokens, err := NamedTokens("analytics") + if err != nil { + t.Fatalf("NamedTokens(\"analytics\") error = %v", err) + } + + if tokens.DB != "database.analytics.db" { + t.Fatalf("DB token = %q, want %q", tokens.DB, "database.analytics.db") + } + if tokens.Dialect != "database.analytics.dialect" { + t.Fatalf("dialect token = %q, want %q", tokens.Dialect, "database.analytics.dialect") + } +} + +func TestNamedTokens_InvalidName(t *testing.T) { + testCases := []string{" ", "analytics reporting"} + + for _, name := range testCases { + t.Run(name, func(t *testing.T) { + _, err := NamedTokens(name) + if err == nil { + t.Fatalf("NamedTokens(%q) expected error", name) + } + + var invalidNameErr *InvalidNameError + if !errors.As(err, &invalidNameErr) { + t.Fatalf("error %T is not *InvalidNameError", err) + } + }) + } +} From ab2a6dbee8d0eb74fcbb26b17e60b01edf071cfc Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Mon, 9 Feb 2026 15:41:13 +0200 Subject: [PATCH 02/31] fix(example-mysql): keep db token compatibility via sql contract Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- .../internal/modules/database/module.go | 17 ++++- .../internal/modules/database/module_test.go | 67 +++++++++++++++++-- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/examples/hello-mysql/internal/modules/database/module.go b/examples/hello-mysql/internal/modules/database/module.go index 3d05ef8..5afc495 100644 --- a/examples/hello-mysql/internal/modules/database/module.go +++ b/examples/hello-mysql/internal/modules/database/module.go @@ -6,10 +6,17 @@ import ( configmodule "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/config" "github.com/go-modkit/modkit/examples/hello-mysql/internal/platform/mysql" + "github.com/go-modkit/modkit/modkit/data/sqlmodule" "github.com/go-modkit/modkit/modkit/module" ) -const TokenDB module.Token = "database.db" +const ( + // TokenDB is kept for backwards compatibility with the existing hello-mysql + // token path. + TokenDB module.Token = sqlmodule.TokenDB + + TokenDialect module.Token = sqlmodule.TokenDialect +) type Options struct { Config module.Module @@ -57,7 +64,13 @@ func (m Module) Definition() module.ModuleDef { return CleanupDB(ctx, db) }, }, + { + Token: TokenDialect, + Build: func(_ module.Resolver) (any, error) { + return sqlmodule.DialectMySQL, nil + }, + }, }, - Exports: []module.Token{TokenDB}, + Exports: []module.Token{TokenDB, TokenDialect}, } } diff --git a/examples/hello-mysql/internal/modules/database/module_test.go b/examples/hello-mysql/internal/modules/database/module_test.go index d652ec2..15f0f45 100644 --- a/examples/hello-mysql/internal/modules/database/module_test.go +++ b/examples/hello-mysql/internal/modules/database/module_test.go @@ -6,6 +6,7 @@ import ( "testing" configmodule "github.com/go-modkit/modkit/examples/hello-mysql/internal/modules/config" + "github.com/go-modkit/modkit/modkit/data/sqlmodule" "github.com/go-modkit/modkit/modkit/module" ) @@ -48,24 +49,78 @@ func TestDatabaseModule_Definition_ProvidesDB(t *testing.T) { if def.Name != "database" { t.Fatalf("expected name database, got %q", def.Name) } - if len(def.Providers) != 1 { - t.Fatalf("expected 1 provider, got %d", len(def.Providers)) + if len(def.Providers) != 2 { + t.Fatalf("expected 2 providers, got %d", len(def.Providers)) } if len(def.Imports) != 1 { t.Fatalf("expected 1 import, got %d", len(def.Imports)) } - if def.Providers[0].Token != TokenDB { - t.Fatalf("expected TokenDB, got %q", def.Providers[0].Token) + + var dbProvider, dialectProvider *module.ProviderDef + for i := range def.Providers { + p := &def.Providers[i] + switch p.Token { + case TokenDB: + dbProvider = p + case TokenDialect: + dialectProvider = p + } + } + if dbProvider == nil { + t.Fatalf("expected provider %q", TokenDB) } - if def.Providers[0].Cleanup == nil { + if dialectProvider == nil { + t.Fatalf("expected provider %q", TokenDialect) + } + if dbProvider.Cleanup == nil { t.Fatal("expected cleanup hook") } + + dialect, err := dialectProvider.Build(resolverMap{}) + if err != nil { + t.Fatalf("expected dialect build to succeed, got %v", err) + } + if dialect != sqlmodule.DialectMySQL { + t.Fatalf("expected dialect %q, got %v", sqlmodule.DialectMySQL, dialect) + } + + exports := map[module.Token]bool{} + for _, token := range def.Exports { + exports[token] = true + } + if !exports[TokenDB] { + t.Fatalf("expected export %q", TokenDB) + } + if !exports[TokenDialect] { + t.Fatalf("expected export %q", TokenDialect) + } +} + +func TestDatabaseModule_TokenDB_CompatibilityWithSQLContract(t *testing.T) { + if TokenDB != sqlmodule.TokenDB { + t.Fatalf("TokenDB = %q, want %q", TokenDB, sqlmodule.TokenDB) + } + if TokenDB != module.Token("database.db") { + t.Fatalf("TokenDB = %q, want %q", TokenDB, module.Token("database.db")) + } } func TestDatabaseModule_ProviderBuildError(t *testing.T) { mod := NewModule(Options{Config: configmodule.NewModule(configmodule.Options{})}) def := mod.(*Module).Definition() - provider := def.Providers[0] + + var provider module.ProviderDef + found := false + for _, p := range def.Providers { + if p.Token == TokenDB { + provider = p + found = true + break + } + } + if !found { + t.Fatalf("expected provider %q", TokenDB) + } _, err := provider.Build(resolverMap{configmodule.TokenMySQLDSN: ""}) if err == nil { From acec0e402a0e606540be291584b12701440f00fb Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Mon, 9 Feb 2026 15:59:15 +0200 Subject: [PATCH 03/31] feat(data-postgres): add postgres provider module --- modkit/data/postgres/cleanup.go | 17 +++ modkit/data/postgres/config.go | 50 ++++++++ modkit/data/postgres/doc.go | 6 + modkit/data/postgres/errors.go | 36 ++++++ modkit/data/postgres/module.go | 147 ++++++++++++++++++++++++ modkit/data/postgres/module_test.go | 171 ++++++++++++++++++++++++++++ modkit/data/postgres/tokens.go | 16 +++ 7 files changed, 443 insertions(+) create mode 100644 modkit/data/postgres/cleanup.go create mode 100644 modkit/data/postgres/config.go create mode 100644 modkit/data/postgres/doc.go create mode 100644 modkit/data/postgres/errors.go create mode 100644 modkit/data/postgres/module.go create mode 100644 modkit/data/postgres/module_test.go create mode 100644 modkit/data/postgres/tokens.go diff --git a/modkit/data/postgres/cleanup.go b/modkit/data/postgres/cleanup.go new file mode 100644 index 0000000..ca25567 --- /dev/null +++ b/modkit/data/postgres/cleanup.go @@ -0,0 +1,17 @@ +package postgres + +import ( + "context" + "database/sql" +) + +// CleanupDB closes a DB handle if present. +func CleanupDB(ctx context.Context, db *sql.DB) error { + if ctx.Err() != nil { + return ctx.Err() + } + if db == nil { + return nil + } + return db.Close() +} diff --git a/modkit/data/postgres/config.go b/modkit/data/postgres/config.go new file mode 100644 index 0000000..e8013bb --- /dev/null +++ b/modkit/data/postgres/config.go @@ -0,0 +1,50 @@ +package postgres + +import ( + "time" + + "github.com/go-modkit/modkit/modkit/config" + "github.com/go-modkit/modkit/modkit/module" +) + +// DefaultConfigModule provides Postgres configuration from environment variables. +// +// Required: +// - POSTGRES_DSN +// +// Optional: +// - POSTGRES_MAX_OPEN_CONNS +// - POSTGRES_MAX_IDLE_CONNS +// - POSTGRES_CONN_MAX_LIFETIME +// - POSTGRES_CONNECT_TIMEOUT (default 0; disables provider ping) +func DefaultConfigModule() module.Module { + return config.NewModule( + config.WithTyped(TokenDSN, config.ValueSpec[string]{ + Key: "POSTGRES_DSN", + Required: true, + Sensitive: true, + Description: "Postgres DSN.", + Parse: config.ParseString, + }, true), + config.WithTyped(TokenMaxOpenConns, config.ValueSpec[int]{ + Key: "POSTGRES_MAX_OPEN_CONNS", + Description: "Maximum open connections for the DB pool.", + Parse: config.ParseInt, + }, true), + config.WithTyped(TokenMaxIdleConns, config.ValueSpec[int]{ + Key: "POSTGRES_MAX_IDLE_CONNS", + Description: "Maximum idle connections for the DB pool.", + Parse: config.ParseInt, + }, true), + config.WithTyped(TokenConnMaxLifetime, config.ValueSpec[time.Duration]{ + Key: "POSTGRES_CONN_MAX_LIFETIME", + Description: "Maximum amount of time a connection may be reused.", + Parse: config.ParseDuration, + }, true), + config.WithTyped(TokenConnectTimeout, config.ValueSpec[time.Duration]{ + Key: "POSTGRES_CONNECT_TIMEOUT", + Description: "Optional ping timeout on provider build. 0 disables ping.", + Parse: config.ParseDuration, + }, true), + ) +} diff --git a/modkit/data/postgres/doc.go b/modkit/data/postgres/doc.go new file mode 100644 index 0000000..405fbd0 --- /dev/null +++ b/modkit/data/postgres/doc.go @@ -0,0 +1,6 @@ +// Package postgres provides a Postgres-backed SQL module. +// +// It exports shared SQL contract tokens from modkit/data/sqlmodule: +// - sqlmodule.TokenDB (*sql.DB) +// - sqlmodule.TokenDialect (sqlmodule.Dialect) +package postgres diff --git a/modkit/data/postgres/errors.go b/modkit/data/postgres/errors.go new file mode 100644 index 0000000..c3ed4a7 --- /dev/null +++ b/modkit/data/postgres/errors.go @@ -0,0 +1,36 @@ +package postgres + +import ( + "fmt" + + "github.com/go-modkit/modkit/modkit/module" +) + +// BuildStage identifies the provider build step. +type BuildStage string + +const ( + // StageResolveConfig indicates a failure resolving config tokens. + StageResolveConfig BuildStage = "resolve_config" + // StageInvalidConfig indicates invalid config values (e.g. negative settings). + StageInvalidConfig BuildStage = "invalid_config" + // StageOpen indicates a failure opening the database handle. + StageOpen BuildStage = "open" + // StagePing indicates a failure pinging the database. + StagePing BuildStage = "ping" +) + +// BuildError is returned when the Postgres provider fails to build. +type BuildError struct { + Token module.Token + Stage BuildStage + Err error +} + +func (e *BuildError) Error() string { + return fmt.Sprintf("postgres provider build failed: token=%q stage=%s: %v", e.Token, e.Stage, e.Err) +} + +func (e *BuildError) Unwrap() error { + return e.Err +} diff --git a/modkit/data/postgres/module.go b/modkit/data/postgres/module.go new file mode 100644 index 0000000..bda2e74 --- /dev/null +++ b/modkit/data/postgres/module.go @@ -0,0 +1,147 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/go-modkit/modkit/modkit/data/sqlmodule" + "github.com/go-modkit/modkit/modkit/module" +) + +const driverName = "postgres" + +// Options configures a Postgres provider module. +type Options struct { + // Config provides Postgres configuration tokens (DSN, pool settings, ping timeout). + Config module.Module + // Name namespaces exported SQL contract tokens via sqlmodule.NamedTokens. + Name string +} + +// Module provides a Postgres-backed *sql.DB and dialect token. +type Module struct { + opts Options +} + +// NewModule constructs a Postgres provider module. +func NewModule(opts Options) module.Module { + if opts.Config == nil { + opts.Config = DefaultConfigModule() + } + return &Module{opts: opts} +} + +// Definition returns the module definition for graph construction. +func (m *Module) Definition() module.ModuleDef { + configMod := m.opts.Config + if configMod == nil { + configMod = DefaultConfigModule() + } + + toks, err := sqlmodule.NamedTokens(m.opts.Name) + if err != nil { + return module.ModuleDef{ + Name: "data.postgres", + Imports: []module.Module{configMod}, + Providers: []module.ProviderDef{{ + Token: "data.postgres.invalid", + Build: func(_ module.Resolver) (any, error) { return nil, err }, + }}, + } + } + + var db *sql.DB + return module.ModuleDef{ + Name: "data.postgres", + Imports: []module.Module{configMod}, + Providers: []module.ProviderDef{ + { + Token: toks.DB, + Build: func(r module.Resolver) (any, error) { + built, buildErr := buildDB(r, toks.DB) + if buildErr != nil { + return nil, buildErr + } + db = built + return db, nil + }, + Cleanup: func(ctx context.Context) error { + return CleanupDB(ctx, db) + }, + }, + { + Token: toks.Dialect, + Build: func(_ module.Resolver) (any, error) { + return sqlmodule.DialectPostgres, nil + }, + }, + }, + Exports: []module.Token{toks.DB, toks.Dialect}, + } +} + +func buildDB(r module.Resolver, dbToken module.Token) (*sql.DB, error) { + dsn, err := module.Get[string](r, TokenDSN) + if err != nil { + return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("dsn: %w", err)} + } + maxOpen, err := module.Get[int](r, TokenMaxOpenConns) + if err != nil { + return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("max_open_conns: %w", err)} + } + maxIdle, err := module.Get[int](r, TokenMaxIdleConns) + if err != nil { + return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("max_idle_conns: %w", err)} + } + maxLifetime, err := module.Get[time.Duration](r, TokenConnMaxLifetime) + if err != nil { + return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("conn_max_lifetime: %w", err)} + } + connectTimeout, err := module.Get[time.Duration](r, TokenConnectTimeout) + if err != nil { + return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("connect_timeout: %w", err)} + } + + if maxOpen < 0 { + return nil, &BuildError{Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("max_open_conns must be >= 0")} + } + if maxIdle < 0 { + return nil, &BuildError{Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("max_idle_conns must be >= 0")} + } + if maxLifetime < 0 { + return nil, &BuildError{Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("conn_max_lifetime must be >= 0")} + } + if connectTimeout < 0 { + return nil, &BuildError{Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("connect_timeout must be >= 0")} + } + + db, err := sql.Open(driverName, dsn) + if err != nil { + return nil, &BuildError{Token: dbToken, Stage: StageOpen, Err: err} + } + + if maxOpen > 0 { + db.SetMaxOpenConns(maxOpen) + } + if maxIdle > 0 { + db.SetMaxIdleConns(maxIdle) + } + if maxLifetime > 0 { + db.SetConnMaxLifetime(maxLifetime) + } + + if connectTimeout == 0 { + return db, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), connectTimeout) + defer cancel() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, &BuildError{Token: dbToken, Stage: StagePing, Err: err} + } + + return db, nil +} diff --git a/modkit/data/postgres/module_test.go b/modkit/data/postgres/module_test.go new file mode 100644 index 0000000..cfddf85 --- /dev/null +++ b/modkit/data/postgres/module_test.go @@ -0,0 +1,171 @@ +package postgres + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "sync" + "testing" + + "github.com/go-modkit/modkit/modkit/data/sqlmodule" + "github.com/go-modkit/modkit/modkit/testkit" +) + +var testDrv = &countingDriver{} + +func init() { + sql.Register(driverName, testDrv) +} + +type countingDriver struct { + mu sync.Mutex + openCount int + pingCount int + closeCount int + pingErr error + sawDeadline bool +} + +func (d *countingDriver) Reset() { + d.mu.Lock() + defer d.mu.Unlock() + c := countingDriver{} + d.openCount = c.openCount + d.pingCount = c.pingCount + d.closeCount = c.closeCount + d.pingErr = nil + d.sawDeadline = false +} + +func (d *countingDriver) SetPingErr(err error) { + d.mu.Lock() + defer d.mu.Unlock() + d.pingErr = err +} + +func (d *countingDriver) Snapshot() (open, ping, closed int, sawDeadline bool) { + d.mu.Lock() + defer d.mu.Unlock() + return d.openCount, d.pingCount, d.closeCount, d.sawDeadline +} + +func (d *countingDriver) Open(_ string) (driver.Conn, error) { + d.mu.Lock() + d.openCount++ + d.mu.Unlock() + return &countingConn{d: d}, nil +} + +type countingConn struct { + d *countingDriver +} + +func (c *countingConn) Prepare(_ string) (driver.Stmt, error) { + return nil, errors.New("not implemented") +} + +func (c *countingConn) Close() error { + c.d.mu.Lock() + c.d.closeCount++ + c.d.mu.Unlock() + return nil +} + +func (c *countingConn) Begin() (driver.Tx, error) { + return nil, errors.New("not implemented") +} + +func (c *countingConn) Ping(ctx context.Context) error { + c.d.mu.Lock() + c.d.pingCount++ + if _, ok := ctx.Deadline(); ok { + c.d.sawDeadline = true + } + err := c.d.pingErr + c.d.mu.Unlock() + return err +} + +func TestModuleExportsDialectAndDBTokens(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + dialect := testkit.Get[sqlmodule.Dialect](t, h, sqlmodule.TokenDialect) + if dialect != sqlmodule.DialectPostgres { + t.Fatalf("unexpected dialect: %q", dialect) + } +} + +func TestConnectTimeoutZeroSkipsPing(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + open, ping, _, _ := testDrv.Snapshot() + if open != 0 { + t.Fatalf("expected open=0, got %d", open) + } + if ping != 0 { + t.Fatalf("expected ping=0, got %d", ping) + } +} + +func TestConnectTimeoutNonZeroPingsWithTimeout(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "25ms") + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + open, ping, _, sawDeadline := testDrv.Snapshot() + if open == 0 { + t.Fatalf("expected open>0, got %d", open) + } + if ping != 1 { + t.Fatalf("expected ping=1, got %d", ping) + } + if !sawDeadline { + t.Fatalf("expected ping to observe a context deadline") + } +} + +func TestPingFailureReturnsTypedBuildErrorAndClosesDB(t *testing.T) { + testDrv.Reset() + pingErr := errors.New("ping failed") + testDrv.SetPingErr(pingErr) + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "25ms") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatalf("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StagePing { + t.Fatalf("expected stage=%s, got %s", StagePing, be.Stage) + } + if be.Token != sqlmodule.TokenDB { + t.Fatalf("expected token=%q, got %q", sqlmodule.TokenDB, be.Token) + } + if !errors.Is(err, pingErr) { + t.Fatalf("expected error to wrap ping error") + } + + _, _, closed, _ := testDrv.Snapshot() + if closed == 0 { + t.Fatalf("expected ping failure path to close the DB") + } +} diff --git a/modkit/data/postgres/tokens.go b/modkit/data/postgres/tokens.go new file mode 100644 index 0000000..02de8cd --- /dev/null +++ b/modkit/data/postgres/tokens.go @@ -0,0 +1,16 @@ +package postgres + +import "github.com/go-modkit/modkit/modkit/module" + +const ( + // TokenDSN resolves the Postgres DSN. + TokenDSN module.Token = "postgres.dsn" //nolint:gosec // token name, not credential + // TokenMaxOpenConns resolves the max open connections pool setting. + TokenMaxOpenConns module.Token = "postgres.max_open_conns" //nolint:gosec // token name, not credential + // TokenMaxIdleConns resolves the max idle connections pool setting. + TokenMaxIdleConns module.Token = "postgres.max_idle_conns" //nolint:gosec // token name, not credential + // TokenConnMaxLifetime resolves the connection max lifetime pool setting. + TokenConnMaxLifetime module.Token = "postgres.conn_max_lifetime" //nolint:gosec // token name, not credential + // TokenConnectTimeout resolves the optional provider ping timeout. + TokenConnectTimeout module.Token = "postgres.connect_timeout" //nolint:gosec // token name, not credential +) From caceafffafccee6869bc2e51ff9f38544c9488e0 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Mon, 9 Feb 2026 16:11:28 +0200 Subject: [PATCH 04/31] feat(data-sqlite): add sqlite provider module --- modkit/data/sqlite/cleanup.go | 17 ++ modkit/data/sqlite/config.go | 43 +++++ modkit/data/sqlite/doc.go | 6 + modkit/data/sqlite/errors.go | 36 ++++ modkit/data/sqlite/module.go | 155 +++++++++++++++++ modkit/data/sqlite/module_test.go | 272 ++++++++++++++++++++++++++++++ modkit/data/sqlite/tokens.go | 14 ++ 7 files changed, 543 insertions(+) create mode 100644 modkit/data/sqlite/cleanup.go create mode 100644 modkit/data/sqlite/config.go create mode 100644 modkit/data/sqlite/doc.go create mode 100644 modkit/data/sqlite/errors.go create mode 100644 modkit/data/sqlite/module.go create mode 100644 modkit/data/sqlite/module_test.go create mode 100644 modkit/data/sqlite/tokens.go diff --git a/modkit/data/sqlite/cleanup.go b/modkit/data/sqlite/cleanup.go new file mode 100644 index 0000000..98b479e --- /dev/null +++ b/modkit/data/sqlite/cleanup.go @@ -0,0 +1,17 @@ +package sqlite + +import ( + "context" + "database/sql" +) + +// CleanupDB closes a DB handle if present. +func CleanupDB(ctx context.Context, db *sql.DB) error { + if ctx.Err() != nil { + return ctx.Err() + } + if db == nil { + return nil + } + return db.Close() +} diff --git a/modkit/data/sqlite/config.go b/modkit/data/sqlite/config.go new file mode 100644 index 0000000..c3d1259 --- /dev/null +++ b/modkit/data/sqlite/config.go @@ -0,0 +1,43 @@ +package sqlite + +import ( + "time" + + "github.com/go-modkit/modkit/modkit/config" + "github.com/go-modkit/modkit/modkit/module" +) + +// DefaultConfigModule provides SQLite configuration from environment variables. +// +// Required: +// - SQLITE_PATH +// +// Optional: +// - SQLITE_BUSY_TIMEOUT +// - SQLITE_JOURNAL_MODE +// - SQLITE_CONNECT_TIMEOUT (default 0; disables provider ping) +func DefaultConfigModule() module.Module { + return config.NewModule( + config.WithTyped(TokenPath, config.ValueSpec[string]{ + Key: "SQLITE_PATH", + Required: true, + Description: "SQLite database path or DSN.", + Parse: config.ParseString, + }, true), + config.WithTyped(TokenBusyTimeout, config.ValueSpec[time.Duration]{ + Key: "SQLITE_BUSY_TIMEOUT", + Description: "Optional busy timeout to apply to the DSN.", + Parse: config.ParseDuration, + }, true), + config.WithTyped(TokenJournalMode, config.ValueSpec[string]{ + Key: "SQLITE_JOURNAL_MODE", + Description: "Optional journal mode to apply to the DSN.", + Parse: config.ParseString, + }, true), + config.WithTyped(TokenConnectTimeout, config.ValueSpec[time.Duration]{ + Key: "SQLITE_CONNECT_TIMEOUT", + Description: "Optional ping timeout on provider build. 0 disables ping.", + Parse: config.ParseDuration, + }, true), + ) +} diff --git a/modkit/data/sqlite/doc.go b/modkit/data/sqlite/doc.go new file mode 100644 index 0000000..3eef020 --- /dev/null +++ b/modkit/data/sqlite/doc.go @@ -0,0 +1,6 @@ +// Package sqlite provides a SQLite-backed SQL module. +// +// It exports shared SQL contract tokens from modkit/data/sqlmodule: +// - sqlmodule.TokenDB (*sql.DB) +// - sqlmodule.TokenDialect (sqlmodule.Dialect) +package sqlite diff --git a/modkit/data/sqlite/errors.go b/modkit/data/sqlite/errors.go new file mode 100644 index 0000000..6a1b16e --- /dev/null +++ b/modkit/data/sqlite/errors.go @@ -0,0 +1,36 @@ +package sqlite + +import ( + "fmt" + + "github.com/go-modkit/modkit/modkit/module" +) + +// BuildStage identifies the provider build step. +type BuildStage string + +const ( + // StageResolveConfig indicates a failure resolving config tokens. + StageResolveConfig BuildStage = "resolve_config" + // StageInvalidConfig indicates invalid config values (e.g. negative settings). + StageInvalidConfig BuildStage = "invalid_config" + // StageOpen indicates a failure opening the database handle. + StageOpen BuildStage = "open" + // StagePing indicates a failure pinging the database. + StagePing BuildStage = "ping" +) + +// BuildError is returned when the SQLite provider fails to build. +type BuildError struct { + Token module.Token + Stage BuildStage + Err error +} + +func (e *BuildError) Error() string { + return fmt.Sprintf("sqlite provider build failed: token=%q stage=%s: %v", e.Token, e.Stage, e.Err) +} + +func (e *BuildError) Unwrap() error { + return e.Err +} diff --git a/modkit/data/sqlite/module.go b/modkit/data/sqlite/module.go new file mode 100644 index 0000000..2812b00 --- /dev/null +++ b/modkit/data/sqlite/module.go @@ -0,0 +1,155 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "github.com/go-modkit/modkit/modkit/data/sqlmodule" + "github.com/go-modkit/modkit/modkit/module" +) + +const driverName = "sqlite3" + +// Options configures a SQLite provider module. +type Options struct { + // Config provides SQLite configuration tokens (path/DSN, DSN options, ping timeout). + Config module.Module + // Name namespaces exported SQL contract tokens via sqlmodule.NamedTokens. + Name string +} + +// Module provides a SQLite-backed *sql.DB and dialect token. +type Module struct { + opts Options +} + +// NewModule constructs a SQLite provider module. +func NewModule(opts Options) module.Module { + if opts.Config == nil { + opts.Config = DefaultConfigModule() + } + return &Module{opts: opts} +} + +// Definition returns the module definition for graph construction. +func (m *Module) Definition() module.ModuleDef { + configMod := m.opts.Config + if configMod == nil { + configMod = DefaultConfigModule() + } + + toks, err := sqlmodule.NamedTokens(m.opts.Name) + if err != nil { + return module.ModuleDef{ + Name: "data.sqlite", + Imports: []module.Module{configMod}, + Providers: []module.ProviderDef{{ + Token: "data.sqlite.invalid", + Build: func(_ module.Resolver) (any, error) { return nil, err }, + }}, + } + } + + var db *sql.DB + return module.ModuleDef{ + Name: "data.sqlite", + Imports: []module.Module{configMod}, + Providers: []module.ProviderDef{ + { + Token: toks.DB, + Build: func(r module.Resolver) (any, error) { + built, buildErr := buildDB(r, toks.DB) + if buildErr != nil { + return nil, buildErr + } + db = built + return db, nil + }, + Cleanup: func(ctx context.Context) error { + return CleanupDB(ctx, db) + }, + }, + { + Token: toks.Dialect, + Build: func(_ module.Resolver) (any, error) { + return sqlmodule.DialectSQLite, nil + }, + }, + }, + Exports: []module.Token{toks.DB, toks.Dialect}, + } +} + +func buildDB(r module.Resolver, dbToken module.Token) (*sql.DB, error) { + path, err := module.Get[string](r, TokenPath) + if err != nil { + return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("path: %w", err)} + } + busyTimeout, err := module.Get[time.Duration](r, TokenBusyTimeout) + if err != nil { + return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("busy_timeout: %w", err)} + } + journalMode, err := module.Get[string](r, TokenJournalMode) + if err != nil { + return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("journal_mode: %w", err)} + } + connectTimeout, err := module.Get[time.Duration](r, TokenConnectTimeout) + if err != nil { + return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("connect_timeout: %w", err)} + } + + if busyTimeout < 0 { + return nil, &BuildError{Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("busy_timeout must be >= 0")} + } + if connectTimeout < 0 { + return nil, &BuildError{Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("connect_timeout must be >= 0")} + } + + dsn := buildDSN(path, busyTimeout, journalMode) + db, err := sql.Open(driverName, dsn) + if err != nil { + return nil, &BuildError{Token: dbToken, Stage: StageOpen, Err: err} + } + + if connectTimeout == 0 { + return db, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), connectTimeout) + defer cancel() + if err := db.PingContext(ctx); err != nil { + _ = db.Close() + return nil, &BuildError{Token: dbToken, Stage: StagePing, Err: err} + } + + return db, nil +} + +func buildDSN(base string, busyTimeout time.Duration, journalMode string) string { + journalMode = strings.TrimSpace(journalMode) + + params := url.Values{} + if busyTimeout > 0 { + params.Set("_busy_timeout", strconv.FormatInt(int64(busyTimeout/time.Millisecond), 10)) + } + if journalMode != "" { + params.Set("_journal_mode", journalMode) + } + if len(params) == 0 { + return base + } + + enc := params.Encode() + if strings.Contains(base, "?") { + if strings.HasSuffix(base, "?") || strings.HasSuffix(base, "&") { + return base + enc + } + return base + "&" + enc + } + return base + "?" + enc +} diff --git a/modkit/data/sqlite/module_test.go b/modkit/data/sqlite/module_test.go new file mode 100644 index 0000000..d7a8564 --- /dev/null +++ b/modkit/data/sqlite/module_test.go @@ -0,0 +1,272 @@ +package sqlite + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "net/url" + "strings" + "sync" + "testing" + + "github.com/go-modkit/modkit/modkit/data/sqlmodule" + "github.com/go-modkit/modkit/modkit/testkit" +) + +var testDrv = &countingDriver{} + +func init() { + sql.Register(driverName, testDrv) +} + +type countingDriver struct { + mu sync.Mutex + openCount int + pingCount int + closeCount int + pingErr error + sawDeadline bool + lastOpenDSN string +} + +func (d *countingDriver) Reset() { + d.mu.Lock() + defer d.mu.Unlock() + c := countingDriver{} + d.openCount = c.openCount + d.pingCount = c.pingCount + d.closeCount = c.closeCount + d.pingErr = nil + d.sawDeadline = false + d.lastOpenDSN = "" +} + +func (d *countingDriver) SetPingErr(err error) { + d.mu.Lock() + defer d.mu.Unlock() + d.pingErr = err +} + +func (d *countingDriver) Snapshot() (open, ping, closed int, sawDeadline bool, lastOpenDSN string) { + d.mu.Lock() + defer d.mu.Unlock() + return d.openCount, d.pingCount, d.closeCount, d.sawDeadline, d.lastOpenDSN +} + +func (d *countingDriver) Open(name string) (driver.Conn, error) { + d.mu.Lock() + d.openCount++ + d.lastOpenDSN = name + d.mu.Unlock() + return &countingConn{d: d}, nil +} + +type countingConn struct { + d *countingDriver +} + +func (c *countingConn) Prepare(_ string) (driver.Stmt, error) { + return nil, errors.New("not implemented") +} + +func (c *countingConn) Close() error { + c.d.mu.Lock() + c.d.closeCount++ + c.d.mu.Unlock() + return nil +} + +func (c *countingConn) Begin() (driver.Tx, error) { + return nil, errors.New("not implemented") +} + +func (c *countingConn) Ping(ctx context.Context) error { + c.d.mu.Lock() + c.d.pingCount++ + if _, ok := ctx.Deadline(); ok { + c.d.sawDeadline = true + } + err := c.d.pingErr + c.d.mu.Unlock() + return err +} + +func TestModuleExportsDialectAndDBTokens(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + dialect := testkit.Get[sqlmodule.Dialect](t, h, sqlmodule.TokenDialect) + if dialect != sqlmodule.DialectSQLite { + t.Fatalf("unexpected dialect: %q", dialect) + } +} + +func TestConnectTimeoutZeroSkipsPing(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + open, ping, _, _, _ := testDrv.Snapshot() + if open != 0 { + t.Fatalf("expected open=0, got %d", open) + } + if ping != 0 { + t.Fatalf("expected ping=0, got %d", ping) + } +} + +func TestConnectTimeoutNonZeroPingsWithTimeout(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "25ms") + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + open, ping, _, sawDeadline, _ := testDrv.Snapshot() + if open == 0 { + t.Fatalf("expected open>0, got %d", open) + } + if ping != 1 { + t.Fatalf("expected ping=1, got %d", ping) + } + if !sawDeadline { + t.Fatalf("expected ping to observe a context deadline") + } +} + +func TestPingFailureReturnsTypedBuildErrorAndClosesDB(t *testing.T) { + testDrv.Reset() + pingErr := errors.New("ping failed") + testDrv.SetPingErr(pingErr) + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "25ms") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatalf("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StagePing { + t.Fatalf("expected stage=%s, got %s", StagePing, be.Stage) + } + if be.Token != sqlmodule.TokenDB { + t.Fatalf("expected token=%q, got %q", sqlmodule.TokenDB, be.Token) + } + if !errors.Is(err, pingErr) { + t.Fatalf("expected error to wrap ping error") + } + + _, _, closed, _, _ := testDrv.Snapshot() + if closed == 0 { + t.Fatalf("expected ping failure path to close the DB") + } +} + +func TestPathConfigBuildsDSNWithSQLiteOptions(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_BUSY_TIMEOUT", "150ms") + t.Setenv("SQLITE_JOURNAL_MODE", "wal") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "25ms") + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + _, _, _, _, openDSN := testDrv.Snapshot() + parts := strings.SplitN(openDSN, "?", 2) + if len(parts) != 2 { + t.Fatalf("expected DSN to contain query, got %q", openDSN) + } + if parts[0] != "test.db" { + t.Fatalf("expected base path %q, got %q", "test.db", parts[0]) + } + q, err := url.ParseQuery(parts[1]) + if err != nil { + t.Fatalf("parse query: %v", err) + } + if got := q.Get("_busy_timeout"); got != "150" { + t.Fatalf("expected _busy_timeout=150, got %q", got) + } + if got := q.Get("_journal_mode"); got != "wal" { + t.Fatalf("expected _journal_mode=wal, got %q", got) + } +} + +func TestDSNConfigAppendsSQLiteOptionsToExistingQuery(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "file:test.db?cache=shared") + t.Setenv("SQLITE_BUSY_TIMEOUT", "200ms") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "25ms") + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + _, _, _, _, openDSN := testDrv.Snapshot() + parts := strings.SplitN(openDSN, "?", 2) + if len(parts) != 2 { + t.Fatalf("expected DSN to contain query, got %q", openDSN) + } + if parts[0] != "file:test.db" { + t.Fatalf("expected base DSN %q, got %q", "file:test.db", parts[0]) + } + q, err := url.ParseQuery(parts[1]) + if err != nil { + t.Fatalf("parse query: %v", err) + } + if got := q.Get("cache"); got != "shared" { + t.Fatalf("expected cache=shared, got %q", got) + } + if got := q.Get("_busy_timeout"); got != "200" { + t.Fatalf("expected _busy_timeout=200, got %q", got) + } +} + +func TestNegativeConnectTimeoutFailsWithInvalidConfig(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "-1ms") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatalf("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageInvalidConfig { + t.Fatalf("expected stage=%s, got %s", StageInvalidConfig, be.Stage) + } +} + +func TestCleanupClosesDB(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "25ms") + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + if err := h.Close(); err != nil { + t.Fatalf("close harness: %v", err) + } + + _, _, closed, _, _ := testDrv.Snapshot() + if closed == 0 { + t.Fatalf("expected cleanup to close a DB connection") + } +} diff --git a/modkit/data/sqlite/tokens.go b/modkit/data/sqlite/tokens.go new file mode 100644 index 0000000..3ddc1a5 --- /dev/null +++ b/modkit/data/sqlite/tokens.go @@ -0,0 +1,14 @@ +package sqlite + +import "github.com/go-modkit/modkit/modkit/module" + +const ( + // TokenPath resolves the SQLite database path or DSN. + TokenPath module.Token = "sqlite.path" //nolint:gosec // token name, not credential + // TokenBusyTimeout resolves the optional busy timeout setting. + TokenBusyTimeout module.Token = "sqlite.busy_timeout" //nolint:gosec // token name, not credential + // TokenJournalMode resolves the optional journal mode setting. + TokenJournalMode module.Token = "sqlite.journal_mode" //nolint:gosec // token name, not credential + // TokenConnectTimeout resolves the optional provider ping timeout. + TokenConnectTimeout module.Token = "sqlite.connect_timeout" //nolint:gosec // token name, not credential +) From 6cc47615318da7506672098bf374b7a0234edea7 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Mon, 9 Feb 2026 16:33:03 +0200 Subject: [PATCH 05/31] feat(examples): add hello-postgres and hello-sqlite --- examples/hello-postgres/cmd/api/main.go | 59 ++++++ examples/hello-postgres/go.mod | 70 ++++++++ examples/hello-postgres/go.sum | 170 ++++++++++++++++++ .../hello-postgres/internal/app/module.go | 21 +++ .../internal/smoke/smoke_test.go | 103 +++++++++++ examples/hello-sqlite/cmd/api/main.go | 59 ++++++ examples/hello-sqlite/go.mod | 12 ++ examples/hello-sqlite/go.sum | 14 ++ examples/hello-sqlite/internal/app/module.go | 21 +++ .../hello-sqlite/internal/smoke/smoke_test.go | 61 +++++++ go.work | 2 + go.work.sum | 16 +- 12 files changed, 606 insertions(+), 2 deletions(-) create mode 100644 examples/hello-postgres/cmd/api/main.go create mode 100644 examples/hello-postgres/go.mod create mode 100644 examples/hello-postgres/go.sum create mode 100644 examples/hello-postgres/internal/app/module.go create mode 100644 examples/hello-postgres/internal/smoke/smoke_test.go create mode 100644 examples/hello-sqlite/cmd/api/main.go create mode 100644 examples/hello-sqlite/go.mod create mode 100644 examples/hello-sqlite/go.sum create mode 100644 examples/hello-sqlite/internal/app/module.go create mode 100644 examples/hello-sqlite/internal/smoke/smoke_test.go diff --git a/examples/hello-postgres/cmd/api/main.go b/examples/hello-postgres/cmd/api/main.go new file mode 100644 index 0000000..0b0244c --- /dev/null +++ b/examples/hello-postgres/cmd/api/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "log" + "net/http" + + _ "github.com/lib/pq" + + "github.com/go-modkit/modkit/examples/hello-postgres/internal/app" + mkhttp "github.com/go-modkit/modkit/modkit/http" + "github.com/go-modkit/modkit/modkit/kernel" + "github.com/go-modkit/modkit/modkit/module" +) + +type HealthController struct{} + +func (c *HealthController) RegisterRoutes(r mkhttp.Router) { + r.Handle(http.MethodGet, "/health", http.HandlerFunc(c.health)) +} + +func (c *HealthController) health(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} + +type RootModule struct{} + +func (m *RootModule) Definition() module.ModuleDef { + return module.ModuleDef{ + Name: "api", + Imports: []module.Module{ + app.NewModule(), + }, + Controllers: []module.ControllerDef{{ + Name: "HealthController", + Build: func(_ module.Resolver) (any, error) { + return &HealthController{}, nil + }, + }}, + } +} + +func main() { + app, err := kernel.Bootstrap(&RootModule{}) + if err != nil { + log.Fatalf("bootstrap: %v", err) + } + + router := mkhttp.NewRouter() + if err := mkhttp.RegisterRoutes(mkhttp.AsRouter(router), app.Controllers); err != nil { + log.Fatalf("routes: %v", err) + } + + log.Println("Server starting on http://localhost:8080") + log.Println("Try: curl http://localhost:8080/health") + if err := mkhttp.Serve(":8080", router); err != nil { + log.Fatalf("serve: %v", err) + } +} diff --git a/examples/hello-postgres/go.mod b/examples/hello-postgres/go.mod new file mode 100644 index 0000000..be19128 --- /dev/null +++ b/examples/hello-postgres/go.mod @@ -0,0 +1,70 @@ +module github.com/go-modkit/modkit/examples/hello-postgres + +go 1.25.7 + +require ( + github.com/go-modkit/modkit v0.0.0 + github.com/lib/pq v1.10.9 + github.com/testcontainers/testcontainers-go v0.40.0 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.1+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.8.4 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-chi/chi/v5 v5.2.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + golang.org/x/crypto v0.43.0 // indirect + golang.org/x/sys v0.40.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/go-modkit/modkit => ../.. diff --git a/examples/hello-postgres/go.sum b/examples/hello-postgres/go.sum new file mode 100644 index 0000000..79a0a9e --- /dev/null +++ b/examples/hello-postgres/go.sum @@ -0,0 +1,170 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= +github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= +golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/examples/hello-postgres/internal/app/module.go b/examples/hello-postgres/internal/app/module.go new file mode 100644 index 0000000..4e5e56e --- /dev/null +++ b/examples/hello-postgres/internal/app/module.go @@ -0,0 +1,21 @@ +package app + +import ( + "github.com/go-modkit/modkit/modkit/data/postgres" + "github.com/go-modkit/modkit/modkit/module" +) + +type Module struct{} + +func NewModule() module.Module { + return &Module{} +} + +func (m *Module) Definition() module.ModuleDef { + return module.ModuleDef{ + Name: "app", + Imports: []module.Module{ + postgres.NewModule(postgres.Options{}), + }, + } +} diff --git a/examples/hello-postgres/internal/smoke/smoke_test.go b/examples/hello-postgres/internal/smoke/smoke_test.go new file mode 100644 index 0000000..9956c66 --- /dev/null +++ b/examples/hello-postgres/internal/smoke/smoke_test.go @@ -0,0 +1,103 @@ +package smoke + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os/exec" + "testing" + "time" + + _ "github.com/lib/pq" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/go-modkit/modkit/examples/hello-postgres/internal/app" + "github.com/go-modkit/modkit/modkit/data/sqlmodule" + "github.com/go-modkit/modkit/modkit/testkit" +) + +func TestSmoke_Postgres_ModuleBootsAndQueries(t *testing.T) { + requireDocker(t) + + ctx := context.Background() + container, dsn := startPostgres(t, ctx) + defer func() { + _ = container.Terminate(ctx) + }() + + t.Setenv("POSTGRES_DSN", dsn) + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "2s") + + h := testkit.New(t, app.NewModule()) + + db := testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + dialect := testkit.Get[sqlmodule.Dialect](t, h, sqlmodule.TokenDialect) + if dialect != sqlmodule.DialectPostgres { + t.Fatalf("unexpected dialect: %q", dialect) + } + + qctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var one int + if err := db.QueryRowContext(qctx, "SELECT 1").Scan(&one); err != nil { + t.Fatalf("select failed: %v", err) + } + if one != 1 { + t.Fatalf("unexpected result: %d", one) + } +} + +func requireDocker(t *testing.T) { + t.Helper() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker binary not found") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "docker", "info") + if err := cmd.Run(); err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Skip("docker info timed out") + } + t.Skipf("docker unavailable: %v", err) + } +} + +func startPostgres(t *testing.T, ctx context.Context) (testcontainers.Container, string) { + t.Helper() + + req := testcontainers.ContainerRequest{ + Image: "postgres:16-alpine", + ExposedPorts: []string{"5432/tcp"}, + Env: map[string]string{ + "POSTGRES_PASSWORD": "password", + "POSTGRES_DB": "app", + }, + WaitingFor: wait.ForListeningPort("5432/tcp").WithStartupTimeout(2 * time.Minute), + } + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("container start failed: %v", err) + } + + host, err := container.Host(ctx) + if err != nil { + _ = container.Terminate(ctx) + t.Fatalf("container host failed: %v", err) + } + port, err := container.MappedPort(ctx, "5432") + if err != nil { + _ = container.Terminate(ctx) + t.Fatalf("container port failed: %v", err) + } + + dsn := fmt.Sprintf("postgres://postgres:password@%s:%s/app?sslmode=disable", host, port.Port()) + return container, dsn +} diff --git a/examples/hello-sqlite/cmd/api/main.go b/examples/hello-sqlite/cmd/api/main.go new file mode 100644 index 0000000..78758df --- /dev/null +++ b/examples/hello-sqlite/cmd/api/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "log" + "net/http" + + _ "github.com/mattn/go-sqlite3" + + "github.com/go-modkit/modkit/examples/hello-sqlite/internal/app" + mkhttp "github.com/go-modkit/modkit/modkit/http" + "github.com/go-modkit/modkit/modkit/kernel" + "github.com/go-modkit/modkit/modkit/module" +) + +type HealthController struct{} + +func (c *HealthController) RegisterRoutes(r mkhttp.Router) { + r.Handle(http.MethodGet, "/health", http.HandlerFunc(c.health)) +} + +func (c *HealthController) health(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) +} + +type RootModule struct{} + +func (m *RootModule) Definition() module.ModuleDef { + return module.ModuleDef{ + Name: "api", + Imports: []module.Module{ + app.NewModule(), + }, + Controllers: []module.ControllerDef{{ + Name: "HealthController", + Build: func(_ module.Resolver) (any, error) { + return &HealthController{}, nil + }, + }}, + } +} + +func main() { + app, err := kernel.Bootstrap(&RootModule{}) + if err != nil { + log.Fatalf("bootstrap: %v", err) + } + + router := mkhttp.NewRouter() + if err := mkhttp.RegisterRoutes(mkhttp.AsRouter(router), app.Controllers); err != nil { + log.Fatalf("routes: %v", err) + } + + log.Println("Server starting on http://localhost:8080") + log.Println("Try: curl http://localhost:8080/health") + if err := mkhttp.Serve(":8080", router); err != nil { + log.Fatalf("serve: %v", err) + } +} diff --git a/examples/hello-sqlite/go.mod b/examples/hello-sqlite/go.mod new file mode 100644 index 0000000..ce961f2 --- /dev/null +++ b/examples/hello-sqlite/go.mod @@ -0,0 +1,12 @@ +module github.com/go-modkit/modkit/examples/hello-sqlite + +go 1.25.7 + +require ( + github.com/go-modkit/modkit v0.0.0 + github.com/mattn/go-sqlite3 v1.14.22 +) + +require github.com/go-chi/chi/v5 v5.2.4 // indirect + +replace github.com/go-modkit/modkit => ../.. diff --git a/examples/hello-sqlite/go.sum b/examples/hello-sqlite/go.sum new file mode 100644 index 0000000..487fcd1 --- /dev/null +++ b/examples/hello-sqlite/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= +github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/hello-sqlite/internal/app/module.go b/examples/hello-sqlite/internal/app/module.go new file mode 100644 index 0000000..1a37f5c --- /dev/null +++ b/examples/hello-sqlite/internal/app/module.go @@ -0,0 +1,21 @@ +package app + +import ( + "github.com/go-modkit/modkit/modkit/data/sqlite" + "github.com/go-modkit/modkit/modkit/module" +) + +type Module struct{} + +func NewModule() module.Module { + return &Module{} +} + +func (m *Module) Definition() module.ModuleDef { + return module.ModuleDef{ + Name: "app", + Imports: []module.Module{ + sqlite.NewModule(sqlite.Options{}), + }, + } +} diff --git a/examples/hello-sqlite/internal/smoke/smoke_test.go b/examples/hello-sqlite/internal/smoke/smoke_test.go new file mode 100644 index 0000000..b891844 --- /dev/null +++ b/examples/hello-sqlite/internal/smoke/smoke_test.go @@ -0,0 +1,61 @@ +package smoke + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" + + "github.com/go-modkit/modkit/examples/hello-sqlite/internal/app" + "github.com/go-modkit/modkit/modkit/data/sqlmodule" + "github.com/go-modkit/modkit/modkit/testkit" +) + +func TestSmoke_SQLite_FileBacked(t *testing.T) { + path := filepath.Join(t.TempDir(), "app.db") + t.Setenv("SQLITE_PATH", path) + t.Setenv("SQLITE_CONNECT_TIMEOUT", "2s") + + h := testkit.New(t, app.NewModule()) + db := testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + dialect := testkit.Get[sqlmodule.Dialect](t, h, sqlmodule.TokenDialect) + if dialect != sqlmodule.DialectSQLite { + t.Fatalf("unexpected dialect: %q", dialect) + } + + roundTripSQLite(t, db) +} + +func TestSmoke_SQLite_InMemory(t *testing.T) { + t.Setenv("SQLITE_PATH", "file:memdb1?mode=memory&cache=shared") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "2s") + + h := testkit.New(t, app.NewModule()) + db := testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + roundTripSQLite(t, db) +} + +func roundTripSQLite(t *testing.T, db *sql.DB) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`); err != nil { + t.Fatalf("create table: %v", err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO users (id, name) VALUES (1, 'Ada')`); err != nil { + t.Fatalf("insert: %v", err) + } + var name string + if err := db.QueryRowContext(ctx, `SELECT name FROM users WHERE id = 1`).Scan(&name); err != nil { + t.Fatalf("select: %v", err) + } + if name != "Ada" { + t.Fatalf("unexpected name: %q", name) + } +} diff --git a/go.work b/go.work index b250d8c..8ae9271 100644 --- a/go.work +++ b/go.work @@ -2,6 +2,8 @@ go 1.25.7 use ( . + ./examples/hello-postgres + ./examples/hello-sqlite ./examples/hello-mysql ./examples/hello-simple ) diff --git a/go.work.sum b/go.work.sum index 310fbcf..eaae63c 100644 --- a/go.work.sum +++ b/go.work.sum @@ -226,9 +226,8 @@ github.com/klauspost/cpuid/v2 v2.0.4 h1:g0I61F2K2DjRHz1cnxlkNSBIaePVoJIjjnHui8QH github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo= github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -277,6 +276,7 @@ github.com/otiai10/curr v1.0.0 h1:TJIWdbX0B+kpNagQrjgq8bCMrbhiuX73M2XwgtDMoOI= github.com/otiai10/mint v1.3.1 h1:BCmzIS3n71sGfHB5NMNDB3lHYPz8fWSkCAErHed//qc= github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d h1:CdDQnGF8Nq9ocOS/xlSptM1N3BbrA6/kmaep5ggwaIA= github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d/go.mod h1:3OzsM7FXDQlpCiw2j81fOmAwQLnZnLGXVKUzeKQXIAw= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/posener/complete v1.1.1 h1:ccV59UEOTzVDnDUEFdT95ZzHVZ+5+158q8+SJb2QV5w= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= @@ -289,6 +289,7 @@ github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5 github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71 h1:CNooiryw5aisadVfzneSZPswRWvnVW8hF1bS/vo8ReI= github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71/go.mod h1:4cgAphtvu7Ftv7vOT2ZOYhC6CvBxZixcasr8qIOTA50= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= @@ -355,10 +356,12 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.5 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 h1:3d+S281UTjM+AbF31XSOYn1qXn3BgIdWl8HNEpx08Jk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0/go.mod h1:0+KuTDyKL4gjKCF75pHOX4wuzYDUZYfAQdSu43o+Z2I= go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= @@ -366,6 +369,8 @@ golang.org/x/lint v0.0.0-20200302205851-738671d3881b h1:Wh+f8QHJXR411sJR8/vRBTZ7 golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028 h1:4+4C/Iv2U4fMZBiMCc98MG1In4gJY5YRhtpDNeDeHWs= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU= golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= @@ -373,6 +378,11 @@ golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= @@ -393,6 +403,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20250219182151-9fdb1cabc7b2/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= From 6219c0637bf18ff22928161724fa52d4c68c09ae Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Mon, 9 Feb 2026 16:57:39 +0200 Subject: [PATCH 06/31] feat(example-postgres): add httpserver smoke wiring Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- examples/hello-postgres/cmd/api/main.go | 46 ++----------- .../hello-postgres/internal/app/module.go | 5 ++ .../internal/httpserver/server.go | 68 +++++++++++++++++++ .../internal/smoke/smoke_test.go | 50 ++++++++++++-- 4 files changed, 122 insertions(+), 47 deletions(-) create mode 100644 examples/hello-postgres/internal/httpserver/server.go diff --git a/examples/hello-postgres/cmd/api/main.go b/examples/hello-postgres/cmd/api/main.go index 0b0244c..b5a82ec 100644 --- a/examples/hello-postgres/cmd/api/main.go +++ b/examples/hello-postgres/cmd/api/main.go @@ -2,58 +2,22 @@ package main import ( "log" - "net/http" _ "github.com/lib/pq" - "github.com/go-modkit/modkit/examples/hello-postgres/internal/app" + "github.com/go-modkit/modkit/examples/hello-postgres/internal/httpserver" mkhttp "github.com/go-modkit/modkit/modkit/http" - "github.com/go-modkit/modkit/modkit/kernel" - "github.com/go-modkit/modkit/modkit/module" ) -type HealthController struct{} - -func (c *HealthController) RegisterRoutes(r mkhttp.Router) { - r.Handle(http.MethodGet, "/health", http.HandlerFunc(c.health)) -} - -func (c *HealthController) health(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok")) -} - -type RootModule struct{} - -func (m *RootModule) Definition() module.ModuleDef { - return module.ModuleDef{ - Name: "api", - Imports: []module.Module{ - app.NewModule(), - }, - Controllers: []module.ControllerDef{{ - Name: "HealthController", - Build: func(_ module.Resolver) (any, error) { - return &HealthController{}, nil - }, - }}, - } -} - func main() { - app, err := kernel.Bootstrap(&RootModule{}) + handler, err := httpserver.BuildHandler() if err != nil { - log.Fatalf("bootstrap: %v", err) - } - - router := mkhttp.NewRouter() - if err := mkhttp.RegisterRoutes(mkhttp.AsRouter(router), app.Controllers); err != nil { - log.Fatalf("routes: %v", err) + log.Fatalf("build handler: %v", err) } log.Println("Server starting on http://localhost:8080") - log.Println("Try: curl http://localhost:8080/health") - if err := mkhttp.Serve(":8080", router); err != nil { + log.Println("Try: curl http://localhost:8080/api/v1/health") + if err := mkhttp.Serve(":8080", handler); err != nil { log.Fatalf("serve: %v", err) } } diff --git a/examples/hello-postgres/internal/app/module.go b/examples/hello-postgres/internal/app/module.go index 4e5e56e..f1c43f6 100644 --- a/examples/hello-postgres/internal/app/module.go +++ b/examples/hello-postgres/internal/app/module.go @@ -2,6 +2,7 @@ package app import ( "github.com/go-modkit/modkit/modkit/data/postgres" + "github.com/go-modkit/modkit/modkit/data/sqlmodule" "github.com/go-modkit/modkit/modkit/module" ) @@ -17,5 +18,9 @@ func (m *Module) Definition() module.ModuleDef { Imports: []module.Module{ postgres.NewModule(postgres.Options{}), }, + Exports: []module.Token{ + sqlmodule.TokenDB, + sqlmodule.TokenDialect, + }, } } diff --git a/examples/hello-postgres/internal/httpserver/server.go b/examples/hello-postgres/internal/httpserver/server.go new file mode 100644 index 0000000..9499443 --- /dev/null +++ b/examples/hello-postgres/internal/httpserver/server.go @@ -0,0 +1,68 @@ +package httpserver + +import ( + "encoding/json" + "net/http" + + "github.com/go-modkit/modkit/examples/hello-postgres/internal/app" + modkithttp "github.com/go-modkit/modkit/modkit/http" + "github.com/go-modkit/modkit/modkit/kernel" + "github.com/go-modkit/modkit/modkit/module" +) + +type HealthController struct{} + +func (c *HealthController) RegisterRoutes(r modkithttp.Router) { + r.Handle(http.MethodGet, "/health", http.HandlerFunc(c.health)) +} + +func (c *HealthController) health(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +type RootModule struct{} + +func (m *RootModule) Definition() module.ModuleDef { + return module.ModuleDef{ + Name: "api", + Imports: []module.Module{ + app.NewModule(), + }, + Controllers: []module.ControllerDef{{ + Name: "HealthController", + Build: func(_ module.Resolver) (any, error) { + return &HealthController{}, nil + }, + }}, + } +} + +var registerRoutes = modkithttp.RegisterRoutes + +func BuildAppHandler() (*kernel.App, http.Handler, error) { + boot, err := kernel.Bootstrap(&RootModule{}) + if err != nil { + return nil, nil, err + } + + router := modkithttp.NewRouter() + root := modkithttp.AsRouter(router) + + var registerErr error + root.Group("/api/v1", func(r modkithttp.Router) { + if err := registerRoutes(r, boot.Controllers); err != nil { + registerErr = err + } + }) + if registerErr != nil { + return boot, nil, registerErr + } + + return boot, router, nil +} + +func BuildHandler() (http.Handler, error) { + _, handler, err := BuildAppHandler() + return handler, err +} diff --git a/examples/hello-postgres/internal/smoke/smoke_test.go b/examples/hello-postgres/internal/smoke/smoke_test.go index 9956c66..2ba6201 100644 --- a/examples/hello-postgres/internal/smoke/smoke_test.go +++ b/examples/hello-postgres/internal/smoke/smoke_test.go @@ -1,10 +1,13 @@ package smoke import ( + "bytes" "context" "database/sql" "errors" "fmt" + "net/http" + "net/http/httptest" "os/exec" "testing" "time" @@ -13,12 +16,11 @@ import ( "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" - "github.com/go-modkit/modkit/examples/hello-postgres/internal/app" + "github.com/go-modkit/modkit/examples/hello-postgres/internal/httpserver" "github.com/go-modkit/modkit/modkit/data/sqlmodule" - "github.com/go-modkit/modkit/modkit/testkit" ) -func TestSmoke_Postgres_ModuleBootsAndQueries(t *testing.T) { +func TestSmoke_Postgres_ModuleBootsAndServes(t *testing.T) { requireDocker(t) ctx := context.Background() @@ -30,10 +32,46 @@ func TestSmoke_Postgres_ModuleBootsAndQueries(t *testing.T) { t.Setenv("POSTGRES_DSN", dsn) t.Setenv("POSTGRES_CONNECT_TIMEOUT", "2s") - h := testkit.New(t, app.NewModule()) + boot, handler, err := httpserver.BuildAppHandler() + if err != nil { + t.Fatalf("build handler failed: %v", err) + } + + srv := httptest.NewServer(handler) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/api/v1/health") + if err != nil { + t.Fatalf("health request failed: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(resp.Body) + if got := bytes.TrimSpace(buf.Bytes()); len(got) == 0 { + t.Fatalf("expected non-empty body") + } + + dbAny, err := boot.Get(sqlmodule.TokenDB) + if err != nil { + t.Fatalf("resolve db: %v", err) + } + db, ok := dbAny.(*sql.DB) + if !ok { + t.Fatalf("unexpected db type: %T", dbAny) + } - db := testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) - dialect := testkit.Get[sqlmodule.Dialect](t, h, sqlmodule.TokenDialect) + dialectAny, err := boot.Get(sqlmodule.TokenDialect) + if err != nil { + t.Fatalf("resolve dialect: %v", err) + } + dialect, ok := dialectAny.(sqlmodule.Dialect) + if !ok { + t.Fatalf("unexpected dialect type: %T", dialectAny) + } if dialect != sqlmodule.DialectPostgres { t.Fatalf("unexpected dialect: %q", dialect) } From 5e2d9dcd9fc82fdaee1aca27dfd453d62752dec4 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Mon, 9 Feb 2026 16:58:00 +0200 Subject: [PATCH 07/31] chore(workspace): update go.work.sum Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- go.work.sum | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/go.work.sum b/go.work.sum index eaae63c..afc72cf 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,3 +1,4 @@ +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w= @@ -11,6 +12,7 @@ cloud.google.com/go/compute v1.6.1 h1:2sMmt8prCn7DPaG4Pmh0N3Inmc8cT8ae5k1M6VJ9Wq cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= cloud.google.com/go/datastore v1.1.0 h1:/May9ojXjRkPBNVrq+oWLqmWCkr4OU5uRY29bu0mRyQ= cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw= cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= @@ -22,6 +24,7 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9 h1:VpgP7xuJadIUu github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 h1:59MxjQVfjXsBpLy+dbd2/ELV5ofnUkUZBvWSC85sheA= github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0/go.mod h1:OahwfttHWG6eJ0clwcfBAHoDI6X/LV/15hx/wlMZSrU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802 h1:1BDTz0u9nC3//pOCMdNH+CiXJVYJh5UQNCOBG7jbELc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0/go.mod h1:Cz6ft6Dkn3Et6l2v2a9/RpN7epQ1GtDlO6lj8bEcOvw= github.com/Masterminds/goutils v1.1.0 h1:zukEsf/1JZwCMgHiK3GZftabmxiCw4apj3a28RPBiVg= github.com/Masterminds/goutils v1.1.0/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -38,6 +41,7 @@ github.com/Rhymond/go-money v1.0.15 h1:rdcIcO8FxCqEwBSt5VZf4hLMfovtcDIiY5/cQWE+7 github.com/Rhymond/go-money v1.0.15/go.mod h1:iHvCuIvitxu2JIlAlhF0g9jHqjRSr+rpdOs7Omqlupg= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/go-metrics v0.3.10 h1:FR+drcQStOe+32sYyJYyZ7FIdgoGGBnwLl+flodp8Uo= github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310 h1:BUAU3CGlLvorLI26FmByPp2eC2qla6E1Tw+scpcg/to= @@ -64,6 +68,7 @@ github.com/cilium/ebpf v0.9.1 h1:64sn2K3UKw8NbP/blsixRpF3nXuyhz/VjRlRzvlBRu4= github.com/cilium/ebpf v0.9.1/go.mod h1:+OhNOIXx/Fnu1IE8bJz2dzOA+VSfyTfdNUVdlQnxUFY= github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f h1:WBZRG4aNOuI15bLRrCgN8fCq8E5Xuty6jGbmSNEvSsU= +github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/containerd/aufs v1.0.0 h1:2oeJiwX5HstO7shSrPZjrohJZLzK36wvpdmzDRkL/LY= github.com/containerd/aufs v1.0.0/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU= github.com/containerd/btrfs/v2 v2.0.0 h1:FN4wsx7KQrYoLXN7uLP0vBV4oVWHOIKDRQ1G2Z0oL5M= @@ -125,7 +130,11 @@ github.com/dromara/carbon/v2 v2.6.15/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5 github.com/emicklei/go-restful/v3 v3.10.1 h1:rc42Y5YTp7Am7CS630D7JmhRjq4UlEUuEKfrDac4bSQ= github.com/emicklei/go-restful/v3 v3.10.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.4 h1:rEvIZUSZ3fx39WIi3JkQqQBitGwpELBIYWeBVh6wn+E= +github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= +github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v0.1.0 h1:EQciDnbrYxy13PgWoY8AqoxGiPrpgBZ1R8UNe3ddc+A= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= @@ -134,6 +143,7 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4 h1:WtGNWLvXpe6ZudgnXrq0barxBImvnnJoMEhXAzcbM0I= github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= github.com/go-kit/kit v0.9.0 h1:wDJmvq38kDhkVxi50ni9ykkdUr1PKgqKOoi01fa0Mdk= github.com/go-kit/log v0.1.0 h1:DGJh0Sm43HbOeYDNnVZFl8BvcYVvjD5bqYJvp0REbwQ= github.com/go-logfmt/logfmt v0.5.0 h1:TrB8swr/68K7m9CcGut2g3UOihhbcbiMAYiuTXdEih4= @@ -145,6 +155,7 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= @@ -277,6 +288,7 @@ github.com/otiai10/mint v1.3.1 h1:BCmzIS3n71sGfHB5NMNDB3lHYPz8fWSkCAErHed//qc= github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d h1:CdDQnGF8Nq9ocOS/xlSptM1N3BbrA6/kmaep5ggwaIA= github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d/go.mod h1:3OzsM7FXDQlpCiw2j81fOmAwQLnZnLGXVKUzeKQXIAw= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/posener/complete v1.1.1 h1:ccV59UEOTzVDnDUEFdT95ZzHVZ+5+158q8+SJb2QV5w= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= @@ -289,6 +301,7 @@ github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5 github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4= github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71 h1:CNooiryw5aisadVfzneSZPswRWvnVW8hF1bS/vo8ReI= github.com/quasilyte/go-ruleguard/rules v0.0.0-20211022131956-028d6511ab71/go.mod h1:4cgAphtvu7Ftv7vOT2ZOYhC6CvBxZixcasr8qIOTA50= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= @@ -303,6 +316,7 @@ github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041 h1:llrF3Fs4018ePo github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 h1:pnnLyeX7o/5aX8qUQ69P/mLojDqwda8hFOCBTmP/6hw= github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6/go.mod h1:39R/xuhNgVhi+K0/zst4TLrJrVmbm6LVgl4A0+ZFS5M= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= @@ -333,6 +347,7 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ= go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= go.etcd.io/etcd/api/v3 v3.5.4 h1:OHVyt3TopwtUQ2GKdd5wu3PmmipR4FTwCqoEjSyRdIc= @@ -349,6 +364,7 @@ go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/detectors/gcp v1.36.0/go.mod h1:IbBN8uAIIx734PTonTPxAxnjc2pQTxWNkwfstZ+6H2k= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.45.0 h1:RsQi0qJ2imFfCvZabqzM9cNXBG8k6gXMv1A0cXRmH6A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.45.0/go.mod h1:vsh3ySueQCiKPxFLvjWC4Z135gIa34TQ/NSqkDTZYUM= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE= @@ -360,8 +376,10 @@ go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwEx go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 h1:3d+S281UTjM+AbF31XSOYn1qXn3BgIdWl8HNEpx08Jk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0/go.mod h1:0+KuTDyKL4gjKCF75pHOX4wuzYDUZYfAQdSu43o+Z2I= go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= @@ -376,6 +394,7 @@ golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU= golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= @@ -390,6 +409,7 @@ golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df h1:5Pf6pFKu98ODmgnpvkJ3kFUOQGGLIzLIkbzUHp47618= golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.223.0 h1:JUTaWEriXmEy5AhvdMgksGGPEFsYfUKaPEYXd4c3Wvc= google.golang.org/api v0.223.0/go.mod h1:C+RS7Z+dDwds2b+zoAk5hN/eSfsiCn0UDrYof/M4d2M= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= From 1b8fda523a974ef8e39e46405679b129da13a21b Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Mon, 9 Feb 2026 17:01:16 +0200 Subject: [PATCH 08/31] docs(data): add postgres and sqlite provider guides Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- docs/guides/database-providers.md | 117 ++++++++++++++++++++++++++++++ docs/guides/getting-started.md | 17 +++++ 2 files changed, 134 insertions(+) create mode 100644 docs/guides/database-providers.md diff --git a/docs/guides/database-providers.md b/docs/guides/database-providers.md new file mode 100644 index 0000000..0993b91 --- /dev/null +++ b/docs/guides/database-providers.md @@ -0,0 +1,117 @@ +# Database Providers + +modkit ships a shared SQL contract plus provider modules for Postgres and +SQLite. The goal is to keep feature modules database-agnostic while still +making driver wiring explicit and deterministic. + +## Shared SQL Contract + +Use the shared contract tokens from `modkit/data/sqlmodule`: + +```go +import "github.com/go-modkit/modkit/modkit/data/sqlmodule" + +db, err := module.Get[*sql.DB](r, sqlmodule.TokenDB) +if err != nil { + return nil, err +} +dialect, err := module.Get[sqlmodule.Dialect](r, sqlmodule.TokenDialect) +if err != nil { + return nil, err +} +``` + +The contract exports two stable tokens: + +- `sqlmodule.TokenDB` -> `*sql.DB` +- `sqlmodule.TokenDialect` -> `sqlmodule.Dialect` + +For multi-instance apps, use `sqlmodule.NamedTokens(name)` and pass the same +name into the provider module options. + +## Postgres Provider + +Package: `modkit/data/postgres` + +```go +import "github.com/go-modkit/modkit/modkit/data/postgres" + +module.ModuleDef{ + Name: "app", + Imports: []module.Module{ + postgres.NewModule(postgres.Options{}), + }, +} +``` + +Configuration: + +- Required: `POSTGRES_DSN` +- Optional: `POSTGRES_MAX_OPEN_CONNS`, `POSTGRES_MAX_IDLE_CONNS`, + `POSTGRES_CONN_MAX_LIFETIME`, `POSTGRES_CONNECT_TIMEOUT` + +`POSTGRES_CONNECT_TIMEOUT=0` skips the startup ping. Any non-zero duration +enables a timeout-bound `PingContext` during provider build. + +The provider is driver-agnostic. Import a driver in your app (for example, +`_ "github.com/lib/pq"`). + +## SQLite Provider + +Package: `modkit/data/sqlite` + +```go +import "github.com/go-modkit/modkit/modkit/data/sqlite" + +module.ModuleDef{ + Name: "app", + Imports: []module.Module{ + sqlite.NewModule(sqlite.Options{}), + }, +} +``` + +Configuration: + +- Required: `SQLITE_PATH` (path or DSN) +- Optional: `SQLITE_BUSY_TIMEOUT`, `SQLITE_JOURNAL_MODE`, + `SQLITE_CONNECT_TIMEOUT` + +`SQLITE_CONNECT_TIMEOUT=0` skips the startup ping. Any non-zero duration +enables a timeout-bound `PingContext` during provider build. + +Like Postgres, the module is driver-agnostic. Import a driver in your app (for +example, `_ "github.com/mattn/go-sqlite3"`). + +## Named Instances + +For multiple databases in one app, supply a name and use `NamedTokens`: + +```go +tokens, err := sqlmodule.NamedTokens("analytics") +if err != nil { + return err +} + +module.ModuleDef{ + Name: "app", + Imports: []module.Module{ + postgres.NewModule(postgres.Options{Name: "analytics"}), + }, + Exports: []module.Token{tokens.DB, tokens.Dialect}, +} +``` + +## Migration Note (MySQL -> Shared SQL Contract) + +The `hello-mysql` example preserves backward compatibility: it still exports +`database.TokenDB` (value `"database.db"`) and adds `database.TokenDialect`. +For new code, prefer the shared contract tokens (`sqlmodule.TokenDB` and +`sqlmodule.TokenDialect`) and keep driver-specific modules out of feature +packages. + +## Examples + +- `examples/hello-postgres` — Postgres provider + smoke test +- `examples/hello-sqlite` — SQLite provider + file/in-memory smoke tests +- `examples/hello-mysql` — legacy MySQL example (still compatible) diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 240350c..cfa6abe 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -129,12 +129,29 @@ curl http://localhost:8080/greet # Hello, modkit! ``` +## SQLite Fast Start (No Docker) + +If you want a quick database-backed example without Docker, try the SQLite +example module: + +```bash +export SQLITE_PATH="/tmp/modkit.db" +go run ./examples/hello-sqlite/cmd/api/main.go +``` + +Then: + +```bash +curl http://localhost:8080/health +``` + ## Next Steps - [Modules Guide](modules.md) — Learn about imports, exports, and visibility - [Testing Guide](testing.md) — Testing patterns for modkit apps - [Architecture Guide](../architecture.md) — How modkit works under the hood - [Example App](../../examples/hello-mysql/) — Full CRUD API with MySQL, migrations, and Swagger +- [SQLite Example](../../examples/hello-sqlite/) — Fast local eval, no Docker required ## Troubleshooting Quickstart From a60246e8544616d0c83c2f7336d19e254b53230c Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Mon, 9 Feb 2026 17:01:37 +0200 Subject: [PATCH 09/31] docs(readme): update database provider matrix Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index a6469bf..3f79499 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,12 @@ func main() { | Validation | [Validation Guide](docs/guides/validation.md) | [`examples/hello-mysql/internal/validation/`](examples/hello-mysql/internal/validation/) + [`examples/hello-mysql/internal/modules/users/types.go`](examples/hello-mysql/internal/modules/users/types.go) | [`examples/hello-mysql/internal/modules/users/validation_test.go`](examples/hello-mysql/internal/modules/users/validation_test.go) | | Middleware | [Middleware Guide](docs/guides/middleware.md) | [`examples/hello-mysql/internal/middleware/`](examples/hello-mysql/internal/middleware/) + [`examples/hello-mysql/internal/httpserver/server.go`](examples/hello-mysql/internal/httpserver/server.go) | [`examples/hello-mysql/internal/middleware/middleware_test.go`](examples/hello-mysql/internal/middleware/middleware_test.go) | | Lifecycle and Cleanup | [Lifecycle Guide](docs/guides/lifecycle.md) | [`examples/hello-mysql/internal/lifecycle/cleanup.go`](examples/hello-mysql/internal/lifecycle/cleanup.go) + [`examples/hello-mysql/cmd/api/main.go`](examples/hello-mysql/cmd/api/main.go) | [`examples/hello-mysql/internal/lifecycle/lifecycle_test.go`](examples/hello-mysql/internal/lifecycle/lifecycle_test.go) | +| Database Providers | [Database Providers Guide](docs/guides/database-providers.md) | [`examples/hello-postgres/`](examples/hello-postgres/) + [`examples/hello-sqlite/`](examples/hello-sqlite/) | [`examples/hello-postgres/internal/smoke/smoke_test.go`](examples/hello-postgres/internal/smoke/smoke_test.go) + [`examples/hello-sqlite/internal/smoke/smoke_test.go`](examples/hello-sqlite/internal/smoke/smoke_test.go) | + +Migration note: if you used the MySQL example tokens (`database.TokenDB`), +prefer the shared SQL contract tokens (`sqlmodule.TokenDB`, +`sqlmodule.TokenDialect`) going forward. The MySQL example preserves backward +compatibility via token aliases. ## Packages @@ -232,6 +238,8 @@ See [Architecture Guide](docs/architecture.md) for details. **Examples:** - [hello-simple](examples/hello-simple/) — Minimal example, no Docker - [hello-mysql](examples/hello-mysql/) — Full CRUD API with MySQL +- [hello-postgres](examples/hello-postgres/) — Postgres provider + smoke tests +- [hello-sqlite](examples/hello-sqlite/) — SQLite provider + fast local smoke tests ## How It Compares to NestJS From cd62042d619823a706be965fc46eefbc59efbb71 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Mon, 9 Feb 2026 17:05:29 +0200 Subject: [PATCH 10/31] ci(data): add smoke coverage for postgres and sqlite examples Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- .github/workflows/ci.yml | 4 ++++ .../hello-mysql/internal/smoke/smoke_test.go | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34b7e71..c9d6bda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,10 @@ jobs: go.mod - name: Test with coverage run: make test-coverage + - name: Example smoke (Postgres, SQLite) + run: | + go test ./examples/hello-postgres/... + go test ./examples/hello-sqlite/... - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: diff --git a/examples/hello-mysql/internal/smoke/smoke_test.go b/examples/hello-mysql/internal/smoke/smoke_test.go index 57b591c..6d55748 100644 --- a/examples/hello-mysql/internal/smoke/smoke_test.go +++ b/examples/hello-mysql/internal/smoke/smoke_test.go @@ -5,9 +5,11 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" + "os/exec" "testing" "time" @@ -18,6 +20,8 @@ import ( ) func TestSmoke_HealthAndUsers(t *testing.T) { + requireDocker(t) + ctx := context.Background() container, dsn := startMySQL(t, ctx) defer func() { @@ -100,6 +104,24 @@ func TestSmoke_HealthAndUsers(t *testing.T) { } } +func requireDocker(t *testing.T) { + t.Helper() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker binary not found") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "docker", "info") + if err := cmd.Run(); err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Skip("docker info timed out") + } + t.Skipf("docker unavailable: %v", err) + } +} + func startMySQL(t *testing.T, ctx context.Context) (testcontainers.Container, string) { req := testcontainers.ContainerRequest{ Image: "mysql:8.0", From 1616c6b4fe428feefd5e81c5b0fe9d1d5b24e461 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Mon, 9 Feb 2026 23:39:37 +0200 Subject: [PATCH 11/31] fix(data-postgres): support named module instances Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- modkit/data/postgres/config.go | 67 +++++++++++++++------------ modkit/data/postgres/module.go | 35 ++++++++++----- modkit/data/postgres/module_test.go | 70 +++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 39 deletions(-) diff --git a/modkit/data/postgres/config.go b/modkit/data/postgres/config.go index e8013bb..0d1ae50 100644 --- a/modkit/data/postgres/config.go +++ b/modkit/data/postgres/config.go @@ -1,12 +1,18 @@ package postgres import ( + "sync" "time" "github.com/go-modkit/modkit/modkit/config" "github.com/go-modkit/modkit/modkit/module" ) +var ( + defaultConfigOnce sync.Once + defaultConfig module.Module +) + // DefaultConfigModule provides Postgres configuration from environment variables. // // Required: @@ -18,33 +24,36 @@ import ( // - POSTGRES_CONN_MAX_LIFETIME // - POSTGRES_CONNECT_TIMEOUT (default 0; disables provider ping) func DefaultConfigModule() module.Module { - return config.NewModule( - config.WithTyped(TokenDSN, config.ValueSpec[string]{ - Key: "POSTGRES_DSN", - Required: true, - Sensitive: true, - Description: "Postgres DSN.", - Parse: config.ParseString, - }, true), - config.WithTyped(TokenMaxOpenConns, config.ValueSpec[int]{ - Key: "POSTGRES_MAX_OPEN_CONNS", - Description: "Maximum open connections for the DB pool.", - Parse: config.ParseInt, - }, true), - config.WithTyped(TokenMaxIdleConns, config.ValueSpec[int]{ - Key: "POSTGRES_MAX_IDLE_CONNS", - Description: "Maximum idle connections for the DB pool.", - Parse: config.ParseInt, - }, true), - config.WithTyped(TokenConnMaxLifetime, config.ValueSpec[time.Duration]{ - Key: "POSTGRES_CONN_MAX_LIFETIME", - Description: "Maximum amount of time a connection may be reused.", - Parse: config.ParseDuration, - }, true), - config.WithTyped(TokenConnectTimeout, config.ValueSpec[time.Duration]{ - Key: "POSTGRES_CONNECT_TIMEOUT", - Description: "Optional ping timeout on provider build. 0 disables ping.", - Parse: config.ParseDuration, - }, true), - ) + defaultConfigOnce.Do(func() { + defaultConfig = config.NewModule( + config.WithTyped(TokenDSN, config.ValueSpec[string]{ + Key: "POSTGRES_DSN", + Required: true, + Sensitive: true, + Description: "Postgres DSN.", + Parse: config.ParseString, + }, true), + config.WithTyped(TokenMaxOpenConns, config.ValueSpec[int]{ + Key: "POSTGRES_MAX_OPEN_CONNS", + Description: "Maximum open connections for the DB pool.", + Parse: config.ParseInt, + }, true), + config.WithTyped(TokenMaxIdleConns, config.ValueSpec[int]{ + Key: "POSTGRES_MAX_IDLE_CONNS", + Description: "Maximum idle connections for the DB pool.", + Parse: config.ParseInt, + }, true), + config.WithTyped(TokenConnMaxLifetime, config.ValueSpec[time.Duration]{ + Key: "POSTGRES_CONN_MAX_LIFETIME", + Description: "Maximum amount of time a connection may be reused.", + Parse: config.ParseDuration, + }, true), + config.WithTyped(TokenConnectTimeout, config.ValueSpec[time.Duration]{ + Key: "POSTGRES_CONNECT_TIMEOUT", + Description: "Optional ping timeout on provider build. 0 disables ping.", + Parse: config.ParseDuration, + }, true), + ) + }) + return defaultConfig } diff --git a/modkit/data/postgres/module.go b/modkit/data/postgres/module.go index bda2e74..3e94787 100644 --- a/modkit/data/postgres/module.go +++ b/modkit/data/postgres/module.go @@ -10,7 +10,10 @@ import ( "github.com/go-modkit/modkit/modkit/module" ) -const driverName = "postgres" +const ( + driverName = "postgres" + moduleNameBase = "data.postgres" +) // Options configures a Postgres provider module. type Options struct { @@ -42,19 +45,12 @@ func (m *Module) Definition() module.ModuleDef { toks, err := sqlmodule.NamedTokens(m.opts.Name) if err != nil { - return module.ModuleDef{ - Name: "data.postgres", - Imports: []module.Module{configMod}, - Providers: []module.ProviderDef{{ - Token: "data.postgres.invalid", - Build: func(_ module.Resolver) (any, error) { return nil, err }, - }}, - } + return invalidModuleDef(err) } var db *sql.DB return module.ModuleDef{ - Name: "data.postgres", + Name: moduleName(m.opts.Name), Imports: []module.Module{configMod}, Providers: []module.ProviderDef{ { @@ -82,6 +78,25 @@ func (m *Module) Definition() module.ModuleDef { } } +func moduleName(name string) string { + if name == "" { + return moduleNameBase + } + return moduleNameBase + "." + name +} + +func invalidModuleDef(err error) module.ModuleDef { + return module.ModuleDef{ + Name: moduleNameBase + ".invalid", + Controllers: []module.ControllerDef{{ + Name: "InvalidPostgresModule", + Build: func(_ module.Resolver) (any, error) { + return nil, err + }, + }}, + } +} + func buildDB(r module.Resolver, dbToken module.Token) (*sql.DB, error) { dsn, err := module.Get[string](r, TokenDSN) if err != nil { diff --git a/modkit/data/postgres/module_test.go b/modkit/data/postgres/module_test.go index cfddf85..4725a9e 100644 --- a/modkit/data/postgres/module_test.go +++ b/modkit/data/postgres/module_test.go @@ -9,6 +9,8 @@ import ( "testing" "github.com/go-modkit/modkit/modkit/data/sqlmodule" + "github.com/go-modkit/modkit/modkit/kernel" + "github.com/go-modkit/modkit/modkit/module" "github.com/go-modkit/modkit/modkit/testkit" ) @@ -169,3 +171,71 @@ func TestPingFailureReturnsTypedBuildErrorAndClosesDB(t *testing.T) { t.Fatalf("expected ping failure path to close the DB") } } + +func TestMultiplePostgresInstancesBootstrap(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") + + primaryTokens, err := sqlmodule.NamedTokens("primary") + if err != nil { + t.Fatalf("primary tokens: %v", err) + } + analyticsTokens, err := sqlmodule.NamedTokens("analytics") + if err != nil { + t.Fatalf("analytics tokens: %v", err) + } + + root := &multiInstanceRootModule{ + imports: []module.Module{ + NewModule(Options{Name: "primary"}), + NewModule(Options{Name: "analytics"}), + }, + exports: []module.Token{ + primaryTokens.DB, + primaryTokens.Dialect, + analyticsTokens.DB, + analyticsTokens.Dialect, + }, + } + app, err := kernel.Bootstrap(root) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + + if _, err := app.Get(primaryTokens.DB); err != nil { + t.Fatalf("primary db: %v", err) + } + if _, err := app.Get(analyticsTokens.DB); err != nil { + t.Fatalf("analytics db: %v", err) + } +} + +func TestInvalidNameFailsAtBootstrap(t *testing.T) { + root := &multiInstanceRootModule{ + imports: []module.Module{ + NewModule(Options{Name: "bad name"}), + }, + } + _, err := kernel.Bootstrap(root) + if err == nil { + t.Fatal("expected bootstrap error") + } + var invalidNameErr *sqlmodule.InvalidNameError + if !errors.As(err, &invalidNameErr) { + t.Fatalf("expected InvalidNameError, got %T", err) + } +} + +type multiInstanceRootModule struct { + imports []module.Module + exports []module.Token +} + +func (m *multiInstanceRootModule) Definition() module.ModuleDef { + return module.ModuleDef{ + Name: "root", + Imports: m.imports, + Exports: m.exports, + } +} From 4efcd377d8a84062d7a32aa00959fc5bb2c01b73 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Mon, 9 Feb 2026 23:40:01 +0200 Subject: [PATCH 12/31] fix(data-sqlite): support named module instances Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- modkit/data/sqlite/config.go | 55 ++++++++++++++---------- modkit/data/sqlite/module.go | 35 +++++++++++----- modkit/data/sqlite/module_test.go | 70 +++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 33 deletions(-) diff --git a/modkit/data/sqlite/config.go b/modkit/data/sqlite/config.go index c3d1259..250493a 100644 --- a/modkit/data/sqlite/config.go +++ b/modkit/data/sqlite/config.go @@ -1,12 +1,18 @@ package sqlite import ( + "sync" "time" "github.com/go-modkit/modkit/modkit/config" "github.com/go-modkit/modkit/modkit/module" ) +var ( + defaultConfigOnce sync.Once + defaultConfig module.Module +) + // DefaultConfigModule provides SQLite configuration from environment variables. // // Required: @@ -17,27 +23,30 @@ import ( // - SQLITE_JOURNAL_MODE // - SQLITE_CONNECT_TIMEOUT (default 0; disables provider ping) func DefaultConfigModule() module.Module { - return config.NewModule( - config.WithTyped(TokenPath, config.ValueSpec[string]{ - Key: "SQLITE_PATH", - Required: true, - Description: "SQLite database path or DSN.", - Parse: config.ParseString, - }, true), - config.WithTyped(TokenBusyTimeout, config.ValueSpec[time.Duration]{ - Key: "SQLITE_BUSY_TIMEOUT", - Description: "Optional busy timeout to apply to the DSN.", - Parse: config.ParseDuration, - }, true), - config.WithTyped(TokenJournalMode, config.ValueSpec[string]{ - Key: "SQLITE_JOURNAL_MODE", - Description: "Optional journal mode to apply to the DSN.", - Parse: config.ParseString, - }, true), - config.WithTyped(TokenConnectTimeout, config.ValueSpec[time.Duration]{ - Key: "SQLITE_CONNECT_TIMEOUT", - Description: "Optional ping timeout on provider build. 0 disables ping.", - Parse: config.ParseDuration, - }, true), - ) + defaultConfigOnce.Do(func() { + defaultConfig = config.NewModule( + config.WithTyped(TokenPath, config.ValueSpec[string]{ + Key: "SQLITE_PATH", + Required: true, + Description: "SQLite database path or DSN.", + Parse: config.ParseString, + }, true), + config.WithTyped(TokenBusyTimeout, config.ValueSpec[time.Duration]{ + Key: "SQLITE_BUSY_TIMEOUT", + Description: "Optional busy timeout to apply to the DSN.", + Parse: config.ParseDuration, + }, true), + config.WithTyped(TokenJournalMode, config.ValueSpec[string]{ + Key: "SQLITE_JOURNAL_MODE", + Description: "Optional journal mode to apply to the DSN.", + Parse: config.ParseString, + }, true), + config.WithTyped(TokenConnectTimeout, config.ValueSpec[time.Duration]{ + Key: "SQLITE_CONNECT_TIMEOUT", + Description: "Optional ping timeout on provider build. 0 disables ping.", + Parse: config.ParseDuration, + }, true), + ) + }) + return defaultConfig } diff --git a/modkit/data/sqlite/module.go b/modkit/data/sqlite/module.go index 2812b00..bb003a3 100644 --- a/modkit/data/sqlite/module.go +++ b/modkit/data/sqlite/module.go @@ -13,7 +13,10 @@ import ( "github.com/go-modkit/modkit/modkit/module" ) -const driverName = "sqlite3" +const ( + driverName = "sqlite3" + moduleNameBase = "data.sqlite" +) // Options configures a SQLite provider module. type Options struct { @@ -45,19 +48,12 @@ func (m *Module) Definition() module.ModuleDef { toks, err := sqlmodule.NamedTokens(m.opts.Name) if err != nil { - return module.ModuleDef{ - Name: "data.sqlite", - Imports: []module.Module{configMod}, - Providers: []module.ProviderDef{{ - Token: "data.sqlite.invalid", - Build: func(_ module.Resolver) (any, error) { return nil, err }, - }}, - } + return invalidModuleDef(err) } var db *sql.DB return module.ModuleDef{ - Name: "data.sqlite", + Name: moduleName(m.opts.Name), Imports: []module.Module{configMod}, Providers: []module.ProviderDef{ { @@ -85,6 +81,25 @@ func (m *Module) Definition() module.ModuleDef { } } +func moduleName(name string) string { + if name == "" { + return moduleNameBase + } + return moduleNameBase + "." + name +} + +func invalidModuleDef(err error) module.ModuleDef { + return module.ModuleDef{ + Name: moduleNameBase + ".invalid", + Controllers: []module.ControllerDef{{ + Name: "InvalidSQLiteModule", + Build: func(_ module.Resolver) (any, error) { + return nil, err + }, + }}, + } +} + func buildDB(r module.Resolver, dbToken module.Token) (*sql.DB, error) { path, err := module.Get[string](r, TokenPath) if err != nil { diff --git a/modkit/data/sqlite/module_test.go b/modkit/data/sqlite/module_test.go index d7a8564..5c1f684 100644 --- a/modkit/data/sqlite/module_test.go +++ b/modkit/data/sqlite/module_test.go @@ -11,6 +11,8 @@ import ( "testing" "github.com/go-modkit/modkit/modkit/data/sqlmodule" + "github.com/go-modkit/modkit/modkit/kernel" + "github.com/go-modkit/modkit/modkit/module" "github.com/go-modkit/modkit/modkit/testkit" ) @@ -270,3 +272,71 @@ func TestCleanupClosesDB(t *testing.T) { t.Fatalf("expected cleanup to close a DB connection") } } + +func TestMultipleSQLiteInstancesBootstrap(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "file:memdb1?mode=memory&cache=shared") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "0") + + primaryTokens, err := sqlmodule.NamedTokens("primary") + if err != nil { + t.Fatalf("primary tokens: %v", err) + } + analyticsTokens, err := sqlmodule.NamedTokens("analytics") + if err != nil { + t.Fatalf("analytics tokens: %v", err) + } + + root := &multiInstanceRootModule{ + imports: []module.Module{ + NewModule(Options{Name: "primary"}), + NewModule(Options{Name: "analytics"}), + }, + exports: []module.Token{ + primaryTokens.DB, + primaryTokens.Dialect, + analyticsTokens.DB, + analyticsTokens.Dialect, + }, + } + app, err := kernel.Bootstrap(root) + if err != nil { + t.Fatalf("bootstrap: %v", err) + } + + if _, err := app.Get(primaryTokens.DB); err != nil { + t.Fatalf("primary db: %v", err) + } + if _, err := app.Get(analyticsTokens.DB); err != nil { + t.Fatalf("analytics db: %v", err) + } +} + +func TestInvalidNameFailsAtBootstrap(t *testing.T) { + root := &multiInstanceRootModule{ + imports: []module.Module{ + NewModule(Options{Name: "bad name"}), + }, + } + _, err := kernel.Bootstrap(root) + if err == nil { + t.Fatal("expected bootstrap error") + } + var invalidNameErr *sqlmodule.InvalidNameError + if !errors.As(err, &invalidNameErr) { + t.Fatalf("expected InvalidNameError, got %T", err) + } +} + +type multiInstanceRootModule struct { + imports []module.Module + exports []module.Token +} + +func (m *multiInstanceRootModule) Definition() module.ModuleDef { + return module.ModuleDef{ + Name: "root", + Imports: m.imports, + Exports: m.exports, + } +} From 8459f0a2bcc321b39a315ef5ff66dd67d3ac7b37 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 02:51:44 +0200 Subject: [PATCH 13/31] feat(examples): add compose setups for examples Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- examples/hello-mysql/Dockerfile | 2 +- examples/hello-postgres/Dockerfile | 9 ++++++++ examples/hello-postgres/docker-compose.yml | 27 ++++++++++++++++++++++ examples/hello-simple/Dockerfile | 9 ++++++++ examples/hello-simple/docker-compose.yml | 7 ++++++ examples/hello-sqlite/Dockerfile | 13 +++++++++++ examples/hello-sqlite/docker-compose.yml | 16 +++++++++++++ 7 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 examples/hello-postgres/Dockerfile create mode 100644 examples/hello-postgres/docker-compose.yml create mode 100644 examples/hello-simple/Dockerfile create mode 100644 examples/hello-simple/docker-compose.yml create mode 100644 examples/hello-sqlite/Dockerfile create mode 100644 examples/hello-sqlite/docker-compose.yml diff --git a/examples/hello-mysql/Dockerfile b/examples/hello-mysql/Dockerfile index a59d0b0..7882e20 100644 --- a/examples/hello-mysql/Dockerfile +++ b/examples/hello-mysql/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25 +FROM golang:1.25.7 WORKDIR /repo/examples/hello-mysql diff --git a/examples/hello-postgres/Dockerfile b/examples/hello-postgres/Dockerfile new file mode 100644 index 0000000..49a55bd --- /dev/null +++ b/examples/hello-postgres/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1.25.7 + +WORKDIR /repo/examples/hello-postgres + +COPY . /repo + +RUN go mod download + +CMD ["go", "run", "./cmd/api"] diff --git a/examples/hello-postgres/docker-compose.yml b/examples/hello-postgres/docker-compose.yml new file mode 100644 index 0000000..2e4a276 --- /dev/null +++ b/examples/hello-postgres/docker-compose.yml @@ -0,0 +1,27 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: app + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d app"] + interval: 5s + timeout: 5s + retries: 10 + app: + build: + context: ../.. + dockerfile: examples/hello-postgres/Dockerfile + environment: + HTTP_ADDR: ":8080" + POSTGRES_DSN: "postgres://postgres:password@postgres:5432/app?sslmode=disable" + LOG_LEVEL: "debug" + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy diff --git a/examples/hello-simple/Dockerfile b/examples/hello-simple/Dockerfile new file mode 100644 index 0000000..3c99aa6 --- /dev/null +++ b/examples/hello-simple/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1.25.7 + +WORKDIR /repo/examples/hello-simple + +COPY . /repo + +RUN go mod download + +CMD ["go", "run", "main.go"] diff --git a/examples/hello-simple/docker-compose.yml b/examples/hello-simple/docker-compose.yml new file mode 100644 index 0000000..a654857 --- /dev/null +++ b/examples/hello-simple/docker-compose.yml @@ -0,0 +1,7 @@ +services: + app: + build: + context: ../.. + dockerfile: examples/hello-simple/Dockerfile + ports: + - "8080:8080" diff --git a/examples/hello-sqlite/Dockerfile b/examples/hello-sqlite/Dockerfile new file mode 100644 index 0000000..f72ce7b --- /dev/null +++ b/examples/hello-sqlite/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.25.7 + +WORKDIR /repo/examples/hello-sqlite + +COPY . /repo + +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc libsqlite3-dev \ + && rm -rf /var/lib/apt/lists/* + +RUN go mod download + +CMD ["go", "run", "./cmd/api"] diff --git a/examples/hello-sqlite/docker-compose.yml b/examples/hello-sqlite/docker-compose.yml new file mode 100644 index 0000000..ce3832f --- /dev/null +++ b/examples/hello-sqlite/docker-compose.yml @@ -0,0 +1,16 @@ +services: + app: + build: + context: ../.. + dockerfile: examples/hello-sqlite/Dockerfile + environment: + HTTP_ADDR: ":8080" + SQLITE_PATH: "/data/app.db" + SQLITE_CONNECT_TIMEOUT: "0" + ports: + - "8080:8080" + volumes: + - sqlite_data:/data + +volumes: + sqlite_data: From 63e8608943ec66571b263b44eb75604f94e97002 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 02:51:57 +0200 Subject: [PATCH 14/31] docs(examples): add compose run instructions Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- examples/hello-postgres/README.md | 29 +++++++++++++++++++++++++++++ examples/hello-simple/README.md | 9 +++++++++ examples/hello-sqlite/README.md | 30 ++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 examples/hello-postgres/README.md create mode 100644 examples/hello-sqlite/README.md diff --git a/examples/hello-postgres/README.md b/examples/hello-postgres/README.md new file mode 100644 index 0000000..1834450 --- /dev/null +++ b/examples/hello-postgres/README.md @@ -0,0 +1,29 @@ +# hello-postgres + +Example consuming app for modkit using Postgres. + +## Run + +```bash +go run ./cmd/api +``` + +Then hit: + +```bash +curl http://localhost:8080/api/v1/health +``` + +## Run with Docker Compose + +```bash +docker compose up -d --build +curl http://localhost:8080/api/v1/health +docker compose down -v +``` + +## Configuration + +Environment variables: +- `HTTP_ADDR` (default `:8080`) +- `POSTGRES_DSN` (example `postgres://postgres:password@localhost:5432/app?sslmode=disable`) diff --git a/examples/hello-simple/README.md b/examples/hello-simple/README.md index 7a9c3ba..499297b 100644 --- a/examples/hello-simple/README.md +++ b/examples/hello-simple/README.md @@ -26,6 +26,15 @@ A minimal modkit example with no external dependencies (no Docker, no database). go run main.go ``` +## Run with Docker Compose + +```bash +docker compose up -d --build +curl http://localhost:8080/health +curl http://localhost:8080/greet +docker compose down -v +``` + Print the module graph while starting the server: ```bash diff --git a/examples/hello-sqlite/README.md b/examples/hello-sqlite/README.md new file mode 100644 index 0000000..3694777 --- /dev/null +++ b/examples/hello-sqlite/README.md @@ -0,0 +1,30 @@ +# hello-sqlite + +Example consuming app for modkit using SQLite. + +## Run + +```bash +go run ./cmd/api +``` + +Then hit: + +```bash +curl http://localhost:8080/health +``` + +## Run with Docker Compose + +```bash +docker compose up -d --build +curl http://localhost:8080/health +docker compose down -v +``` + +## Configuration + +Environment variables: +- `HTTP_ADDR` (default `:8080`) +- `SQLITE_PATH` (example `/tmp/app.db`) +- `SQLITE_CONNECT_TIMEOUT` (default `0`) From 7d1a8eb235ec630a91f96ff6b89b2552614c96b6 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 02:53:42 +0200 Subject: [PATCH 15/31] fix(example-postgres): handle health encode errors Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- .../internal/httpserver/server.go | 4 +- .../internal/httpserver/server_test.go | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 examples/hello-postgres/internal/httpserver/server_test.go diff --git a/examples/hello-postgres/internal/httpserver/server.go b/examples/hello-postgres/internal/httpserver/server.go index 9499443..9c70111 100644 --- a/examples/hello-postgres/internal/httpserver/server.go +++ b/examples/hello-postgres/internal/httpserver/server.go @@ -18,7 +18,9 @@ func (c *HealthController) RegisterRoutes(r modkithttp.Router) { func (c *HealthController) health(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + if err := json.NewEncoder(w).Encode(map[string]string{"status": "ok"}); err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } } type RootModule struct{} diff --git a/examples/hello-postgres/internal/httpserver/server_test.go b/examples/hello-postgres/internal/httpserver/server_test.go new file mode 100644 index 0000000..69d7b75 --- /dev/null +++ b/examples/hello-postgres/internal/httpserver/server_test.go @@ -0,0 +1,43 @@ +package httpserver + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +type errorResponseWriter struct { + header http.Header + status int +} + +func (w *errorResponseWriter) Header() http.Header { + if w.header == nil { + w.header = http.Header{} + } + return w.header +} + +func (w *errorResponseWriter) WriteHeader(status int) { + w.status = status +} + +func (w *errorResponseWriter) Write(_ []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + return 0, errors.New("write failed") +} + +func TestHealthEncodeErrorReturnsServerError(t *testing.T) { + w := &errorResponseWriter{} + req := httptest.NewRequest(http.MethodGet, "/health", nil) + + controller := &HealthController{} + controller.health(w, req) + + if w.status != http.StatusInternalServerError { + t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, w.status) + } +} From cf39b7fc844b0b32597d3ede33325d6c6287911b Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 02:53:55 +0200 Subject: [PATCH 16/31] fix(data-postgres): honor idle config and cleanup Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- modkit/data/postgres/cleanup.go | 10 ++-- modkit/data/postgres/cleanup_test.go | 24 +++++++++ modkit/data/postgres/config.go | 79 +++++++++++++++------------- modkit/data/postgres/config_test.go | 11 ++++ modkit/data/postgres/module.go | 10 ++-- modkit/data/postgres/module_test.go | 20 ++++++- modkit/data/postgres/tokens.go | 3 +- 7 files changed, 110 insertions(+), 47 deletions(-) create mode 100644 modkit/data/postgres/cleanup_test.go create mode 100644 modkit/data/postgres/config_test.go diff --git a/modkit/data/postgres/cleanup.go b/modkit/data/postgres/cleanup.go index ca25567..90456cd 100644 --- a/modkit/data/postgres/cleanup.go +++ b/modkit/data/postgres/cleanup.go @@ -3,15 +3,19 @@ package postgres import ( "context" "database/sql" + "fmt" ) // CleanupDB closes a DB handle if present. func CleanupDB(ctx context.Context, db *sql.DB) error { - if ctx.Err() != nil { - return ctx.Err() + if err := ctx.Err(); err != nil { + return fmt.Errorf("cleanup db: %w", err) } if db == nil { return nil } - return db.Close() + if err := db.Close(); err != nil { + return fmt.Errorf("cleanup db: %w", err) + } + return nil } diff --git a/modkit/data/postgres/cleanup_test.go b/modkit/data/postgres/cleanup_test.go new file mode 100644 index 0000000..4e3044b --- /dev/null +++ b/modkit/data/postgres/cleanup_test.go @@ -0,0 +1,24 @@ +package postgres + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestCleanupDBWrapsContextError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := CleanupDB(ctx, nil) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected wrapped context error") + } + if !strings.Contains(err.Error(), "cleanup") { + t.Fatalf("expected cleanup context in error, got %q", err.Error()) + } +} diff --git a/modkit/data/postgres/config.go b/modkit/data/postgres/config.go index 0d1ae50..9193b59 100644 --- a/modkit/data/postgres/config.go +++ b/modkit/data/postgres/config.go @@ -1,18 +1,12 @@ package postgres import ( - "sync" "time" "github.com/go-modkit/modkit/modkit/config" "github.com/go-modkit/modkit/modkit/module" ) -var ( - defaultConfigOnce sync.Once - defaultConfig module.Module -) - // DefaultConfigModule provides Postgres configuration from environment variables. // // Required: @@ -24,36 +18,45 @@ var ( // - POSTGRES_CONN_MAX_LIFETIME // - POSTGRES_CONNECT_TIMEOUT (default 0; disables provider ping) func DefaultConfigModule() module.Module { - defaultConfigOnce.Do(func() { - defaultConfig = config.NewModule( - config.WithTyped(TokenDSN, config.ValueSpec[string]{ - Key: "POSTGRES_DSN", - Required: true, - Sensitive: true, - Description: "Postgres DSN.", - Parse: config.ParseString, - }, true), - config.WithTyped(TokenMaxOpenConns, config.ValueSpec[int]{ - Key: "POSTGRES_MAX_OPEN_CONNS", - Description: "Maximum open connections for the DB pool.", - Parse: config.ParseInt, - }, true), - config.WithTyped(TokenMaxIdleConns, config.ValueSpec[int]{ - Key: "POSTGRES_MAX_IDLE_CONNS", - Description: "Maximum idle connections for the DB pool.", - Parse: config.ParseInt, - }, true), - config.WithTyped(TokenConnMaxLifetime, config.ValueSpec[time.Duration]{ - Key: "POSTGRES_CONN_MAX_LIFETIME", - Description: "Maximum amount of time a connection may be reused.", - Parse: config.ParseDuration, - }, true), - config.WithTyped(TokenConnectTimeout, config.ValueSpec[time.Duration]{ - Key: "POSTGRES_CONNECT_TIMEOUT", - Description: "Optional ping timeout on provider build. 0 disables ping.", - Parse: config.ParseDuration, - }, true), - ) - }) - return defaultConfig + return configModule("") +} + +func configModule(name string) module.Module { + return config.NewModule( + config.WithModuleName(moduleName(name)+".config"), + config.WithTyped(TokenDSN, config.ValueSpec[string]{ + Key: "POSTGRES_DSN", + Required: true, + Sensitive: true, + Description: "Postgres DSN.", + Parse: config.ParseString, + }, true), + config.WithTyped(TokenMaxOpenConns, config.ValueSpec[int]{ + Key: "POSTGRES_MAX_OPEN_CONNS", + Description: "Maximum open connections for the DB pool.", + Parse: config.ParseInt, + }, true), + config.WithTyped(TokenMaxIdleConns, config.ValueSpec[int]{ + Key: "POSTGRES_MAX_IDLE_CONNS", + Description: "Maximum idle connections for the DB pool.", + Parse: config.ParseInt, + }, true), + config.WithTyped(tokenMaxIdleConnsSet, config.ValueSpec[bool]{ + Key: "POSTGRES_MAX_IDLE_CONNS", + Description: "Whether POSTGRES_MAX_IDLE_CONNS is explicitly set.", + Parse: func(string) (bool, error) { + return true, nil + }, + }, true), + config.WithTyped(TokenConnMaxLifetime, config.ValueSpec[time.Duration]{ + Key: "POSTGRES_CONN_MAX_LIFETIME", + Description: "Maximum amount of time a connection may be reused.", + Parse: config.ParseDuration, + }, true), + config.WithTyped(TokenConnectTimeout, config.ValueSpec[time.Duration]{ + Key: "POSTGRES_CONNECT_TIMEOUT", + Description: "Optional ping timeout on provider build. 0 disables ping.", + Parse: config.ParseDuration, + }, true), + ) } diff --git a/modkit/data/postgres/config_test.go b/modkit/data/postgres/config_test.go new file mode 100644 index 0000000..b3b06b6 --- /dev/null +++ b/modkit/data/postgres/config_test.go @@ -0,0 +1,11 @@ +package postgres + +import "testing" + +func TestDefaultConfigModuleReturnsNewInstance(t *testing.T) { + first := DefaultConfigModule() + second := DefaultConfigModule() + if first == second { + t.Fatal("expected DefaultConfigModule to return a new module instance") + } +} diff --git a/modkit/data/postgres/module.go b/modkit/data/postgres/module.go index 3e94787..553d026 100644 --- a/modkit/data/postgres/module.go +++ b/modkit/data/postgres/module.go @@ -31,7 +31,7 @@ type Module struct { // NewModule constructs a Postgres provider module. func NewModule(opts Options) module.Module { if opts.Config == nil { - opts.Config = DefaultConfigModule() + opts.Config = configModule(opts.Name) } return &Module{opts: opts} } @@ -40,7 +40,7 @@ func NewModule(opts Options) module.Module { func (m *Module) Definition() module.ModuleDef { configMod := m.opts.Config if configMod == nil { - configMod = DefaultConfigModule() + configMod = configModule(m.opts.Name) } toks, err := sqlmodule.NamedTokens(m.opts.Name) @@ -110,6 +110,10 @@ func buildDB(r module.Resolver, dbToken module.Token) (*sql.DB, error) { if err != nil { return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("max_idle_conns: %w", err)} } + maxIdleSet, err := module.Get[bool](r, tokenMaxIdleConnsSet) + if err != nil { + return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("max_idle_conns_set: %w", err)} + } maxLifetime, err := module.Get[time.Duration](r, TokenConnMaxLifetime) if err != nil { return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("conn_max_lifetime: %w", err)} @@ -140,7 +144,7 @@ func buildDB(r module.Resolver, dbToken module.Token) (*sql.DB, error) { if maxOpen > 0 { db.SetMaxOpenConns(maxOpen) } - if maxIdle > 0 { + if maxIdleSet { db.SetMaxIdleConns(maxIdle) } if maxLifetime > 0 { diff --git a/modkit/data/postgres/module_test.go b/modkit/data/postgres/module_test.go index 4725a9e..9db2242 100644 --- a/modkit/data/postgres/module_test.go +++ b/modkit/data/postgres/module_test.go @@ -172,6 +172,21 @@ func TestPingFailureReturnsTypedBuildErrorAndClosesDB(t *testing.T) { } } +func TestMaxIdleConnsZeroDisablesIdlePoolWhenExplicit(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "25ms") + t.Setenv("POSTGRES_MAX_IDLE_CONNS", "0") + + h := testkit.New(t, NewModule(Options{})) + db := testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + stats := db.Stats() + if stats.Idle != 0 { + t.Fatalf("expected idle=0, got %d", stats.Idle) + } +} + func TestMultiplePostgresInstancesBootstrap(t *testing.T) { testDrv.Reset() t.Setenv("POSTGRES_DSN", "test") @@ -185,11 +200,12 @@ func TestMultiplePostgresInstancesBootstrap(t *testing.T) { if err != nil { t.Fatalf("analytics tokens: %v", err) } + configMod := DefaultConfigModule() root := &multiInstanceRootModule{ imports: []module.Module{ - NewModule(Options{Name: "primary"}), - NewModule(Options{Name: "analytics"}), + NewModule(Options{Name: "primary", Config: configMod}), + NewModule(Options{Name: "analytics", Config: configMod}), }, exports: []module.Token{ primaryTokens.DB, diff --git a/modkit/data/postgres/tokens.go b/modkit/data/postgres/tokens.go index 02de8cd..6ea189f 100644 --- a/modkit/data/postgres/tokens.go +++ b/modkit/data/postgres/tokens.go @@ -8,7 +8,8 @@ const ( // TokenMaxOpenConns resolves the max open connections pool setting. TokenMaxOpenConns module.Token = "postgres.max_open_conns" //nolint:gosec // token name, not credential // TokenMaxIdleConns resolves the max idle connections pool setting. - TokenMaxIdleConns module.Token = "postgres.max_idle_conns" //nolint:gosec // token name, not credential + TokenMaxIdleConns module.Token = "postgres.max_idle_conns" //nolint:gosec // token name, not credential + tokenMaxIdleConnsSet module.Token = "postgres.max_idle_conns_set" //nolint:gosec // token name, not credential // TokenConnMaxLifetime resolves the connection max lifetime pool setting. TokenConnMaxLifetime module.Token = "postgres.conn_max_lifetime" //nolint:gosec // token name, not credential // TokenConnectTimeout resolves the optional provider ping timeout. From 91ecf2482630e84c5ecd0c2aad960b65cc4683aa Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 02:54:04 +0200 Subject: [PATCH 17/31] fix(data-sqlite): refresh config and cleanup Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- modkit/data/sqlite/cleanup.go | 10 +++-- modkit/data/sqlite/cleanup_test.go | 24 ++++++++++++ modkit/data/sqlite/config.go | 60 ++++++++++++++---------------- modkit/data/sqlite/config_test.go | 11 ++++++ modkit/data/sqlite/module.go | 4 +- modkit/data/sqlite/module_test.go | 5 ++- 6 files changed, 75 insertions(+), 39 deletions(-) create mode 100644 modkit/data/sqlite/cleanup_test.go create mode 100644 modkit/data/sqlite/config_test.go diff --git a/modkit/data/sqlite/cleanup.go b/modkit/data/sqlite/cleanup.go index 98b479e..992705e 100644 --- a/modkit/data/sqlite/cleanup.go +++ b/modkit/data/sqlite/cleanup.go @@ -3,15 +3,19 @@ package sqlite import ( "context" "database/sql" + "fmt" ) // CleanupDB closes a DB handle if present. func CleanupDB(ctx context.Context, db *sql.DB) error { - if ctx.Err() != nil { - return ctx.Err() + if err := ctx.Err(); err != nil { + return fmt.Errorf("cleanup db: %w", err) } if db == nil { return nil } - return db.Close() + if err := db.Close(); err != nil { + return fmt.Errorf("cleanup db: %w", err) + } + return nil } diff --git a/modkit/data/sqlite/cleanup_test.go b/modkit/data/sqlite/cleanup_test.go new file mode 100644 index 0000000..91bc3ba --- /dev/null +++ b/modkit/data/sqlite/cleanup_test.go @@ -0,0 +1,24 @@ +package sqlite + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestCleanupDBWrapsContextError(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := CleanupDB(ctx, nil) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected wrapped context error") + } + if !strings.Contains(err.Error(), "cleanup") { + t.Fatalf("expected cleanup context in error, got %q", err.Error()) + } +} diff --git a/modkit/data/sqlite/config.go b/modkit/data/sqlite/config.go index 250493a..6ae9237 100644 --- a/modkit/data/sqlite/config.go +++ b/modkit/data/sqlite/config.go @@ -1,18 +1,12 @@ package sqlite import ( - "sync" "time" "github.com/go-modkit/modkit/modkit/config" "github.com/go-modkit/modkit/modkit/module" ) -var ( - defaultConfigOnce sync.Once - defaultConfig module.Module -) - // DefaultConfigModule provides SQLite configuration from environment variables. // // Required: @@ -23,30 +17,32 @@ var ( // - SQLITE_JOURNAL_MODE // - SQLITE_CONNECT_TIMEOUT (default 0; disables provider ping) func DefaultConfigModule() module.Module { - defaultConfigOnce.Do(func() { - defaultConfig = config.NewModule( - config.WithTyped(TokenPath, config.ValueSpec[string]{ - Key: "SQLITE_PATH", - Required: true, - Description: "SQLite database path or DSN.", - Parse: config.ParseString, - }, true), - config.WithTyped(TokenBusyTimeout, config.ValueSpec[time.Duration]{ - Key: "SQLITE_BUSY_TIMEOUT", - Description: "Optional busy timeout to apply to the DSN.", - Parse: config.ParseDuration, - }, true), - config.WithTyped(TokenJournalMode, config.ValueSpec[string]{ - Key: "SQLITE_JOURNAL_MODE", - Description: "Optional journal mode to apply to the DSN.", - Parse: config.ParseString, - }, true), - config.WithTyped(TokenConnectTimeout, config.ValueSpec[time.Duration]{ - Key: "SQLITE_CONNECT_TIMEOUT", - Description: "Optional ping timeout on provider build. 0 disables ping.", - Parse: config.ParseDuration, - }, true), - ) - }) - return defaultConfig + return configModule("") +} + +func configModule(name string) module.Module { + return config.NewModule( + config.WithModuleName(moduleName(name)+".config"), + config.WithTyped(TokenPath, config.ValueSpec[string]{ + Key: "SQLITE_PATH", + Required: true, + Description: "SQLite database path or DSN.", + Parse: config.ParseString, + }, true), + config.WithTyped(TokenBusyTimeout, config.ValueSpec[time.Duration]{ + Key: "SQLITE_BUSY_TIMEOUT", + Description: "Optional busy timeout to apply to the DSN.", + Parse: config.ParseDuration, + }, true), + config.WithTyped(TokenJournalMode, config.ValueSpec[string]{ + Key: "SQLITE_JOURNAL_MODE", + Description: "Optional journal mode to apply to the DSN.", + Parse: config.ParseString, + }, true), + config.WithTyped(TokenConnectTimeout, config.ValueSpec[time.Duration]{ + Key: "SQLITE_CONNECT_TIMEOUT", + Description: "Optional ping timeout on provider build. 0 disables ping.", + Parse: config.ParseDuration, + }, true), + ) } diff --git a/modkit/data/sqlite/config_test.go b/modkit/data/sqlite/config_test.go new file mode 100644 index 0000000..32f7443 --- /dev/null +++ b/modkit/data/sqlite/config_test.go @@ -0,0 +1,11 @@ +package sqlite + +import "testing" + +func TestDefaultConfigModuleReturnsNewInstance(t *testing.T) { + first := DefaultConfigModule() + second := DefaultConfigModule() + if first == second { + t.Fatal("expected DefaultConfigModule to return a new module instance") + } +} diff --git a/modkit/data/sqlite/module.go b/modkit/data/sqlite/module.go index bb003a3..e0531f6 100644 --- a/modkit/data/sqlite/module.go +++ b/modkit/data/sqlite/module.go @@ -34,7 +34,7 @@ type Module struct { // NewModule constructs a SQLite provider module. func NewModule(opts Options) module.Module { if opts.Config == nil { - opts.Config = DefaultConfigModule() + opts.Config = configModule(opts.Name) } return &Module{opts: opts} } @@ -43,7 +43,7 @@ func NewModule(opts Options) module.Module { func (m *Module) Definition() module.ModuleDef { configMod := m.opts.Config if configMod == nil { - configMod = DefaultConfigModule() + configMod = configModule(m.opts.Name) } toks, err := sqlmodule.NamedTokens(m.opts.Name) diff --git a/modkit/data/sqlite/module_test.go b/modkit/data/sqlite/module_test.go index 5c1f684..18050b0 100644 --- a/modkit/data/sqlite/module_test.go +++ b/modkit/data/sqlite/module_test.go @@ -286,11 +286,12 @@ func TestMultipleSQLiteInstancesBootstrap(t *testing.T) { if err != nil { t.Fatalf("analytics tokens: %v", err) } + configMod := DefaultConfigModule() root := &multiInstanceRootModule{ imports: []module.Module{ - NewModule(Options{Name: "primary"}), - NewModule(Options{Name: "analytics"}), + NewModule(Options{Name: "primary", Config: configMod}), + NewModule(Options{Name: "analytics", Config: configMod}), }, exports: []module.Token{ primaryTokens.DB, From 743782a7b25b1a0a6565942f1897cdf267e05304 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 03:10:06 +0200 Subject: [PATCH 18/31] refactor(data): centralize sql build errors Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- modkit/data/postgres/errors.go | 30 +++-------- modkit/data/postgres/module.go | 24 ++++----- modkit/data/postgres/module_test.go | 81 +++++++++++++++++----------- modkit/data/sqlite/errors.go | 30 +++-------- modkit/data/sqlite/module.go | 16 +++--- modkit/data/sqlite/module_test.go | 84 ++++++++++++++++++----------- modkit/data/sqlmodule/errors.go | 40 ++++++++++++++ 7 files changed, 175 insertions(+), 130 deletions(-) create mode 100644 modkit/data/sqlmodule/errors.go diff --git a/modkit/data/postgres/errors.go b/modkit/data/postgres/errors.go index c3ed4a7..c76a843 100644 --- a/modkit/data/postgres/errors.go +++ b/modkit/data/postgres/errors.go @@ -1,36 +1,20 @@ package postgres -import ( - "fmt" - - "github.com/go-modkit/modkit/modkit/module" -) +import "github.com/go-modkit/modkit/modkit/data/sqlmodule" // BuildStage identifies the provider build step. -type BuildStage string +type BuildStage = sqlmodule.BuildStage const ( // StageResolveConfig indicates a failure resolving config tokens. - StageResolveConfig BuildStage = "resolve_config" + StageResolveConfig = sqlmodule.StageResolveConfig // StageInvalidConfig indicates invalid config values (e.g. negative settings). - StageInvalidConfig BuildStage = "invalid_config" + StageInvalidConfig = sqlmodule.StageInvalidConfig // StageOpen indicates a failure opening the database handle. - StageOpen BuildStage = "open" + StageOpen = sqlmodule.StageOpen // StagePing indicates a failure pinging the database. - StagePing BuildStage = "ping" + StagePing = sqlmodule.StagePing ) // BuildError is returned when the Postgres provider fails to build. -type BuildError struct { - Token module.Token - Stage BuildStage - Err error -} - -func (e *BuildError) Error() string { - return fmt.Sprintf("postgres provider build failed: token=%q stage=%s: %v", e.Token, e.Stage, e.Err) -} - -func (e *BuildError) Unwrap() error { - return e.Err -} +type BuildError = sqlmodule.BuildError diff --git a/modkit/data/postgres/module.go b/modkit/data/postgres/module.go index 553d026..71a2447 100644 --- a/modkit/data/postgres/module.go +++ b/modkit/data/postgres/module.go @@ -100,45 +100,45 @@ func invalidModuleDef(err error) module.ModuleDef { func buildDB(r module.Resolver, dbToken module.Token) (*sql.DB, error) { dsn, err := module.Get[string](r, TokenDSN) if err != nil { - return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("dsn: %w", err)} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("dsn: %w", err)} } maxOpen, err := module.Get[int](r, TokenMaxOpenConns) if err != nil { - return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("max_open_conns: %w", err)} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("max_open_conns: %w", err)} } maxIdle, err := module.Get[int](r, TokenMaxIdleConns) if err != nil { - return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("max_idle_conns: %w", err)} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("max_idle_conns: %w", err)} } maxIdleSet, err := module.Get[bool](r, tokenMaxIdleConnsSet) if err != nil { - return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("max_idle_conns_set: %w", err)} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("max_idle_conns_set: %w", err)} } maxLifetime, err := module.Get[time.Duration](r, TokenConnMaxLifetime) if err != nil { - return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("conn_max_lifetime: %w", err)} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("conn_max_lifetime: %w", err)} } connectTimeout, err := module.Get[time.Duration](r, TokenConnectTimeout) if err != nil { - return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("connect_timeout: %w", err)} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("connect_timeout: %w", err)} } if maxOpen < 0 { - return nil, &BuildError{Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("max_open_conns must be >= 0")} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("max_open_conns must be >= 0")} } if maxIdle < 0 { - return nil, &BuildError{Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("max_idle_conns must be >= 0")} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("max_idle_conns must be >= 0")} } if maxLifetime < 0 { - return nil, &BuildError{Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("conn_max_lifetime must be >= 0")} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("conn_max_lifetime must be >= 0")} } if connectTimeout < 0 { - return nil, &BuildError{Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("connect_timeout must be >= 0")} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("connect_timeout must be >= 0")} } db, err := sql.Open(driverName, dsn) if err != nil { - return nil, &BuildError{Token: dbToken, Stage: StageOpen, Err: err} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageOpen, Err: err} } if maxOpen > 0 { @@ -159,7 +159,7 @@ func buildDB(r module.Resolver, dbToken module.Token) (*sql.DB, error) { defer cancel() if err := db.PingContext(ctx); err != nil { _ = db.Close() - return nil, &BuildError{Token: dbToken, Stage: StagePing, Err: err} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StagePing, Err: err} } return db, nil diff --git a/modkit/data/postgres/module_test.go b/modkit/data/postgres/module_test.go index 9db2242..d7fa451 100644 --- a/modkit/data/postgres/module_test.go +++ b/modkit/data/postgres/module_test.go @@ -102,40 +102,54 @@ func TestModuleExportsDialectAndDBTokens(t *testing.T) { } } -func TestConnectTimeoutZeroSkipsPing(t *testing.T) { - testDrv.Reset() - t.Setenv("POSTGRES_DSN", "test") - t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") - - h := testkit.New(t, NewModule(Options{})) - _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) - - open, ping, _, _ := testDrv.Snapshot() - if open != 0 { - t.Fatalf("expected open=0, got %d", open) - } - if ping != 0 { - t.Fatalf("expected ping=0, got %d", ping) +func TestConnectTimeoutPingBehavior(t *testing.T) { + cases := []struct { + name string + timeout string + wantOpen int + wantOpenNonZero bool + wantPing int + wantDeadline bool + }{ + { + name: "zero timeout skips ping", + timeout: "0", + wantOpen: 0, + wantPing: 0, + }, + { + name: "non-zero timeout pings with deadline", + timeout: "25ms", + wantOpenNonZero: true, + wantPing: 1, + wantDeadline: true, + }, } -} -func TestConnectTimeoutNonZeroPingsWithTimeout(t *testing.T) { - testDrv.Reset() - t.Setenv("POSTGRES_DSN", "test") - t.Setenv("POSTGRES_CONNECT_TIMEOUT", "25ms") - - h := testkit.New(t, NewModule(Options{})) - _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) - - open, ping, _, sawDeadline := testDrv.Snapshot() - if open == 0 { - t.Fatalf("expected open>0, got %d", open) - } - if ping != 1 { - t.Fatalf("expected ping=1, got %d", ping) - } - if !sawDeadline { - t.Fatalf("expected ping to observe a context deadline") + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", tc.timeout) + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + open, ping, _, sawDeadline := testDrv.Snapshot() + if tc.wantOpenNonZero { + if open == 0 { + t.Fatalf("expected open>0, got %d", open) + } + } else if open != tc.wantOpen { + t.Fatalf("expected open=%d, got %d", tc.wantOpen, open) + } + if ping != tc.wantPing { + t.Fatalf("expected ping=%d, got %d", tc.wantPing, ping) + } + if tc.wantDeadline != sawDeadline { + t.Fatalf("expected deadline=%v, got %v", tc.wantDeadline, sawDeadline) + } + }) } } @@ -159,6 +173,9 @@ func TestPingFailureReturnsTypedBuildErrorAndClosesDB(t *testing.T) { if be.Stage != StagePing { t.Fatalf("expected stage=%s, got %s", StagePing, be.Stage) } + if be.Provider != driverName { + t.Fatalf("expected provider=%q, got %q", driverName, be.Provider) + } if be.Token != sqlmodule.TokenDB { t.Fatalf("expected token=%q, got %q", sqlmodule.TokenDB, be.Token) } diff --git a/modkit/data/sqlite/errors.go b/modkit/data/sqlite/errors.go index 6a1b16e..744fa30 100644 --- a/modkit/data/sqlite/errors.go +++ b/modkit/data/sqlite/errors.go @@ -1,36 +1,20 @@ package sqlite -import ( - "fmt" - - "github.com/go-modkit/modkit/modkit/module" -) +import "github.com/go-modkit/modkit/modkit/data/sqlmodule" // BuildStage identifies the provider build step. -type BuildStage string +type BuildStage = sqlmodule.BuildStage const ( // StageResolveConfig indicates a failure resolving config tokens. - StageResolveConfig BuildStage = "resolve_config" + StageResolveConfig = sqlmodule.StageResolveConfig // StageInvalidConfig indicates invalid config values (e.g. negative settings). - StageInvalidConfig BuildStage = "invalid_config" + StageInvalidConfig = sqlmodule.StageInvalidConfig // StageOpen indicates a failure opening the database handle. - StageOpen BuildStage = "open" + StageOpen = sqlmodule.StageOpen // StagePing indicates a failure pinging the database. - StagePing BuildStage = "ping" + StagePing = sqlmodule.StagePing ) // BuildError is returned when the SQLite provider fails to build. -type BuildError struct { - Token module.Token - Stage BuildStage - Err error -} - -func (e *BuildError) Error() string { - return fmt.Sprintf("sqlite provider build failed: token=%q stage=%s: %v", e.Token, e.Stage, e.Err) -} - -func (e *BuildError) Unwrap() error { - return e.Err -} +type BuildError = sqlmodule.BuildError diff --git a/modkit/data/sqlite/module.go b/modkit/data/sqlite/module.go index e0531f6..a5276ab 100644 --- a/modkit/data/sqlite/module.go +++ b/modkit/data/sqlite/module.go @@ -103,32 +103,32 @@ func invalidModuleDef(err error) module.ModuleDef { func buildDB(r module.Resolver, dbToken module.Token) (*sql.DB, error) { path, err := module.Get[string](r, TokenPath) if err != nil { - return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("path: %w", err)} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("path: %w", err)} } busyTimeout, err := module.Get[time.Duration](r, TokenBusyTimeout) if err != nil { - return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("busy_timeout: %w", err)} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("busy_timeout: %w", err)} } journalMode, err := module.Get[string](r, TokenJournalMode) if err != nil { - return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("journal_mode: %w", err)} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("journal_mode: %w", err)} } connectTimeout, err := module.Get[time.Duration](r, TokenConnectTimeout) if err != nil { - return nil, &BuildError{Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("connect_timeout: %w", err)} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageResolveConfig, Err: fmt.Errorf("connect_timeout: %w", err)} } if busyTimeout < 0 { - return nil, &BuildError{Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("busy_timeout must be >= 0")} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("busy_timeout must be >= 0")} } if connectTimeout < 0 { - return nil, &BuildError{Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("connect_timeout must be >= 0")} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageInvalidConfig, Err: fmt.Errorf("connect_timeout must be >= 0")} } dsn := buildDSN(path, busyTimeout, journalMode) db, err := sql.Open(driverName, dsn) if err != nil { - return nil, &BuildError{Token: dbToken, Stage: StageOpen, Err: err} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageOpen, Err: err} } if connectTimeout == 0 { @@ -139,7 +139,7 @@ func buildDB(r module.Resolver, dbToken module.Token) (*sql.DB, error) { defer cancel() if err := db.PingContext(ctx); err != nil { _ = db.Close() - return nil, &BuildError{Token: dbToken, Stage: StagePing, Err: err} + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StagePing, Err: err} } return db, nil diff --git a/modkit/data/sqlite/module_test.go b/modkit/data/sqlite/module_test.go index 18050b0..ef3b538 100644 --- a/modkit/data/sqlite/module_test.go +++ b/modkit/data/sqlite/module_test.go @@ -107,40 +107,54 @@ func TestModuleExportsDialectAndDBTokens(t *testing.T) { } } -func TestConnectTimeoutZeroSkipsPing(t *testing.T) { - testDrv.Reset() - t.Setenv("SQLITE_PATH", "test.db") - t.Setenv("SQLITE_CONNECT_TIMEOUT", "0") - - h := testkit.New(t, NewModule(Options{})) - _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) - - open, ping, _, _, _ := testDrv.Snapshot() - if open != 0 { - t.Fatalf("expected open=0, got %d", open) - } - if ping != 0 { - t.Fatalf("expected ping=0, got %d", ping) +func TestConnectTimeoutPingBehavior(t *testing.T) { + cases := []struct { + name string + timeout string + wantOpen int + wantOpenNonZero bool + wantPing int + wantDeadline bool + }{ + { + name: "zero timeout skips ping", + timeout: "0", + wantOpen: 0, + wantPing: 0, + }, + { + name: "non-zero timeout pings with deadline", + timeout: "25ms", + wantOpenNonZero: true, + wantPing: 1, + wantDeadline: true, + }, } -} -func TestConnectTimeoutNonZeroPingsWithTimeout(t *testing.T) { - testDrv.Reset() - t.Setenv("SQLITE_PATH", "test.db") - t.Setenv("SQLITE_CONNECT_TIMEOUT", "25ms") - - h := testkit.New(t, NewModule(Options{})) - _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) - - open, ping, _, sawDeadline, _ := testDrv.Snapshot() - if open == 0 { - t.Fatalf("expected open>0, got %d", open) - } - if ping != 1 { - t.Fatalf("expected ping=1, got %d", ping) - } - if !sawDeadline { - t.Fatalf("expected ping to observe a context deadline") + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_CONNECT_TIMEOUT", tc.timeout) + + h := testkit.New(t, NewModule(Options{})) + _ = testkit.Get[*sql.DB](t, h, sqlmodule.TokenDB) + + open, ping, _, sawDeadline, _ := testDrv.Snapshot() + if tc.wantOpenNonZero { + if open == 0 { + t.Fatalf("expected open>0, got %d", open) + } + } else if open != tc.wantOpen { + t.Fatalf("expected open=%d, got %d", tc.wantOpen, open) + } + if ping != tc.wantPing { + t.Fatalf("expected ping=%d, got %d", tc.wantPing, ping) + } + if tc.wantDeadline != sawDeadline { + t.Fatalf("expected deadline=%v, got %v", tc.wantDeadline, sawDeadline) + } + }) } } @@ -164,6 +178,9 @@ func TestPingFailureReturnsTypedBuildErrorAndClosesDB(t *testing.T) { if be.Stage != StagePing { t.Fatalf("expected stage=%s, got %s", StagePing, be.Stage) } + if be.Provider != driverName { + t.Fatalf("expected provider=%q, got %q", driverName, be.Provider) + } if be.Token != sqlmodule.TokenDB { t.Fatalf("expected token=%q, got %q", sqlmodule.TokenDB, be.Token) } @@ -254,6 +271,9 @@ func TestNegativeConnectTimeoutFailsWithInvalidConfig(t *testing.T) { if be.Stage != StageInvalidConfig { t.Fatalf("expected stage=%s, got %s", StageInvalidConfig, be.Stage) } + if be.Provider != driverName { + t.Fatalf("expected provider=%q, got %q", driverName, be.Provider) + } } func TestCleanupClosesDB(t *testing.T) { diff --git a/modkit/data/sqlmodule/errors.go b/modkit/data/sqlmodule/errors.go new file mode 100644 index 0000000..fd83f41 --- /dev/null +++ b/modkit/data/sqlmodule/errors.go @@ -0,0 +1,40 @@ +package sqlmodule + +import ( + "fmt" + + "github.com/go-modkit/modkit/modkit/module" +) + +// BuildStage identifies the provider build step. +type BuildStage string + +const ( + // StageResolveConfig indicates a failure resolving config tokens. + StageResolveConfig BuildStage = "resolve_config" + // StageInvalidConfig indicates invalid config values (e.g. negative settings). + StageInvalidConfig BuildStage = "invalid_config" + // StageOpen indicates a failure opening the database handle. + StageOpen BuildStage = "open" + // StagePing indicates a failure pinging the database. + StagePing BuildStage = "ping" +) + +// BuildError is returned when a SQL provider fails to build. +type BuildError struct { + Provider string + Token module.Token + Stage BuildStage + Err error +} + +func (e *BuildError) Error() string { + if e.Provider == "" { + return fmt.Sprintf("sql provider build failed: token=%q stage=%s: %v", e.Token, e.Stage, e.Err) + } + return fmt.Sprintf("%s provider build failed: token=%q stage=%s: %v", e.Provider, e.Token, e.Stage, e.Err) +} + +func (e *BuildError) Unwrap() error { + return e.Err +} From 6f09f94457cd898a091102dd225c948c5bc7493c Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 03:10:20 +0200 Subject: [PATCH 19/31] refactor(example-postgres): cache provider module Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- examples/hello-postgres/internal/app/module.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/examples/hello-postgres/internal/app/module.go b/examples/hello-postgres/internal/app/module.go index f1c43f6..d4be1e0 100644 --- a/examples/hello-postgres/internal/app/module.go +++ b/examples/hello-postgres/internal/app/module.go @@ -6,17 +6,21 @@ import ( "github.com/go-modkit/modkit/modkit/module" ) -type Module struct{} +type Module struct { + postgres module.Module +} func NewModule() module.Module { - return &Module{} + return &Module{ + postgres: postgres.NewModule(postgres.Options{}), + } } func (m *Module) Definition() module.ModuleDef { return module.ModuleDef{ Name: "app", Imports: []module.Module{ - postgres.NewModule(postgres.Options{}), + m.postgres, }, Exports: []module.Token{ sqlmodule.TokenDB, From 432da3a7f89bab024cb1b16ce6552bd59e08d80a Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 03:10:32 +0200 Subject: [PATCH 20/31] feat(example-sqlite): add graceful shutdown Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- examples/hello-sqlite/cmd/api/main.go | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/examples/hello-sqlite/cmd/api/main.go b/examples/hello-sqlite/cmd/api/main.go index 78758df..e6c2cc7 100644 --- a/examples/hello-sqlite/cmd/api/main.go +++ b/examples/hello-sqlite/cmd/api/main.go @@ -1,8 +1,13 @@ package main import ( + "context" "log" "net/http" + "os" + "os/signal" + "syscall" + "time" _ "github.com/mattn/go-sqlite3" @@ -51,9 +56,23 @@ func main() { log.Fatalf("routes: %v", err) } - log.Println("Server starting on http://localhost:8080") + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + addr := ":8080" + srv := &http.Server{Addr: addr, Handler: router} + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("shutdown: %v", err) + } + }() + + log.Printf("Server starting on http://localhost%s", addr) log.Println("Try: curl http://localhost:8080/health") - if err := mkhttp.Serve(":8080", router); err != nil { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("serve: %v", err) } } From 9bd545fb66b8be0456508a6496defb1b51088c51 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 03:10:44 +0200 Subject: [PATCH 21/31] test(examples): harden smoke helpers Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- examples/hello-postgres/internal/smoke/smoke_test.go | 4 +++- examples/hello-sqlite/internal/smoke/smoke_test.go | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/hello-postgres/internal/smoke/smoke_test.go b/examples/hello-postgres/internal/smoke/smoke_test.go index 2ba6201..559aa91 100644 --- a/examples/hello-postgres/internal/smoke/smoke_test.go +++ b/examples/hello-postgres/internal/smoke/smoke_test.go @@ -50,7 +50,9 @@ func TestSmoke_Postgres_ModuleBootsAndServes(t *testing.T) { } buf := new(bytes.Buffer) - _, _ = buf.ReadFrom(resp.Body) + if _, err := buf.ReadFrom(resp.Body); err != nil { + t.Fatalf("read body: %v", err) + } if got := bytes.TrimSpace(buf.Bytes()); len(got) == 0 { t.Fatalf("expected non-empty body") } diff --git a/examples/hello-sqlite/internal/smoke/smoke_test.go b/examples/hello-sqlite/internal/smoke/smoke_test.go index b891844..2907649 100644 --- a/examples/hello-sqlite/internal/smoke/smoke_test.go +++ b/examples/hello-sqlite/internal/smoke/smoke_test.go @@ -48,7 +48,7 @@ func roundTripSQLite(t *testing.T, db *sql.DB) { if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`); err != nil { t.Fatalf("create table: %v", err) } - if _, err := db.ExecContext(ctx, `INSERT INTO users (id, name) VALUES (1, 'Ada')`); err != nil { + if _, err := db.ExecContext(ctx, `INSERT OR REPLACE INTO users (id, name) VALUES (1, 'Ada')`); err != nil { t.Fatalf("insert: %v", err) } var name string From 82cff53257c7cd16a5e80d515260d66c32d92aaf Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 03:10:55 +0200 Subject: [PATCH 22/31] chore(examples): run containers as non-root Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- examples/hello-mysql/Dockerfile | 7 +++++++ examples/hello-simple/Dockerfile | 7 +++++++ examples/hello-sqlite/Dockerfile | 7 +++++++ 3 files changed, 21 insertions(+) diff --git a/examples/hello-mysql/Dockerfile b/examples/hello-mysql/Dockerfile index 7882e20..fbc7f37 100644 --- a/examples/hello-mysql/Dockerfile +++ b/examples/hello-mysql/Dockerfile @@ -6,4 +6,11 @@ COPY . /repo RUN go mod download +RUN useradd -m -u 10001 appuser \ + && chown -R appuser:appuser /repo /go + +ENV GOCACHE=/tmp/go-cache + +USER appuser + CMD ["sh", "-c", "go run ./cmd/migrate && go run ./cmd/seed && go run ./cmd/api"] diff --git a/examples/hello-simple/Dockerfile b/examples/hello-simple/Dockerfile index 3c99aa6..71f44b6 100644 --- a/examples/hello-simple/Dockerfile +++ b/examples/hello-simple/Dockerfile @@ -6,4 +6,11 @@ COPY . /repo RUN go mod download +RUN useradd -m -u 10001 appuser \ + && chown -R appuser:appuser /repo /go + +ENV GOCACHE=/tmp/go-cache + +USER appuser + CMD ["go", "run", "main.go"] diff --git a/examples/hello-sqlite/Dockerfile b/examples/hello-sqlite/Dockerfile index f72ce7b..eefab23 100644 --- a/examples/hello-sqlite/Dockerfile +++ b/examples/hello-sqlite/Dockerfile @@ -10,4 +10,11 @@ RUN apt-get update \ RUN go mod download +RUN useradd -m -u 10001 appuser \ + && chown -R appuser:appuser /repo /go + +ENV GOCACHE=/tmp/go-cache + +USER appuser + CMD ["go", "run", "./cmd/api"] From 9d97fe9106eeadae9f0e9061f692a198e6eb834c Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 03:36:25 +0200 Subject: [PATCH 23/31] test(sqlmodule): cover error formatting Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- modkit/data/sqlmodule/errors_test.go | 36 ++++++++++++++++++++++++++++ modkit/data/sqlmodule/tokens_test.go | 12 ++++++++++ 2 files changed, 48 insertions(+) create mode 100644 modkit/data/sqlmodule/errors_test.go diff --git a/modkit/data/sqlmodule/errors_test.go b/modkit/data/sqlmodule/errors_test.go new file mode 100644 index 0000000..f6f10c8 --- /dev/null +++ b/modkit/data/sqlmodule/errors_test.go @@ -0,0 +1,36 @@ +package sqlmodule + +import ( + "errors" + "strings" + "testing" +) + +func TestBuildErrorIncludesProvider(t *testing.T) { + inner := errors.New("boom") + be := &BuildError{Provider: "postgres", Token: "db", Stage: StageOpen, Err: inner} + msg := be.Error() + + if !strings.Contains(msg, "postgres provider build failed") { + t.Fatalf("expected provider in message, got %q", msg) + } + if !strings.Contains(msg, "token=\"db\"") { + t.Fatalf("expected token in message, got %q", msg) + } + if !strings.Contains(msg, "stage=open") { + t.Fatalf("expected stage in message, got %q", msg) + } + if !errors.Is(be, inner) { + t.Fatalf("expected error to unwrap") + } +} + +func TestBuildErrorWithEmptyProviderUsesGenericPrefix(t *testing.T) { + inner := errors.New("boom") + be := &BuildError{Token: "db", Stage: StageOpen, Err: inner} + msg := be.Error() + + if !strings.Contains(msg, "sql provider build failed") { + t.Fatalf("expected generic prefix, got %q", msg) + } +} diff --git a/modkit/data/sqlmodule/tokens_test.go b/modkit/data/sqlmodule/tokens_test.go index 29fac4c..35c951b 100644 --- a/modkit/data/sqlmodule/tokens_test.go +++ b/modkit/data/sqlmodule/tokens_test.go @@ -2,6 +2,7 @@ package sqlmodule import ( "errors" + "strings" "testing" ) @@ -50,3 +51,14 @@ func TestNamedTokens_InvalidName(t *testing.T) { }) } } + +func TestInvalidNameErrorMessage(t *testing.T) { + err := &InvalidNameError{Name: "bad name", Reason: "name must not contain spaces"} + msg := err.Error() + if !strings.Contains(msg, "bad name") { + t.Fatalf("expected name in error message, got %q", msg) + } + if !strings.Contains(msg, "name must not contain spaces") { + t.Fatalf("expected reason in error message, got %q", msg) + } +} From dbeb1ed61dfc1fdd7c27efa23f705bd4497a43f5 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 03:36:27 +0200 Subject: [PATCH 24/31] test(data-postgres): cover invalid config paths Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- modkit/data/postgres/cleanup_test.go | 82 +++++++++++++++++++ modkit/data/postgres/module_test.go | 113 +++++++++++++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/modkit/data/postgres/cleanup_test.go b/modkit/data/postgres/cleanup_test.go index 4e3044b..144a471 100644 --- a/modkit/data/postgres/cleanup_test.go +++ b/modkit/data/postgres/cleanup_test.go @@ -2,11 +2,68 @@ package postgres import ( "context" + "database/sql" + "database/sql/driver" "errors" "strings" + "sync" "testing" ) +const cleanupDriverName = "postgres-cleanup-driver" + +var ( + cleanupOnce sync.Once + cleanupDrv = &cleanupDriver{} +) + +type cleanupDriver struct { + mu sync.Mutex + closeErr error +} + +func (d *cleanupDriver) SetCloseErr(err error) { + d.mu.Lock() + defer d.mu.Unlock() + d.closeErr = err +} + +func (d *cleanupDriver) getCloseErr() error { + d.mu.Lock() + defer d.mu.Unlock() + return d.closeErr +} + +func (d *cleanupDriver) Open(_ string) (driver.Conn, error) { + return &cleanupConn{d: d}, nil +} + +type cleanupConn struct { + d *cleanupDriver +} + +func (c *cleanupConn) Prepare(_ string) (driver.Stmt, error) { + return nil, errors.New("not implemented") +} + +func (c *cleanupConn) Close() error { + return c.d.getCloseErr() +} + +func (c *cleanupConn) Begin() (driver.Tx, error) { + return nil, errors.New("not implemented") +} + +func (c *cleanupConn) Ping(_ context.Context) error { + return nil +} + +func registerCleanupDriver() { + cleanupOnce.Do(func() { + sql.Register(cleanupDriverName, cleanupDrv) + }) +} + func TestCleanupDBWrapsContextError(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -22,3 +79,28 @@ func TestCleanupDBWrapsContextError(t *testing.T) { t.Fatalf("expected cleanup context in error, got %q", err.Error()) } } + +func TestCleanupDBWrapsCloseError(t *testing.T) { + registerCleanupDriver() + closeErr := errors.New("close failed") + cleanupDrv.SetCloseErr(closeErr) + + db, err := sql.Open(cleanupDriverName, "test") + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.PingContext(context.Background()); err != nil { + t.Fatalf("ping db: %v", err) + } + + err = CleanupDB(context.Background(), db) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, closeErr) { + t.Fatalf("expected wrapped close error") + } + if !strings.Contains(err.Error(), "cleanup") { + t.Fatalf("expected cleanup context in error, got %q", err.Error()) + } +} diff --git a/modkit/data/postgres/module_test.go b/modkit/data/postgres/module_test.go index d7fa451..aabe380 100644 --- a/modkit/data/postgres/module_test.go +++ b/modkit/data/postgres/module_test.go @@ -153,6 +153,119 @@ func TestConnectTimeoutPingBehavior(t *testing.T) { } } +func TestResolveConfigErrorReturnsBuildError(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_MAX_OPEN_CONNS", "nope") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageResolveConfig { + t.Fatalf("expected stage=%s, got %s", StageResolveConfig, be.Stage) + } +} + +func TestNegativeMaxOpenConnsReturnsInvalidConfig(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_MAX_OPEN_CONNS", "-1") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageInvalidConfig { + t.Fatalf("expected stage=%s, got %s", StageInvalidConfig, be.Stage) + } +} + +func TestNegativeMaxIdleConnsReturnsInvalidConfig(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_MAX_IDLE_CONNS", "-1") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageInvalidConfig { + t.Fatalf("expected stage=%s, got %s", StageInvalidConfig, be.Stage) + } +} + +func TestNegativeConnMaxLifetimeReturnsInvalidConfig(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONN_MAX_LIFETIME", "-1s") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageInvalidConfig { + t.Fatalf("expected stage=%s, got %s", StageInvalidConfig, be.Stage) + } +} + +func TestNegativeConnectTimeoutReturnsInvalidConfig(t *testing.T) { + testDrv.Reset() + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "-1s") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageInvalidConfig { + t.Fatalf("expected stage=%s, got %s", StageInvalidConfig, be.Stage) + } +} + +func TestModuleNameUsesSuffixWhenProvided(t *testing.T) { + if moduleName("") != moduleNameBase { + t.Fatalf("expected base module name") + } + if moduleName("analytics") != moduleNameBase+".analytics" { + t.Fatalf("expected named module suffix") + } +} + func TestPingFailureReturnsTypedBuildErrorAndClosesDB(t *testing.T) { testDrv.Reset() pingErr := errors.New("ping failed") From ddfd41f9817660b2adf1aa05f6e404ead2082b7a Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 03:36:29 +0200 Subject: [PATCH 25/31] test(data-sqlite): cover invalid config paths Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- modkit/data/sqlite/cleanup_test.go | 82 ++++++++++++++++++++++++++++++ modkit/data/sqlite/module_test.go | 42 +++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/modkit/data/sqlite/cleanup_test.go b/modkit/data/sqlite/cleanup_test.go index 91bc3ba..9014687 100644 --- a/modkit/data/sqlite/cleanup_test.go +++ b/modkit/data/sqlite/cleanup_test.go @@ -2,11 +2,68 @@ package sqlite import ( "context" + "database/sql" + "database/sql/driver" "errors" "strings" + "sync" "testing" ) +const cleanupDriverName = "sqlite-cleanup-driver" + +var ( + cleanupOnce sync.Once + cleanupDrv = &cleanupDriver{} +) + +type cleanupDriver struct { + mu sync.Mutex + closeErr error +} + +func (d *cleanupDriver) SetCloseErr(err error) { + d.mu.Lock() + defer d.mu.Unlock() + d.closeErr = err +} + +func (d *cleanupDriver) getCloseErr() error { + d.mu.Lock() + defer d.mu.Unlock() + return d.closeErr +} + +func (d *cleanupDriver) Open(_ string) (driver.Conn, error) { + return &cleanupConn{d: d}, nil +} + +type cleanupConn struct { + d *cleanupDriver +} + +func (c *cleanupConn) Prepare(_ string) (driver.Stmt, error) { + return nil, errors.New("not implemented") +} + +func (c *cleanupConn) Close() error { + return c.d.getCloseErr() +} + +func (c *cleanupConn) Begin() (driver.Tx, error) { + return nil, errors.New("not implemented") +} + +func (c *cleanupConn) Ping(_ context.Context) error { + return nil +} + +func registerCleanupDriver() { + cleanupOnce.Do(func() { + sql.Register(cleanupDriverName, cleanupDrv) + }) +} + func TestCleanupDBWrapsContextError(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -22,3 +79,28 @@ func TestCleanupDBWrapsContextError(t *testing.T) { t.Fatalf("expected cleanup context in error, got %q", err.Error()) } } + +func TestCleanupDBWrapsCloseError(t *testing.T) { + registerCleanupDriver() + closeErr := errors.New("close failed") + cleanupDrv.SetCloseErr(closeErr) + + db, err := sql.Open(cleanupDriverName, "test") + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.PingContext(context.Background()); err != nil { + t.Fatalf("ping db: %v", err) + } + + err = CleanupDB(context.Background(), db) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, closeErr) { + t.Fatalf("expected wrapped close error") + } + if !strings.Contains(err.Error(), "cleanup") { + t.Fatalf("expected cleanup context in error, got %q", err.Error()) + } +} diff --git a/modkit/data/sqlite/module_test.go b/modkit/data/sqlite/module_test.go index ef3b538..7c7ae3b 100644 --- a/modkit/data/sqlite/module_test.go +++ b/modkit/data/sqlite/module_test.go @@ -158,6 +158,48 @@ func TestConnectTimeoutPingBehavior(t *testing.T) { } } +func TestResolveConfigErrorReturnsBuildError(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_BUSY_TIMEOUT", "nope") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageResolveConfig { + t.Fatalf("expected stage=%s, got %s", StageResolveConfig, be.Stage) + } +} + +func TestNegativeBusyTimeoutReturnsInvalidConfig(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_BUSY_TIMEOUT", "-1ms") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "0") + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageInvalidConfig { + t.Fatalf("expected stage=%s, got %s", StageInvalidConfig, be.Stage) + } +} + func TestPingFailureReturnsTypedBuildErrorAndClosesDB(t *testing.T) { testDrv.Reset() pingErr := errors.New("ping failed") From a269e5804e6a843c96ddde7cc9b387aee02ed44f Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 03:36:31 +0200 Subject: [PATCH 26/31] test(examples): cover app and http wiring Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- .../internal/app/module_test.go | 35 +++++++++++++++ .../internal/httpserver/server_test.go | 44 +++++++++++++++++++ .../hello-sqlite/internal/app/module_test.go | 14 ++++++ 3 files changed, 93 insertions(+) create mode 100644 examples/hello-postgres/internal/app/module_test.go create mode 100644 examples/hello-sqlite/internal/app/module_test.go diff --git a/examples/hello-postgres/internal/app/module_test.go b/examples/hello-postgres/internal/app/module_test.go new file mode 100644 index 0000000..f617470 --- /dev/null +++ b/examples/hello-postgres/internal/app/module_test.go @@ -0,0 +1,35 @@ +package app + +import ( + "testing" + + "github.com/go-modkit/modkit/modkit/data/sqlmodule" +) + +func TestModuleDefinition(t *testing.T) { + def := NewModule().Definition() + + if def.Name != "app" { + t.Fatalf("expected name=app, got %q", def.Name) + } + if len(def.Imports) != 1 { + t.Fatalf("expected 1 import, got %d", len(def.Imports)) + } + if len(def.Exports) != 2 { + t.Fatalf("expected 2 exports, got %d", len(def.Exports)) + } + + foundDB := false + foundDialect := false + for _, token := range def.Exports { + switch token { + case sqlmodule.TokenDB: + foundDB = true + case sqlmodule.TokenDialect: + foundDialect = true + } + } + if !foundDB || !foundDialect { + t.Fatalf("expected db and dialect exports") + } +} diff --git a/examples/hello-postgres/internal/httpserver/server_test.go b/examples/hello-postgres/internal/httpserver/server_test.go index 69d7b75..852e462 100644 --- a/examples/hello-postgres/internal/httpserver/server_test.go +++ b/examples/hello-postgres/internal/httpserver/server_test.go @@ -1,10 +1,13 @@ package httpserver import ( + "encoding/json" "errors" "net/http" "net/http/httptest" "testing" + + modkithttp "github.com/go-modkit/modkit/modkit/http" ) type errorResponseWriter struct { @@ -41,3 +44,44 @@ func TestHealthEncodeErrorReturnsServerError(t *testing.T) { t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, w.status) } } + +func TestBuildHandlerServesHealth(t *testing.T) { + t.Setenv("POSTGRES_DSN", "test") + t.Setenv("POSTGRES_CONNECT_TIMEOUT", "0") + + handler, err := BuildHandler() + if err != nil { + t.Fatalf("build handler: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code) + } + + var payload map[string]string + if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["status"] != "ok" { + t.Fatalf("expected status=ok, got %q", payload["status"]) + } +} + +func TestBuildAppHandlerReturnsRegisterError(t *testing.T) { + oldRegister := registerRoutes + registerRoutes = func(_ modkithttp.Router, _ map[string]any) error { + return errors.New("register failed") + } + t.Cleanup(func() { + registerRoutes = oldRegister + }) + + _, _, err := BuildAppHandler() + if err == nil { + t.Fatal("expected error") + } +} diff --git a/examples/hello-sqlite/internal/app/module_test.go b/examples/hello-sqlite/internal/app/module_test.go new file mode 100644 index 0000000..e9b3113 --- /dev/null +++ b/examples/hello-sqlite/internal/app/module_test.go @@ -0,0 +1,14 @@ +package app + +import "testing" + +func TestModuleDefinition(t *testing.T) { + def := NewModule().Definition() + + if def.Name != "app" { + t.Fatalf("expected name=app, got %q", def.Name) + } + if len(def.Imports) != 1 { + t.Fatalf("expected 1 import, got %d", len(def.Imports)) + } +} From bc4d58c5d1866cc7c7859c69657d35a5221dc791 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 04:07:29 +0200 Subject: [PATCH 27/31] fix(data-sqlite): guard missing driver Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- modkit/data/sqlite/module.go | 14 ++++++++++++++ modkit/data/sqlite/module_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/modkit/data/sqlite/module.go b/modkit/data/sqlite/module.go index a5276ab..3d8128a 100644 --- a/modkit/data/sqlite/module.go +++ b/modkit/data/sqlite/module.go @@ -18,6 +18,8 @@ const ( moduleNameBase = "data.sqlite" ) +var listDrivers = sql.Drivers + // Options configures a SQLite provider module. type Options struct { // Config provides SQLite configuration tokens (path/DSN, DSN options, ping timeout). @@ -126,6 +128,9 @@ func buildDB(r module.Resolver, dbToken module.Token) (*sql.DB, error) { } dsn := buildDSN(path, busyTimeout, journalMode) + if !driverRegistered(driverName) { + return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageOpen, Err: fmt.Errorf("driver %q is not registered", driverName)} + } db, err := sql.Open(driverName, dsn) if err != nil { return nil, &BuildError{Provider: driverName, Token: dbToken, Stage: StageOpen, Err: err} @@ -145,6 +150,15 @@ func buildDB(r module.Resolver, dbToken module.Token) (*sql.DB, error) { return db, nil } +func driverRegistered(name string) bool { + for _, driver := range listDrivers() { + if driver == name { + return true + } + } + return false +} + func buildDSN(base string, busyTimeout time.Duration, journalMode string) string { journalMode = strings.TrimSpace(journalMode) diff --git a/modkit/data/sqlite/module_test.go b/modkit/data/sqlite/module_test.go index 7c7ae3b..ae17fab 100644 --- a/modkit/data/sqlite/module_test.go +++ b/modkit/data/sqlite/module_test.go @@ -200,6 +200,35 @@ func TestNegativeBusyTimeoutReturnsInvalidConfig(t *testing.T) { } } +func TestMissingDriverReturnsBuildError(t *testing.T) { + testDrv.Reset() + t.Setenv("SQLITE_PATH", "test.db") + t.Setenv("SQLITE_CONNECT_TIMEOUT", "0") + + origDrivers := listDrivers + listDrivers = func() []string { return []string{} } + t.Cleanup(func() { + listDrivers = origDrivers + }) + + h := testkit.New(t, NewModule(Options{})) + _, err := testkit.GetE[*sql.DB](h, sqlmodule.TokenDB) + if err == nil { + t.Fatal("expected error") + } + + var be *BuildError + if !errors.As(err, &be) { + t.Fatalf("expected BuildError, got %T", err) + } + if be.Stage != StageOpen { + t.Fatalf("expected stage=%s, got %s", StageOpen, be.Stage) + } + if !strings.Contains(err.Error(), driverName) { + t.Fatalf("expected driver name in error") + } +} + func TestPingFailureReturnsTypedBuildErrorAndClosesDB(t *testing.T) { testDrv.Reset() pingErr := errors.New("ping failed") From 54b16a4a88d2bb6bfcece2f817c16a2c7e0cc7f0 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 04:07:43 +0200 Subject: [PATCH 28/31] test(sqlmodule): use canonical tokens Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- modkit/data/sqlmodule/errors_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modkit/data/sqlmodule/errors_test.go b/modkit/data/sqlmodule/errors_test.go index f6f10c8..37db3aa 100644 --- a/modkit/data/sqlmodule/errors_test.go +++ b/modkit/data/sqlmodule/errors_test.go @@ -8,13 +8,13 @@ import ( func TestBuildErrorIncludesProvider(t *testing.T) { inner := errors.New("boom") - be := &BuildError{Provider: "postgres", Token: "db", Stage: StageOpen, Err: inner} + be := &BuildError{Provider: "postgres", Token: TokenDB, Stage: StageOpen, Err: inner} msg := be.Error() if !strings.Contains(msg, "postgres provider build failed") { t.Fatalf("expected provider in message, got %q", msg) } - if !strings.Contains(msg, "token=\"db\"") { + if !strings.Contains(msg, "token=\"database.db\"") { t.Fatalf("expected token in message, got %q", msg) } if !strings.Contains(msg, "stage=open") { @@ -27,7 +27,7 @@ func TestBuildErrorIncludesProvider(t *testing.T) { func TestBuildErrorWithEmptyProviderUsesGenericPrefix(t *testing.T) { inner := errors.New("boom") - be := &BuildError{Token: "db", Stage: StageOpen, Err: inner} + be := &BuildError{Token: TokenDB, Stage: StageOpen, Err: inner} msg := be.Error() if !strings.Contains(msg, "sql provider build failed") { From 87d025ad9ffda6ba0fd37561cbc1c92c19f5b082 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 04:27:44 +0200 Subject: [PATCH 29/31] chore(lint): align golangci-lint v2 config Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- .golangci.yml | 18 +++++++++++------- Makefile | 4 +++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 32060be..e93dac5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,3 +1,5 @@ +version: 2 + run: timeout: 5m tests: true @@ -10,7 +12,6 @@ linters: - staticcheck - govet - unused - - gosec - bodyclose - nilerr @@ -19,8 +20,6 @@ linters: - gocritic # (extensive checks) - ineffassign - misspell - - gofmt - - goimports - unconvert - unparam - nakedret @@ -28,9 +27,15 @@ linters: # Complexity - gocyclo +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: github.com/go-modkit/modkit + linters-settings: - goimports: - local-prefixes: github.com/go-modkit/modkit govet: enable: - nilness @@ -61,10 +66,9 @@ issues: exclude-rules: # Relax rules for tests - - path: _test\.go + - path: ".*_test\\.go$" linters: - errcheck - - gosec - dupl - funlen - gocyclo diff --git a/Makefile b/Makefile index d57abd2..235e559 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ SHELL := /bin/sh GOPATH ?= $(shell go env GOPATH) GOIMPORTS ?= $(GOPATH)/bin/goimports GOLANGCI_LINT ?= $(GOPATH)/bin/golangci-lint +GOLANGCI_LINT_VERSION ?= v2.5.0 GOVULNCHECK ?= $(GOPATH)/bin/govulncheck GO_PATCH_COVER ?= $(GOPATH)/bin/go-patch-cover LEFTHOOK ?= $(GOPATH)/bin/lefthook @@ -53,7 +54,8 @@ test-patch-coverage: test-coverage # Install all development tools (tracked in tools/tools.go) tools: @echo "Installing development tools..." - @cat tools/tools.go | grep _ | awk '{print $$2}' | xargs -I {} sh -c 'go install {}' + @cat tools/tools.go | grep _ | awk '{print $$2}' | grep -v golangci-lint | xargs -I {} sh -c 'go install {}' + @go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) @echo "Done: All tools installed" # Install development tools and setup git hooks From b523b07aca122d45979233f52876dfa53eec4d80 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 04:27:54 +0200 Subject: [PATCH 30/31] fix(cli): address lint findings Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- internal/cli/ast/modify.go | 6 +++--- internal/cli/cmd/naming.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cli/ast/modify.go b/internal/cli/ast/modify.go index b5a9be8..1807914 100644 --- a/internal/cli/ast/modify.go +++ b/internal/cli/ast/modify.go @@ -35,8 +35,8 @@ func (e *ProviderError) Unwrap() error { // Common errors var ( - ErrDefinitionNotFound = errors.New("Definition method not found") - ErrProvidersNotFound = errors.New("Providers field not found in Definition") + ErrDefinitionNotFound = errors.New("definition method not found") + ErrProvidersNotFound = errors.New("providers field not found in definition") ErrTokenExists = errors.New("provider token already exists") ) @@ -263,7 +263,7 @@ func (e *ControllerError) Unwrap() error { } // ErrControllersNotFound is returned when Controllers field is not found in Definition -var ErrControllersNotFound = errors.New("Controllers field not found in Definition") +var ErrControllersNotFound = errors.New("controllers field not found in definition") // AddController registers a new controller in the module definition func AddController(filePath, controllerName, buildFunc string) error { diff --git a/internal/cli/cmd/naming.go b/internal/cli/cmd/naming.go index c80bfda..41ea71d 100644 --- a/internal/cli/cmd/naming.go +++ b/internal/cli/cmd/naming.go @@ -66,7 +66,7 @@ func validateScaffoldName(value, label string) error { return fmt.Errorf("invalid %s: %q", label, value) } for _, r := range value { - if !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_') { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' { return fmt.Errorf("invalid %s: %q", label, value) } } From 82f3a967645afdd2a86b702fc62f71023d5af1b0 Mon Sep 17 00:00:00 2001 From: Arye Kogan Date: Fri, 13 Feb 2026 04:38:26 +0200 Subject: [PATCH 31/31] chore(lint): enable gosec with v2 config Ultraworked with Sisyphus https://github.com/code-yeongyu/oh-my-opencode Co-authored-by: Sisyphus --- .golangci.yml | 10 +++++++++- internal/cli/ast/modify_test.go | 16 ++++++++-------- internal/cli/cmd/new_app_test.go | 8 ++++---- internal/cli/cmd/new_controller_test.go | 6 +++--- internal/cli/cmd/new_module_test.go | 2 +- internal/cli/cmd/new_provider_test.go | 8 ++++---- modkit/config/module_test.go | 18 +++++++++--------- modkit/http/server_test.go | 6 +++--- 8 files changed, 41 insertions(+), 33 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index e93dac5..c117c63 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -12,6 +12,7 @@ linters: - staticcheck - govet - unused + - gosec - bodyclose - nilerr @@ -36,6 +37,13 @@ formatters: local-prefixes: github.com/go-modkit/modkit linters-settings: + gosec: + excludes: + - G101 + - G302 + - G304 + - G306 + - G114 govet: enable: - nilness @@ -66,7 +74,7 @@ issues: exclude-rules: # Relax rules for tests - - path: ".*_test\\.go$" + - path: '.*_test\.go$' linters: - errcheck - dupl diff --git a/internal/cli/ast/modify_test.go b/internal/cli/ast/modify_test.go index c5caab9..5710001 100644 --- a/internal/cli/ast/modify_test.go +++ b/internal/cli/ast/modify_test.go @@ -38,7 +38,7 @@ func (m *Module) Definition() module.ModuleDef { t.Fatalf("AddProvider failed: %v", err) } - b, err := os.ReadFile(file) + b, err := os.ReadFile(file) //nolint:gosec if err != nil { t.Fatal(err) } @@ -178,7 +178,7 @@ func (m *Module) Definition() module.ModuleDef { t.Fatalf("Duplicate AddProvider should succeed idempotently: %v", err) } - b, err := os.ReadFile(file) + b, err := os.ReadFile(file) //nolint:gosec if err != nil { t.Fatal(err) } @@ -220,7 +220,7 @@ func (m *Module) Definition() module.ModuleDef { t.Fatalf("AddController failed: %v", err) } - b, err := os.ReadFile(file) + b, err := os.ReadFile(file) //nolint:gosec if err != nil { t.Fatal(err) } @@ -266,7 +266,7 @@ func (m *Module) Definition() module.ModuleDef { t.Fatalf("Duplicate AddController should succeed idempotently: %v", err) } - b, err := os.ReadFile(file) + b, err := os.ReadFile(file) //nolint:gosec if err != nil { t.Fatal(err) } @@ -555,10 +555,10 @@ func (m *Module) Definition() module.ModuleDef { t.Fatal(err) } - if err := os.Chmod(moduleDir, 0o500); err != nil { + if err := os.Chmod(moduleDir, 0o500); err != nil { //nolint:gosec t.Fatal(err) } - t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) + t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) //nolint:gosec err := AddProvider(file, "users.auth", "buildAuth") if err == nil { @@ -601,10 +601,10 @@ func (m *Module) Definition() module.ModuleDef { t.Fatal(err) } - if err := os.Chmod(moduleDir, 0o500); err != nil { + if err := os.Chmod(moduleDir, 0o500); err != nil { //nolint:gosec t.Fatal(err) } - t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) + t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) //nolint:gosec err := AddController(file, "UsersController", "NewUsersController") if err == nil { diff --git a/internal/cli/cmd/new_app_test.go b/internal/cli/cmd/new_app_test.go index 4e4afe6..5453c3b 100644 --- a/internal/cli/cmd/new_app_test.go +++ b/internal/cli/cmd/new_app_test.go @@ -61,7 +61,7 @@ func TestCreateNewApp(t *testing.T) { shim = filepath.Join(binDir, "go.bat") content = "@echo off\r\nexit /b 0\r\n" } - if err := os.WriteFile(shim, []byte(content), 0o755); err != nil { + if err := os.WriteFile(shim, []byte(content), 0o755); err != nil { //nolint:gosec t.Fatal(err) } @@ -79,7 +79,7 @@ func TestCreateNewApp(t *testing.T) { t.Fatalf("expected go.mod, got %v", err) } - modBytes, err := os.ReadFile(filepath.Join(tmp, "demo", "go.mod")) + modBytes, err := os.ReadFile(filepath.Join(tmp, "demo", "go.mod")) //nolint:gosec if err != nil { t.Fatal(err) } @@ -194,7 +194,7 @@ func TestCreateNewAppExistingEmptyDirectory(t *testing.T) { shim = filepath.Join(binDir, "go.bat") content = "@echo off\r\nexit /b 0\r\n" } - if err := os.WriteFile(shim, []byte(content), 0o755); err != nil { + if err := os.WriteFile(shim, []byte(content), 0o755); err != nil { //nolint:gosec t.Fatal(err) } oldPath := os.Getenv("PATH") @@ -262,7 +262,7 @@ func TestCreateNewAppRunE(t *testing.T) { shim = filepath.Join(binDir, "go.bat") content = "@echo off\r\nexit /b 0\r\n" } - if err := os.WriteFile(shim, []byte(content), 0o755); err != nil { + if err := os.WriteFile(shim, []byte(content), 0o755); err != nil { //nolint:gosec t.Fatal(err) } oldPath := os.Getenv("PATH") diff --git a/internal/cli/cmd/new_controller_test.go b/internal/cli/cmd/new_controller_test.go index 87ddff6..f495606 100644 --- a/internal/cli/cmd/new_controller_test.go +++ b/internal/cli/cmd/new_controller_test.go @@ -43,7 +43,7 @@ func (m *UserServiceModule) Definition() module.ModuleDef { t.Fatalf("createNewController failed: %v", err) } - b, err := os.ReadFile(filepath.Join(moduleDir, "auth_controller.go")) + b, err := os.ReadFile(filepath.Join(moduleDir, "auth_controller.go")) //nolint:gosec if err != nil { t.Fatal(err) } @@ -252,10 +252,10 @@ func TestCreateNewControllerCreateFileFailure(t *testing.T) { if err := os.WriteFile(filepath.Join(moduleDir, "module.go"), []byte("package users\n"), 0o600); err != nil { t.Fatal(err) } - if err := os.Chmod(moduleDir, 0o500); err != nil { + if err := os.Chmod(moduleDir, 0o500); err != nil { //nolint:gosec t.Fatal(err) } - t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) + t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) //nolint:gosec if err := createNewController("auth", "users"); err == nil { t.Fatal("expected error when controller file cannot be created") diff --git a/internal/cli/cmd/new_module_test.go b/internal/cli/cmd/new_module_test.go index aabf190..7244857 100644 --- a/internal/cli/cmd/new_module_test.go +++ b/internal/cli/cmd/new_module_test.go @@ -22,7 +22,7 @@ func TestCreateNewModule(t *testing.T) { t.Fatalf("createNewModule failed: %v", err) } - b, err := os.ReadFile(filepath.Join(tmp, "internal", "modules", "user-service", "module.go")) + b, err := os.ReadFile(filepath.Join(tmp, "internal", "modules", "user-service", "module.go")) //nolint:gosec if err != nil { t.Fatal(err) } diff --git a/internal/cli/cmd/new_provider_test.go b/internal/cli/cmd/new_provider_test.go index 543b32d..3045115 100644 --- a/internal/cli/cmd/new_provider_test.go +++ b/internal/cli/cmd/new_provider_test.go @@ -84,7 +84,7 @@ func (m *UserServiceModule) Definition() module.ModuleDef { t.Fatalf("createNewProvider failed: %v", err) } - b, err := os.ReadFile(filepath.Join(moduleDir, "auth.go")) + b, err := os.ReadFile(filepath.Join(moduleDir, "auth.go")) //nolint:gosec if err != nil { t.Fatal(err) } @@ -312,7 +312,7 @@ func (m *UsersModule) Definition() module.ModuleDef { t.Fatalf("createNewProvider failed: %v", err) } - b, err := os.ReadFile(modulePath) + b, err := os.ReadFile(modulePath) //nolint:gosec if err != nil { t.Fatal(err) } @@ -340,10 +340,10 @@ func TestCreateNewProviderCreateFileFailure(t *testing.T) { if err := os.WriteFile(filepath.Join(moduleDir, "module.go"), []byte("package users\n"), 0o600); err != nil { t.Fatal(err) } - if err := os.Chmod(moduleDir, 0o500); err != nil { + if err := os.Chmod(moduleDir, 0o500); err != nil { //nolint:gosec t.Fatal(err) } - t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) + t.Cleanup(func() { _ = os.Chmod(moduleDir, 0o750) }) //nolint:gosec if err := createNewProvider("auth", "users"); err == nil { t.Fatal("expected error when provider file cannot be created") diff --git a/modkit/config/module_test.go b/modkit/config/module_test.go index 0784110..019f927 100644 --- a/modkit/config/module_test.go +++ b/modkit/config/module_test.go @@ -42,7 +42,7 @@ func mod( } func TestWithTyped_DefaultAndParse(t *testing.T) { - const token module.Token = "config.jwt_ttl" + const token module.Token = "config.jwt_ttl" //nolint:gosec def := 1 * time.Hour cfgModule := config.NewModule( @@ -76,7 +76,7 @@ func TestWithTyped_DefaultAndParse(t *testing.T) { } func TestWithTyped_UsesDefaultWhenUnset(t *testing.T) { - const token module.Token = "config.http_addr" + const token module.Token = "config.http_addr" //nolint:gosec def := ":8080" cfgModule := config.NewModule( @@ -105,7 +105,7 @@ func TestWithTyped_UsesDefaultWhenUnset(t *testing.T) { } func TestWithTyped_OptionalUnsetReturnsZeroWithoutParsing(t *testing.T) { - const token module.Token = "config.optional_int" + const token module.Token = "config.optional_int" //nolint:gosec called := false cfgModule := config.NewModule( @@ -145,7 +145,7 @@ func TestWithTyped_OptionalUnsetReturnsZeroWithoutParsing(t *testing.T) { } func TestWithTyped_MissingRequired(t *testing.T) { - const token module.Token = "config.jwt_secret" + const token module.Token = "config.jwt_secret" //nolint:gosec cfgModule := config.NewModule( config.WithSource(mapSource{}), @@ -187,7 +187,7 @@ func TestWithTyped_MissingRequired(t *testing.T) { } func TestWithTyped_ParseError(t *testing.T) { - const token module.Token = "config.rate_limit_burst" + const token module.Token = "config.rate_limit_burst" //nolint:gosec cfgModule := config.NewModule( config.WithSource(mapSource{"RATE_LIMIT_BURST": "NaN"}), @@ -223,7 +223,7 @@ func TestWithTyped_ParseError(t *testing.T) { func TestWithTyped_InvalidSpec(t *testing.T) { t.Run("empty key", func(t *testing.T) { - const token module.Token = "config.foo" + const token module.Token = "config.foo" //nolint:gosec cfgModule := config.NewModule( config.WithSource(mapSource{"X": "1"}), config.WithTyped(token, config.ValueSpec[int]{ @@ -276,7 +276,7 @@ func TestWithTyped_InvalidSpec(t *testing.T) { } func TestWithTyped_SensitiveErrorDoesNotLeakValue(t *testing.T) { - const token module.Token = "config.jwt_secret" + const token module.Token = "config.jwt_secret" //nolint:gosec cfgModule := config.NewModule( config.WithSource(mapSource{"JWT_SECRET": "super-secret-value"}), @@ -305,7 +305,7 @@ func TestWithTyped_SensitiveErrorDoesNotLeakValue(t *testing.T) { } func TestWithSourceNil(t *testing.T) { - const token module.Token = "config.foo" + const token module.Token = "config.foo" //nolint:gosec cfgModule := config.NewModule( config.WithSource(nil), @@ -334,7 +334,7 @@ func TestWithSourceNil(t *testing.T) { } func TestNoReflectionMagic_CustomParser(t *testing.T) { - const token module.Token = "config.custom" + const token module.Token = "config.custom" //nolint:gosec parseCustom := func(raw string) (string, error) { if raw != "expected" { diff --git a/modkit/http/server_test.go b/modkit/http/server_test.go index af894fe..97260d1 100644 --- a/modkit/http/server_test.go +++ b/modkit/http/server_test.go @@ -33,7 +33,7 @@ func TestServe_ReturnsErrorWhenServerFailsToStart(t *testing.T) { } router := NewRouter() - err := Serve("127.0.0.1:12345", router) + err := Serve("127.0.0.1:12345", router) //nolint:gosec if gotAddr != "127.0.0.1:12345" { t.Fatalf("expected addr %q, got %q", "127.0.0.1:12345", gotAddr) @@ -77,7 +77,7 @@ func TestServe_HandlesSignals_ReturnsNil(t *testing.T) { errCh := make(chan error, 1) go func() { - errCh <- Serve("127.0.0.1:12345", NewRouter()) + errCh <- Serve("127.0.0.1:12345", NewRouter()) //nolint:gosec }() <-serveStarted @@ -135,7 +135,7 @@ func TestServe_ShutdownWaitsForInFlightRequest(t *testing.T) { serveErrCh := make(chan error, 1) go func() { - serveErrCh <- Serve(addr, handler) + serveErrCh <- Serve(addr, handler) //nolint:gosec }() clientErrCh := make(chan error, 1)