@nakedev/go-scaffold 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +75 -0
  2. package/dist/commands/auth.js +129 -0
  3. package/dist/commands/create.js +3 -2
  4. package/dist/commands/generate.js +59 -1
  5. package/dist/commands/method.js +3 -0
  6. package/dist/commands/migration.js +34 -0
  7. package/dist/commands/rbac.js +103 -0
  8. package/dist/commands/remove.js +19 -2
  9. package/dist/commands/worker.js +75 -0
  10. package/dist/index.js +66 -3
  11. package/dist/prompts/create-wizard.js +6 -1
  12. package/dist/prompts/generate-wizard.js +8 -0
  13. package/dist/templates/auth-manifest.js +19 -0
  14. package/dist/templates/create-manifest.js +25 -0
  15. package/dist/templates/rbac-manifest.js +17 -0
  16. package/dist/templates/worker-manifest.js +12 -0
  17. package/dist/utils/auth-patcher.js +96 -0
  18. package/dist/utils/gocheck.js +65 -0
  19. package/dist/utils/main-patcher.js +8 -1
  20. package/dist/utils/migrations.js +30 -8
  21. package/dist/utils/openapi-patcher.js +16 -0
  22. package/dist/utils/platform-patcher.js +59 -0
  23. package/dist/utils/rbac-patcher.js +277 -0
  24. package/dist/utils/version.js +24 -0
  25. package/package.json +2 -2
  26. package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
  27. package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
  28. package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
  29. package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
  30. package/templates/add/auth/docs/login.yaml.hbs +19 -0
  31. package/templates/add/auth/docs/logout.yaml.hbs +8 -0
  32. package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
  33. package/templates/add/auth/docs/register.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
  35. package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
  36. package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
  37. package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
  38. package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
  39. package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
  40. package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
  41. package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
  42. package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
  43. package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
  44. package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
  45. package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
  46. package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
  50. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
  51. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
  52. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
  53. package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
  54. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
  55. package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
  56. package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
  57. package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
  58. package/templates/add/rbac/docs/role.yaml.hbs +17 -0
  59. package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
  60. package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
  61. package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
  62. package/templates/add/rbac/docs/user.yaml.hbs +16 -0
  63. package/templates/add/rbac/docs/users.yaml.hbs +23 -0
  64. package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
  65. package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
  66. package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
  67. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
  68. package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
  69. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
  70. package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
  71. package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
  72. package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
  73. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
  74. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
  75. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
  76. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
  77. package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
  78. package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
  79. package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
  80. package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
  81. package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
  82. package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
  83. package/templates/create/base/.env.example.hbs +20 -0
  84. package/templates/create/base/.github/workflows/ci.yml.hbs +4 -2
  85. package/templates/create/base/.gitignore.hbs +2 -0
  86. package/templates/create/base/Makefile.hbs +30 -5
  87. package/templates/create/base/README.md.hbs +36 -8
  88. package/templates/create/base/cmd/api/main.go.hbs +26 -1
  89. package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
  90. package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
  91. package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
  92. package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
  93. package/templates/create/base/migrations/embed.go.hbs +15 -0
  94. package/templates/create/features/docs/architecture.md.hbs +22 -0
  95. package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
  96. package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
  97. package/templates/create/features/docs/openapi.yaml.hbs +13 -0
  98. package/templates/create/features/docs/techstack.md.hbs +3 -0
  99. package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
  100. package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
  101. package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
  102. package/templates/generate/module/handler.go.hbs +20 -3
  103. package/templates/generate/module/handler_test.go.hbs +49 -6
  104. package/templates/generate/module/minimal/handler.go.hbs +21 -3
  105. package/templates/generate/module/minimal/handler_test.go.hbs +53 -6
  106. package/templates/generate/module/permission.down.sql.hbs +5 -0
  107. package/templates/generate/module/permission.up.sql.hbs +4 -0
  108. package/dist/utils/module-paths.js +0 -33
@@ -0,0 +1,88 @@
1
+ package middleware
2
+
3
+ import (
4
+ "context"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "sync/atomic"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/gin-gonic/gin"
12
+ )
13
+
14
+ func TestAuthz_CachesWithinTTL(t *testing.T) {
15
+ var calls int32
16
+ a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
17
+ atomic.AddInt32(&calls, 1)
18
+ return map[string]struct{}{"x": {}}, nil
19
+ }, time.Minute)
20
+
21
+ if _, err := a.permsOf(context.Background(), "staff"); err != nil {
22
+ t.Fatalf("unexpected error: %v", err)
23
+ }
24
+ if _, err := a.permsOf(context.Background(), "staff"); err != nil {
25
+ t.Fatalf("unexpected error: %v", err)
26
+ }
27
+ if got := atomic.LoadInt32(&calls); got != 1 {
28
+ t.Fatalf("want resolve called once for two calls within TTL (cache hit), got %d", got)
29
+ }
30
+ }
31
+
32
+ func TestAuthz_ReResolvesAfterTTLExpires(t *testing.T) {
33
+ var calls int32
34
+ a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
35
+ atomic.AddInt32(&calls, 1)
36
+ return map[string]struct{}{"x": {}}, nil
37
+ }, 10*time.Millisecond)
38
+
39
+ if _, err := a.permsOf(context.Background(), "staff"); err != nil {
40
+ t.Fatalf("unexpected error: %v", err)
41
+ }
42
+ time.Sleep(20 * time.Millisecond)
43
+ if _, err := a.permsOf(context.Background(), "staff"); err != nil {
44
+ t.Fatalf("unexpected error: %v", err)
45
+ }
46
+ if got := atomic.LoadInt32(&calls); got != 2 {
47
+ t.Fatalf("want resolve called again once the TTL expires, got %d calls", got)
48
+ }
49
+ }
50
+
51
+ func TestAuthz_Require_ForbidsMissingPermission(t *testing.T) {
52
+ gin.SetMode(gin.TestMode)
53
+ a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
54
+ return map[string]struct{}{"other:perm": {}}, nil
55
+ }, time.Minute)
56
+
57
+ r := gin.New()
58
+ // Require only records a *apperror.AppError via c.Error — Error(true) is
59
+ // what actually translates that into the response status code, same as
60
+ // the real router (see cmd/api/main.go).
61
+ r.Use(Error(true))
62
+ r.GET("/x", func(c *gin.Context) { c.Set(RoleKey, "staff") }, a.Require("role:manage"), func(c *gin.Context) {
63
+ c.Status(http.StatusOK)
64
+ })
65
+ w := httptest.NewRecorder()
66
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
67
+ if w.Code != http.StatusForbidden {
68
+ t.Fatalf("want 403 without the required permission, got %d", w.Code)
69
+ }
70
+ }
71
+
72
+ func TestAuthz_Require_AllowsGrantedPermission(t *testing.T) {
73
+ gin.SetMode(gin.TestMode)
74
+ a := NewAuthz(func(context.Context, string) (map[string]struct{}, error) {
75
+ return map[string]struct{}{"role:manage": {}}, nil
76
+ }, time.Minute)
77
+
78
+ r := gin.New()
79
+ r.Use(Error(true))
80
+ r.GET("/x", func(c *gin.Context) { c.Set(RoleKey, "admin") }, a.Require("role:manage"), func(c *gin.Context) {
81
+ c.Status(http.StatusOK)
82
+ })
83
+ w := httptest.NewRecorder()
84
+ r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
85
+ if w.Code != http.StatusOK {
86
+ t.Fatalf("want 200 with the required permission granted, got %d", w.Code)
87
+ }
88
+ }
@@ -0,0 +1,15 @@
1
+ -- if an admin created a custom role after this migration and it's still
2
+ -- assigned to any user, dropping the roles table would orphan that value —
3
+ -- raise a clear, actionable error instead of a raw FK-violation.
4
+ DO $$
5
+ BEGIN
6
+ IF EXISTS (SELECT 1 FROM users WHERE role NOT IN ('staff', 'admin')) THEN
7
+ RAISE EXCEPTION 'cannot roll back add_roles: users still have a role other than staff/admin — reassign them to staff/admin first';
8
+ END IF;
9
+ END $$;
10
+
11
+ ALTER TABLE users DROP COLUMN role;
12
+
13
+ DROP TABLE role_permissions;
14
+ DROP TABLE permissions;
15
+ DROP TABLE roles;
@@ -0,0 +1,35 @@
1
+ CREATE TABLE roles (
2
+ code VARCHAR(20) PRIMARY KEY,
3
+ name TEXT NOT NULL,
4
+ is_system BOOLEAN NOT NULL DEFAULT FALSE,
5
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
6
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
7
+ );
8
+
9
+ CREATE TABLE permissions (
10
+ code VARCHAR(50) PRIMARY KEY,
11
+ description TEXT NOT NULL
12
+ );
13
+
14
+ CREATE TABLE role_permissions (
15
+ role_code VARCHAR(20) NOT NULL REFERENCES roles(code) ON DELETE CASCADE,
16
+ permission_code VARCHAR(50) NOT NULL REFERENCES permissions(code) ON DELETE CASCADE,
17
+ PRIMARY KEY (role_code, permission_code)
18
+ );
19
+
20
+ INSERT INTO roles (code, name, is_system) VALUES
21
+ ('staff', 'Staff', TRUE),
22
+ ('admin', 'Admin', TRUE);
23
+
24
+ INSERT INTO permissions (code, description) VALUES
25
+ ('role:manage', 'Create roles and change their permissions'),
26
+ ('user:manage-role', 'Change a user''s assigned role'),
27
+ ('user:read', 'List and view other users');
28
+
29
+ -- admin gets every permission that exists, staff gets none — no behavior
30
+ -- change for existing users until an admin explicitly grants something.
31
+ INSERT INTO role_permissions (role_code, permission_code)
32
+ SELECT 'admin', code FROM permissions;
33
+
34
+ ALTER TABLE users
35
+ ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'staff' REFERENCES roles(code);
@@ -0,0 +1,77 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "log/slog"
7
+ "os"
8
+ "os/signal"
9
+ "syscall"
10
+ "time"
11
+
12
+ "{{goModule}}/internal/platform/mail"
13
+ "{{goModule}}/internal/platform/queue"
14
+ "{{goModule}}/internal/shared/config"
15
+ // go-scaffold:imports
16
+ )
17
+
18
+ func main() {
19
+ cfg := config.Load()
20
+
21
+ logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}))
22
+ slog.SetDefault(logger)
23
+
24
+ srv, err := queue.NewServer(cfg.RedisURL)
25
+ if err != nil {
26
+ logger.Error("new queue server", "error", err)
27
+ os.Exit(1)
28
+ }
29
+
30
+ srv.Handle(mail.TypeSendEmail, mail.HandleSendEmail(mail.Open(cfg)))
31
+ // go-scaffold:queue-handlers
32
+
33
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
34
+ defer stop()
35
+
36
+ go func() {
37
+ logger.Info("worker started")
38
+ if err := srv.Start(); err != nil && !errors.Is(err, context.Canceled) {
39
+ logger.Error("worker server", "error", err)
40
+ os.Exit(1)
41
+ }
42
+ }()
43
+
44
+ <-ctx.Done()
45
+ logger.Info("shutting down worker")
46
+
47
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
48
+ defer cancel()
49
+ // asynq's Shutdown() blocks until in-flight tasks finish (or the process
50
+ // is killed) with no context/timeout parameter of its own — run it in a
51
+ // goroutine and race it against shutdownCtx so a stuck task can't hang
52
+ // the process forever.
53
+ done := make(chan struct{}, 1)
54
+ go func() {
55
+ srv.Shutdown()
56
+ close(done)
57
+ }()
58
+ select {
59
+ case <-done:
60
+ case <-shutdownCtx.Done():
61
+ logger.Warn("worker shutdown timed out")
62
+ }
63
+ logger.Info("worker stopped")
64
+ }
65
+
66
+ func parseLevel(s string) slog.Level {
67
+ switch s {
68
+ case "debug":
69
+ return slog.LevelDebug
70
+ case "warn":
71
+ return slog.LevelWarn
72
+ case "error":
73
+ return slog.LevelError
74
+ default:
75
+ return slog.LevelInfo
76
+ }
77
+ }
@@ -0,0 +1,18 @@
1
+ package cache
2
+
3
+ import (
4
+ "{{goModule}}/internal/shared/config"
5
+
6
+ "github.com/redis/go-redis/v9"
7
+ )
8
+
9
+ // Open connects to Redis — it talks to a real external system, so it lives
10
+ // in platform/, not shared/. Used for ephemeral, TTL-bound data and as the
11
+ // backing store for the async task queue (platform/queue).
12
+ func Open(cfg config.Config) (*redis.Client, error) {
13
+ opts, err := redis.ParseURL(cfg.RedisURL)
14
+ if err != nil {
15
+ return nil, err
16
+ }
17
+ return redis.NewClient(opts), nil
18
+ }
@@ -0,0 +1,48 @@
1
+ package mail
2
+
3
+ import (
4
+ "fmt"
5
+ "log/slog"
6
+ "net/smtp"
7
+
8
+ "{{goModule}}/internal/shared/config"
9
+ )
10
+
11
+ // Client sends plain-text email over SMTP (net/smtp already handles STARTTLS
12
+ // when the server offers it, which covers Gmail/SendGrid/Mailgun/SES/Resend
13
+ // SMTP relays on port 587) — talks to a real external system, so it lives in
14
+ // platform/, not shared/.
15
+ type Client struct {
16
+ host, port, username, password, from string
17
+ }
18
+
19
+ func Open(cfg config.Config) *Client {
20
+ return &Client{
21
+ host: cfg.SMTPHost,
22
+ port: cfg.SMTPPort,
23
+ username: cfg.SMTPUsername,
24
+ password: cfg.SMTPPassword,
25
+ from: cfg.SMTPFrom,
26
+ }
27
+ }
28
+
29
+ // Send delivers a plain-text email. If SMTP isn't configured (dev default —
30
+ // SMTP_HOST unset), it logs the message instead of failing, so flows that
31
+ // send email stay testable without a real mail server.
32
+ func (c *Client) Send(to, subject, body string) error {
33
+ if c.host == "" {
34
+ // ponytail: no SMTP configured — log instead of failing. Upgrade path:
35
+ // set SMTP_HOST (+ USERNAME/PASSWORD/FROM) once a real environment needs it.
36
+ slog.Info("email not sent (SMTP not configured)", "to", to, "subject", subject, "body", body)
37
+ return nil
38
+ }
39
+ msg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\n%s\r\n", to, c.from, subject, body)
40
+ // nil Auth when no username is set — a relay/local catcher that doesn't
41
+ // require auth (or doesn't advertise AUTH at all) rejects a non-nil Auth
42
+ // outright, even with empty credentials.
43
+ var auth smtp.Auth
44
+ if c.username != "" {
45
+ auth = smtp.PlainAuth("", c.username, c.password, c.host)
46
+ }
47
+ return smtp.SendMail(c.host+":"+c.port, auth, c.from, []string{to}, []byte(msg))
48
+ }
@@ -0,0 +1,52 @@
1
+ package mail
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+
7
+ "github.com/hibiken/asynq"
8
+ )
9
+
10
+ const TypeSendEmail = "email:send"
11
+
12
+ type sendEmailPayload struct {
13
+ To string
14
+ Subject string
15
+ Body string
16
+ }
17
+
18
+ func NewSendEmailTask(p sendEmailPayload) *asynq.Task {
19
+ payload, _ := json.Marshal(p)
20
+ return asynq.NewTask(TypeSendEmail, payload)
21
+ }
22
+
23
+ func HandleSendEmail(client *Client) asynq.Handler {
24
+ return asynq.HandlerFunc(func(ctx context.Context, task *asynq.Task) error {
25
+ var p sendEmailPayload
26
+ if err := json.Unmarshal(task.Payload(), &p); err != nil {
27
+ return err
28
+ }
29
+ return client.Send(p.To, p.Subject, p.Body)
30
+ })
31
+ }
32
+
33
+ type AsyncClient struct {
34
+ q interface {
35
+ Enqueue(task *asynq.Task, opts ...asynq.Option) (string, error)
36
+ }
37
+ }
38
+
39
+ func NewAsyncClient(q interface {
40
+ Enqueue(task *asynq.Task, opts ...asynq.Option) (string, error)
41
+ }) *AsyncClient {
42
+ return &AsyncClient{q: q}
43
+ }
44
+
45
+ // Send enqueues the email instead of sending it inline, so the caller (an
46
+ // HTTP handler) returns in ~µs instead of blocking on SMTP. cmd/worker picks
47
+ // it up and calls Client.Send.
48
+ func (c *AsyncClient) Send(to, subject, body string) error {
49
+ task := NewSendEmailTask(sendEmailPayload{To: to, Subject: subject, Body: body})
50
+ _, err := c.q.Enqueue(task, asynq.MaxRetry(5))
51
+ return err
52
+ }
@@ -0,0 +1,31 @@
1
+ package queue
2
+
3
+ import (
4
+ "github.com/hibiken/asynq"
5
+ )
6
+
7
+ // Client enqueues tasks to Redis. Created by cmd/api; each domain or platform
8
+ // package defines its own task types and calls Client.Enqueue.
9
+ type Client struct {
10
+ inner *asynq.Client
11
+ }
12
+
13
+ func NewClient(redisURL string) (*Client, error) {
14
+ opt, err := asynq.ParseRedisURI(redisURL)
15
+ if err != nil {
16
+ return nil, err
17
+ }
18
+ return &Client{inner: asynq.NewClient(opt)}, nil
19
+ }
20
+
21
+ func (c *Client) Enqueue(task *asynq.Task, opts ...asynq.Option) (string, error) {
22
+ info, err := c.inner.Enqueue(task, opts...)
23
+ if err != nil {
24
+ return "", err
25
+ }
26
+ return info.ID, nil
27
+ }
28
+
29
+ func (c *Client) Close() error {
30
+ return c.inner.Close()
31
+ }
@@ -0,0 +1,68 @@
1
+ package queue
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "log/slog"
7
+
8
+ "github.com/hibiken/asynq"
9
+ )
10
+
11
+ // Server runs the background worker. Created by cmd/worker; handlers register
12
+ // their task types on the mux before Start is called.
13
+ type Server struct {
14
+ inner *asynq.Server
15
+ mux *asynq.ServeMux
16
+ }
17
+
18
+ // slogAdapter wraps *slog.Logger to implement asynq.Logger. Asynq calls these
19
+ // like the stdlib `log` package (a plain message, sometimes several args
20
+ // meant to be concatenated) — fmt.Sprint joins them into one message string
21
+ // rather than passing them as slog key-value pairs, which would otherwise
22
+ // misinterpret a lone message argument as a key with no value.
23
+ type slogAdapter struct {
24
+ inner *slog.Logger
25
+ }
26
+
27
+ func (a *slogAdapter) Debug(args ...interface{}) { a.inner.Debug(fmt.Sprint(args...)) }
28
+ func (a *slogAdapter) Info(args ...interface{}) { a.inner.Info(fmt.Sprint(args...)) }
29
+ func (a *slogAdapter) Warn(args ...interface{}) { a.inner.Warn(fmt.Sprint(args...)) }
30
+ func (a *slogAdapter) Error(args ...interface{}) { a.inner.Error(fmt.Sprint(args...)) }
31
+ func (a *slogAdapter) Fatal(args ...interface{}) { a.inner.Error(fmt.Sprint(args...)) }
32
+
33
+ func NewServer(redisURL string) (*Server, error) {
34
+ opt, err := asynq.ParseRedisURI(redisURL)
35
+ if err != nil {
36
+ return nil, err
37
+ }
38
+ srv := &Server{
39
+ inner: asynq.NewServer(opt, asynq.Config{
40
+ Concurrency: 10,
41
+ Logger: &slogAdapter{inner: slog.Default()},
42
+ }),
43
+ mux: asynq.NewServeMux(),
44
+ }
45
+ // Global error handler for tasks that panic or return a non-Retryable error.
46
+ srv.mux.Use(func(next asynq.Handler) asynq.Handler {
47
+ return asynq.HandlerFunc(func(ctx context.Context, task *asynq.Task) error {
48
+ err := next.ProcessTask(ctx, task)
49
+ if err != nil {
50
+ slog.Error("task failed", "type", task.Type(), "error", err)
51
+ }
52
+ return err
53
+ })
54
+ })
55
+ return srv, nil
56
+ }
57
+
58
+ func (s *Server) Handle(pattern string, handler asynq.Handler) {
59
+ s.mux.Handle(pattern, handler)
60
+ }
61
+
62
+ func (s *Server) Start() error {
63
+ return s.inner.Start(s.mux)
64
+ }
65
+
66
+ func (s *Server) Shutdown() {
67
+ s.inner.Shutdown()
68
+ }
@@ -1,3 +1,8 @@
1
+ # development | production — the one gate for prod behavior (config.IsProd):
2
+ # hides error `details` from responses. Any other value makes the app refuse
3
+ # to boot (so a typo can't silently downgrade prod).
4
+ APP_ENV=development # prod: production
5
+
1
6
  PORT=8080
2
7
  DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable
3
8
  LOG_LEVEL=info
@@ -5,3 +10,18 @@ AUTO_MIGRATE=true
5
10
  DB_MAX_OPEN_CONNS=10
6
11
  DB_MAX_IDLE_CONNS=10
7
12
  DB_CONN_MAX_LIFETIME_MIN=5
13
+
14
+ # read only by `go test`, never by cmd/api — a separate database on purpose: the
15
+ # integration-test harness does DropTable+AutoMigrate on every run, which would
16
+ # otherwise wipe the schema `make migrate-up` built in DB_DSN above.
17
+ # Create it once: make db-create DB_NAME={{dbName}}_test
18
+ TEST_DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable
19
+
20
+ # comma-separated frontend origins allowed to call this API with credentials (cookies)
21
+ CORS_ALLOWED_ORIGINS=http://localhost:3000
22
+ {{#if observability}}
23
+
24
+ # OTLP/HTTP endpoint for trace export (e.g. localhost:4318) — empty disables
25
+ # tracing entirely: no exporter is created, no network calls are made
26
+ OTEL_EXPORTER_OTLP_ENDPOINT=
27
+ {{/if}}
@@ -14,7 +14,9 @@ jobs:
14
14
  env:
15
15
  POSTGRES_USER: postgres
16
16
  POSTGRES_PASSWORD: postgres
17
- POSTGRES_DB: {{dbName}}
17
+ # the test database, not the app's — CI only runs tests, and the
18
+ # integration harness drops/recreates its tables on every run
19
+ POSTGRES_DB: {{dbName}}_test
18
20
  ports:
19
21
  - 5432:5432
20
22
  options: >-
@@ -42,5 +44,5 @@ jobs:
42
44
  version: latest
43
45
 
44
46
  # TEST_DB_DSN not set: the default in handler_test.go already points at
45
- # localhost:5432/{{dbName}} with postgres/postgres, matching the service above
47
+ # localhost:5432/{{dbName}}_test with postgres/postgres, matching the service above
46
48
  - run: go test ./...
@@ -1,5 +1,7 @@
1
1
  app.db
2
2
  *.db
3
3
  .env
4
+ .env.*
5
+ !.env.example
4
6
  bin/
5
7
  docs/openapi.bundled.yaml
@@ -4,16 +4,32 @@ DB_USER ?= postgres
4
4
  DB_NAME ?= {{dbName}}
5
5
  PGPASSWORD ?= postgres
6
6
 
7
- .PHONY: run build test fmt vet lint tidy db-create db-drop migrate-up migrate-down{{#if openapiDocs}} openapi-bundle{{/if}}{{#if docker}} docker-up docker-down{{/if}}
7
+ # which env file to load defaults to .env (your local override, gitignored).
8
+ # override to run against another file, e.g. copy .env.example to .env.production,
9
+ # fill it in, then `make run ENV_FILE=.env.production`.
10
+ #
11
+ # Every target that needs config loads it the same way: drop whole-line comments
12
+ # (^#) AND trailing ` # ...` comments — the sed only strips a `#` preceded by
13
+ # whitespace, so a `#` inside a value (password, DSN) is kept. Without the sed,
14
+ # `xargs` hands the comment's words to `export` too: `PORT=8080 # prod: PORT=80`
15
+ # would export PORT twice and the comment's value would win. (Can't factor this
16
+ # into a make variable: a `#` in a variable value starts a make comment; in a
17
+ # recipe line it's passed to the shell untouched.)
18
+ ENV_FILE ?= .env
19
+
20
+ .PHONY: run build test fmt vet lint tidy db-create db-drop migrate-up migrate-down migrate-verify{{#if openapiDocs}} openapi-bundle{{/if}}{{#if docker}} docker-up docker-down{{/if}}
8
21
 
9
22
  run:
10
- @[ -f .env ] && export $$(grep -v '^#' .env | xargs); go run ./cmd/api
23
+ @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); go run ./cmd/api
11
24
 
12
25
  build:
13
26
  go build -o bin/api ./cmd/api
14
27
 
28
+ # loads ENV_FILE like every other target, so TEST_DB_DSN set there reaches the
29
+ # integration tests. A bare `go test ./...` still works, but falls back to
30
+ # whatever defaults the test files carry.
15
31
  test:
16
- go test ./...
32
+ @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); go test ./...
17
33
 
18
34
  fmt:
19
35
  gofmt -w .
@@ -69,10 +85,19 @@ db-drop:
69
85
  fi
70
86
 
71
87
  migrate-up:
72
- migrate -path migrations -database "$$DB_DSN" up
88
+ @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); migrate -path migrations -database "$$DB_DSN" up
73
89
 
74
90
  migrate-down:
75
- migrate -path migrations -database "$$DB_DSN" down 1
91
+ @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); migrate -path migrations -database "$$DB_DSN" down 1
92
+
93
+ # runs up -> down-to-zero -> up against $DB_DSN to catch a bit-rotted down.sql
94
+ # (one that no longer reverses cleanly) before you actually need a rollback.
95
+ # point DB_DSN at a throwaway/test database first — this drops every table.
96
+ migrate-verify:
97
+ @[ -f $(ENV_FILE) ] && export $$(grep -v '^#' $(ENV_FILE) | sed -E 's/[[:space:]]+#.*$$//' | xargs); \
98
+ migrate -path migrations -database "$$DB_DSN" up && \
99
+ migrate -path migrations -database "$$DB_DSN" down -all && \
100
+ migrate -path migrations -database "$$DB_DSN" up
76
101
  {{#if openapiDocs}}
77
102
 
78
103
  # docs/openapi.yaml is hand-written and split across sibling files via relative
@@ -40,9 +40,13 @@ go mod tidy
40
40
  make run # AUTO_MIGRATE=true creates the schema automatically in dev
41
41
  ```
42
42
 
43
- `make run` loads `.env` if present (copy `.env.example` to `.env` to override
44
- defaults the app itself just reads `os.Getenv`, no `.env` parsing at
45
- runtime).
43
+ `make run`/`make test`/`make migrate-up`/`make migrate-down` load `.env` if
44
+ present (copy `.env.example` to `.env` to override defaults the app itself
45
+ just reads `os.Getenv`, no `.env` parsing at runtime). Point them at another
46
+ file with `ENV_FILE`: copy `.env.example` to `.env.production`, fill it in,
47
+ then `make run ENV_FILE=.env.production`. Trailing `# ...` comments in the env
48
+ file are stripped before loading, so a `# prod: ...` note next to a value is
49
+ safe; a `#` inside a value (password, DSN) is kept.
46
50
 
47
51
  `make db-create` connects to Postgres at `DB_HOST`/`DB_PORT`/`DB_USER` (default:
48
52
  `localhost`/`5432`/`postgres`, matching `.env.example`) using the `psql`
@@ -68,15 +72,20 @@ make lint # golangci-lint run (see .golangci.yml)
68
72
  make tidy # go mod tidy
69
73
  make db-create # create the database itself (safe to re-run)
70
74
  make db-drop # drop the database
71
- make migrate-up # apply migrations (needs $DB_DSN)
75
+ make migrate-up # apply migrations (reads DB_DSN from ENV_FILE)
72
76
  make migrate-down # roll back one migration
77
+ make migrate-verify # up -> down-to-zero -> up, catches a broken down.sql (point DB_DSN at a throwaway DB)
73
78
  {{#if docker}}make docker-up # docker compose up -d
74
79
  make docker-down # docker compose down
75
80
  {{/if}}```
76
81
 
77
82
  ## Migrations
78
83
 
79
- Schema is managed with [golang-migrate](https://github.com/golang-migrate/migrate), files live in `migrations/`.
84
+ Schema is managed with [golang-migrate](https://github.com/golang-migrate/migrate), files live in `migrations/`, named `<version>_<name>.{up,down}.sql`. `version` is a 14-digit UTC timestamp, not an incrementing counter — two people adding a migration off the same base branch get different filenames instead of both claiming the same number and colliding on merge. (`generate module` names its own migration the same way; existing sequential `0000NN_*` files from before this convention sort fine alongside timestamped ones either way.)
85
+
86
+ ```bash
87
+ go-scaffold generate migration add_status_to_orders # reserves migrations/<version>_add_status_to_orders.{up,down}.sql, TODO-stubbed — you write the SQL
88
+ ```
80
89
 
81
90
  ```bash
82
91
  brew install golang-migrate
@@ -84,16 +93,31 @@ brew install golang-migrate
84
93
 
85
94
  migrate -path migrations -database "$DB_DSN" up
86
95
  migrate -path migrations -database "$DB_DSN" down 1
87
- migrate create -ext sql -dir migrations -seq add_something
96
+ migrate create -ext sql -dir migrations -seq=false add_something # same as `go-scaffold generate migration`, if you'd rather not use the CLI
88
97
  ```
89
98
 
90
99
  **dev:** leave `AUTO_MIGRATE=true` (default) so GORM's AutoMigrate creates the schema quickly.
91
100
  **prod:** set `AUTO_MIGRATE=false` and run `migrate up` as a separate deploy step — versioned, has rollback (`down`), doesn't lock the table the way AutoMigrate does once there's real data.
92
101
 
102
+ With `AUTO_MIGRATE=false`, the app checks the DB's applied migration version
103
+ against the migration files baked into the binary (embedded at build time)
104
+ before it starts serving traffic — a stale or half-applied schema fails fast
105
+ at boot with a clear message, instead of failing later on whatever query
106
+ happens to hit the missing column first:
107
+
108
+ ```text
109
+ DB schema is at migration 3, this binary expects 5 — run `make migrate-up`
110
+ ```
111
+
112
+ ```bash
113
+ make migrate-verify # up -> down-to-zero -> up, catches a broken down.sql before you actually need a rollback (point DB_DSN at a throwaway DB first — this drops every table)
114
+ ```
115
+
93
116
  ## Env vars
94
117
 
95
118
  | var | default | notes |
96
119
  |---|---|---|
120
+ | `APP_ENV` | `development` | `development` or `production` — **the prod gate**: hides error `details` from responses. App refuses to boot on any other value |
97
121
  | `PORT` | `8080` | |
98
122
  | `DB_DSN` | `postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable` | used by both GORM and the `migrate` CLI |
99
123
  | `LOG_LEVEL` | `info` | debug/info/warn/error |
@@ -101,17 +125,21 @@ migrate create -ext sql -dir migrations -seq add_something
101
125
  | `DB_MAX_OPEN_CONNS` | `10` | |
102
126
  | `DB_MAX_IDLE_CONNS` | `10` | |
103
127
  | `DB_CONN_MAX_LIFETIME_MIN` | `5` | minutes |
128
+ | `CORS_ALLOWED_ORIGINS` | `http://localhost:3000` | comma-separated frontend origins allowed to call this API with credentials (cookies) |
104
129
 
105
130
  ## Tests
106
131
 
107
132
  Integration tests (handler-level) run against a **real Postgres** instance (same engine as prod), each test in a transaction that's rolled back — no leftover rows. If the DB isn't reachable those tests **skip** (unit tests using a fake repo always run).
108
133
 
134
+ They use a **separate `{{dbName}}_test` database**, not the one `DB_DSN` points at. That's deliberate: the harness runs `DropTable` + `AutoMigrate` on every real run, so pointing it at your dev database would wipe whatever `make migrate-up` built there — FK constraints and seed data included. Create it once:
135
+
109
136
  ```bash
110
137
  {{#if docker}}
111
138
  make docker-up # local postgres first
112
139
  {{/if}}
140
+ make db-create DB_NAME={{dbName}}_test
113
141
  make test
114
- # point at a different test DB: TEST_DB_DSN=postgres://... go test ./...
142
+ # point somewhere else entirely: TEST_DB_DSN=postgres://... go test ./...
115
143
  ```
116
144
 
117
145
  ## Error payload
@@ -120,7 +148,7 @@ make test
120
148
  {"error":{"code":"VALIDATION_ERROR","message":"invalid input","details":{"email":"email"},"request_id":"a1b2..."}}
121
149
  ```
122
150
 
123
- `code` is machine-readable, `details` names the field that failed, `request_id` correlates with server logs (header `X-Request-ID`).
151
+ `code` is machine-readable, `details` names the field that failed, `request_id` correlates with server logs (header `X-Request-ID`). `details` is **only returned outside production** (`APP_ENV` != `production`) — in prod it's stripped from every error so a direct caller can't learn the API's shape; the server log still has the full detail keyed by `request_id`.
124
152
  {{#if openapiDocs}}
125
153
 
126
154
  ## API spec