@nakedev/go-scaffold 0.1.4 → 0.3.0

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -44
  3. package/dist/commands/auth.js +116 -11
  4. package/dist/commands/create.js +13 -1
  5. package/dist/commands/generate.js +21 -11
  6. package/dist/commands/method.js +32 -3
  7. package/dist/commands/observability.js +114 -0
  8. package/dist/commands/rbac.js +19 -2
  9. package/dist/commands/undo.js +331 -0
  10. package/dist/commands/worker.js +92 -32
  11. package/dist/index.js +366 -63
  12. package/dist/prompts/auth-wizard.js +29 -0
  13. package/dist/prompts/create-wizard.js +5 -2
  14. package/dist/prompts/generate-wizard.js +57 -0
  15. package/dist/prompts/worker-wizard.js +25 -0
  16. package/dist/templates/auth-manifest.js +20 -3
  17. package/dist/templates/create-manifest.js +13 -20
  18. package/dist/templates/observability-manifest.js +24 -0
  19. package/dist/templates/rbac-manifest.js +1 -0
  20. package/dist/templates/worker-manifest.js +23 -6
  21. package/dist/utils/auth-patcher.js +96 -21
  22. package/dist/utils/config.js +58 -10
  23. package/dist/utils/gocheck.js +57 -5
  24. package/dist/utils/golangci-patcher.js +73 -0
  25. package/dist/utils/gomod-patcher.js +53 -0
  26. package/dist/utils/main-patcher.js +58 -4
  27. package/dist/utils/marker-patch.js +125 -3
  28. package/dist/utils/method-patcher.js +17 -2
  29. package/dist/utils/module-location.js +37 -1
  30. package/dist/utils/naming.js +50 -2
  31. package/dist/utils/observability-patcher.js +107 -0
  32. package/dist/utils/platform-patcher.js +98 -12
  33. package/dist/utils/rbac-patcher.js +60 -10
  34. package/package.json +3 -5
  35. package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
  36. package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
  37. package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
  38. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
  39. package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
  40. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
  41. package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
  42. package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
  43. package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
  44. package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
  45. package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
  46. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
  47. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
  48. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
  49. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
  50. package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
  51. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
  52. package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
  53. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
  54. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
  55. package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
  56. package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
  57. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  58. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
  59. package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
  60. package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
  61. package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
  62. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
  63. package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
  64. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
  65. package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
  66. package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
  67. package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
  68. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
  69. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
  70. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
  71. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
  72. package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
  73. package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
  74. package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
  75. package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
  76. package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
  77. package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
  78. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
  79. package/templates/create/base/.dockerignore.hbs +13 -0
  80. package/templates/create/base/.env.example.hbs +18 -9
  81. package/templates/create/base/.github/dependabot.yml.hbs +20 -0
  82. package/templates/create/base/.github/workflows/ci.yml.hbs +12 -2
  83. package/templates/create/base/.golangci.yml.hbs +27 -0
  84. package/templates/create/base/AGENTS.md.hbs +8 -4
  85. package/templates/create/base/Dockerfile.hbs +42 -0
  86. package/templates/create/base/Makefile.hbs +43 -13
  87. package/templates/create/base/README.md.hbs +45 -8
  88. package/templates/create/base/cmd/api/main.go.hbs +17 -130
  89. package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
  90. package/templates/create/base/go.mod.hbs +4 -4
  91. package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
  92. package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
  93. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
  94. package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
  95. package/templates/create/base/redocly.yaml.hbs +21 -0
  96. package/templates/create/features/docs/architecture.md.hbs +32 -11
  97. package/templates/create/features/docs/openapi.yaml.hbs +0 -4
  98. package/templates/create/features/docs/patterns.md.hbs +82 -8
  99. package/templates/create/features/docs/techstack.md.hbs +8 -3
  100. package/templates/generate/module/dto.go.hbs +8 -1
  101. package/templates/generate/module/errors.go.hbs +5 -0
  102. package/templates/generate/module/field-column.down.sql.hbs +2 -0
  103. package/templates/generate/module/field-column.up.sql.hbs +15 -0
  104. package/templates/generate/module/handler_test.go.hbs +8 -1
  105. package/templates/generate/module/migration.down.sql.hbs +3 -1
  106. package/templates/generate/module/migration.up.sql.hbs +7 -2
  107. package/templates/generate/module/minimal/dto.go.hbs +3 -1
  108. package/templates/generate/module/model/model.go.hbs +10 -1
  109. package/templates/generate/module/permission.up.sql.hbs +3 -1
  110. package/templates/generate/module/repository.go.hbs +60 -6
  111. package/templates/generate/module/repository_test.go.hbs +30 -0
  112. package/templates/generate/module/service.go.hbs +10 -1
  113. package/templates/generate/module/service_test.go.hbs +45 -0
  114. package/dist/commands/remove.js +0 -88
  115. package/scripts/smoke-test.mjs +0 -2058
  116. package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
  117. package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
  118. package/tests/integration/default-module.test.mjs +0 -46
  119. package/tests/integration/generator-naming.test.mjs +0 -81
  120. package/tests/integration/generator-unit-test-seams.test.mjs +0 -91
  121. package/tests/integration/legacy-method-compat.test.mjs +0 -222
  122. package/tests/integration/remove-module.test.mjs +0 -58
  123. package/tests/unit/naming.test.mjs +0 -94
  124. package/tests/unit/smoke-isolation.test.mjs +0 -35
@@ -4,49 +4,46 @@ import (
4
4
  "context"
5
5
  "encoding/json"
6
6
 
7
- "github.com/hibiken/asynq"
7
+ "{{goModule}}/internal/platform/queue"
8
8
  )
9
9
 
10
- const TypeSendEmail = "email:send"
11
-
12
- type sendEmailPayload struct {
13
- To string
14
- Subject string
15
- Body string
10
+ // SendEmail is the job cmd/api enqueues and cmd/worker runs. It is a plain
11
+ // JSON struct on purpose — no queue backend's types appear here, so moving
12
+ // between backends never touches this file.
13
+ type SendEmail struct {
14
+ To string `json:"to"`
15
+ Subject string `json:"subject"`
16
+ Body string `json:"body"`
16
17
  }
17
18
 
18
- func NewSendEmailTask(p sendEmailPayload) *asynq.Task {
19
- payload, _ := json.Marshal(p)
20
- return asynq.NewTask(TypeSendEmail, payload)
21
- }
19
+ // KindSendEmail is registered by cmd/worker and used when enqueueing.
20
+ const KindSendEmail = "email:send"
22
21
 
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 {
22
+ func (SendEmail) Kind() string { return KindSendEmail }
23
+
24
+ // Handle returns the worker-side handler for SendEmail.
25
+ func Handle(c *Client) queue.Handler {
26
+ return func(ctx context.Context, raw []byte) error {
27
+ var p SendEmail
28
+ if err := json.Unmarshal(raw, &p); err != nil {
29
+ // Malformed payload will never parse — returning it lets the
30
+ // backend park the job for inspection instead of retrying it
31
+ // into a wall.
27
32
  return err
28
33
  }
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)
34
+ return c.Send(p.To, p.Subject, p.Body)
36
35
  }
37
36
  }
38
37
 
39
- func NewAsyncClient(q interface {
40
- Enqueue(task *asynq.Task, opts ...asynq.Option) (string, error)
41
- }) *AsyncClient {
42
- return &AsyncClient{q: q}
43
- }
38
+ // AsyncClient enqueues mail instead of sending it inline, so an HTTP handler
39
+ // returns in microseconds rather than blocking on SMTP.
40
+ type AsyncClient struct{ q queue.Enqueuer }
41
+
42
+ func NewAsyncClient(q queue.Enqueuer) *AsyncClient { return &AsyncClient{q: q} }
44
43
 
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
44
+ // Send takes a ctx so the enqueue can join the caller's transaction: with a
45
+ // Postgres-backed queue the mail is only ever sent if the write that asked
46
+ // for it committed.
47
+ func (c *AsyncClient) Send(ctx context.Context, to, subject, body string) error {
48
+ return c.q.Enqueue(ctx, SendEmail{To: to, Subject: subject, Body: body}, &queue.Options{MaxRetry: 5})
52
49
  }
@@ -0,0 +1,140 @@
1
+ package queue
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "log/slog"
7
+
8
+ "github.com/hibiken/asynq"
9
+ )
10
+
11
+ // Asynq keeps jobs in Redis.
12
+ //
13
+ // ⚠️ It cannot honour the Enqueue contract in queue.go. Redis knows nothing
14
+ // about your Postgres transaction, so a job enqueued inside tx.Do is still
15
+ // delivered when that transaction rolls back — the classic "welcome email
16
+ // sent to a user whose signup failed". The standard fix is an outbox: write
17
+ // the job as a row in the same transaction, then relay those rows to Redis
18
+ // from a separate loop. Add that before using this adapter for work that
19
+ // must not fire against data that never existed.
20
+ //
21
+ // Pick this backend when throughput genuinely needs Redis (tens of thousands
22
+ // of jobs per second), when several languages share one queue, or when the
23
+ // primary database is already near capacity.
24
+ type Asynq struct {
25
+ client *asynq.Client
26
+ server *asynq.Server
27
+ mux *asynq.ServeMux
28
+ m *mux
29
+ }
30
+
31
+ // NewAsynqEnqueuer builds an insert-only client, for cmd/api.
32
+ func NewAsynqEnqueuer(redisURL string) (*Asynq, error) {
33
+ opt, err := asynq.ParseRedisURI(redisURL)
34
+ if err != nil {
35
+ return nil, err
36
+ }
37
+ return &Asynq{client: asynq.NewClient(opt), m: newMux()}, nil
38
+ }
39
+
40
+ // NewAsynqWorker builds a client that also works jobs, for cmd/worker.
41
+ // Register every handler with Handle before calling Start.
42
+ func NewAsynqWorker(redisURL string, concurrency int) (*Asynq, error) {
43
+ opt, err := asynq.ParseRedisURI(redisURL)
44
+ if err != nil {
45
+ return nil, err
46
+ }
47
+ srv := asynq.NewServer(opt, asynq.Config{
48
+ Concurrency: concurrency,
49
+ Logger: &slogAdapter{inner: slog.Default()},
50
+ })
51
+ m := asynq.NewServeMux()
52
+ // One place to log every failed job, whatever it was.
53
+ m.Use(func(next asynq.Handler) asynq.Handler {
54
+ return asynq.HandlerFunc(func(ctx context.Context, task *asynq.Task) error {
55
+ err := next.ProcessTask(ctx, task)
56
+ if err != nil {
57
+ slog.Error("job failed", "kind", task.Type(), "error", err)
58
+ }
59
+ return err
60
+ })
61
+ })
62
+ return &Asynq{server: srv, mux: m, m: newMux()}, nil
63
+ }
64
+
65
+ // Handle registers a handler. Asynq dispatches on its own task type string,
66
+ // which maps one-to-one onto our kind — no envelope needed on this backend.
67
+ func (a *Asynq) Handle(kind string, h Handler) {
68
+ a.m.Handle(kind, h)
69
+ // Dispatch through our own table rather than closing over h, so both
70
+ // backends resolve a kind the same way — including what happens when a
71
+ // job arrives for a kind this binary doesn't know.
72
+ a.mux.Handle(kind, asynq.HandlerFunc(func(ctx context.Context, task *asynq.Task) error {
73
+ return a.m.dispatch(ctx, task.Type(), task.Payload())
74
+ }))
75
+ }
76
+
77
+ func (a *Asynq) Enqueue(ctx context.Context, j Job, opts *Options) error {
78
+ raw, err := encode(j)
79
+ if err != nil {
80
+ return err
81
+ }
82
+ var asynqOpts []asynq.Option
83
+ if opts != nil {
84
+ if opts.MaxRetry > 0 {
85
+ asynqOpts = append(asynqOpts, asynq.MaxRetry(opts.MaxRetry))
86
+ }
87
+ if !opts.RunAt.IsZero() {
88
+ asynqOpts = append(asynqOpts, asynq.ProcessAt(opts.RunAt))
89
+ }
90
+ }
91
+ _, err = a.client.EnqueueContext(ctx, asynq.NewTask(j.Kind(), raw), asynqOpts...)
92
+ return err
93
+ }
94
+
95
+ func (a *Asynq) Start(ctx context.Context) error {
96
+ if a.server == nil {
97
+ return fmt.Errorf("queue: this Asynq client was built for enqueueing only")
98
+ }
99
+ return a.server.Start(a.mux)
100
+ }
101
+
102
+ // Stop drains in-flight jobs. Asynq's Shutdown blocks with no timeout of its
103
+ // own, so race it against ctx rather than letting a stuck job hang the
104
+ // process forever.
105
+ func (a *Asynq) Stop(ctx context.Context) error {
106
+ if a.server == nil {
107
+ return nil
108
+ }
109
+ done := make(chan struct{})
110
+ go func() {
111
+ a.server.Shutdown()
112
+ close(done)
113
+ }()
114
+ select {
115
+ case <-done:
116
+ return nil
117
+ case <-ctx.Done():
118
+ return ctx.Err()
119
+ }
120
+ }
121
+
122
+ func (a *Asynq) Close() error {
123
+ if a.client == nil {
124
+ return nil
125
+ }
126
+ return a.client.Close()
127
+ }
128
+
129
+ // slogAdapter lets asynq log through slog. Asynq calls these like the stdlib
130
+ // `log` package (a plain message, sometimes several args meant to be
131
+ // concatenated), so fmt.Sprint joins them into one message rather than
132
+ // passing them as slog key-value pairs, which would read a lone message
133
+ // argument as a key with no value.
134
+ type slogAdapter struct{ inner *slog.Logger }
135
+
136
+ func (a *slogAdapter) Debug(args ...interface{}) { a.inner.Debug(fmt.Sprint(args...)) }
137
+ func (a *slogAdapter) Info(args ...interface{}) { a.inner.Info(fmt.Sprint(args...)) }
138
+ func (a *slogAdapter) Warn(args ...interface{}) { a.inner.Warn(fmt.Sprint(args...)) }
139
+ func (a *slogAdapter) Error(args ...interface{}) { a.inner.Error(fmt.Sprint(args...)) }
140
+ func (a *slogAdapter) Fatal(args ...interface{}) { a.inner.Error(fmt.Sprint(args...)) }
@@ -0,0 +1,87 @@
1
+ // Package queue is this project's own vocabulary for background work.
2
+ //
3
+ // No backend package (River, Asynq, ...) may appear in this file or in any
4
+ // caller — swapping backends has to mean rewriting one adapter file next to
5
+ // this one, not every module that enqueues something.
6
+ package queue
7
+
8
+ import (
9
+ "context"
10
+ "encoding/json"
11
+ "fmt"
12
+ "sync"
13
+ "time"
14
+ )
15
+
16
+ // Job is one unit of background work. Each module defines its own job types;
17
+ // the value is carried as JSON, so keep the fields plain and tagged.
18
+ type Job interface {
19
+ // Kind is the stable identifier a handler registers against. Changing it
20
+ // orphans jobs already sitting in the queue.
21
+ Kind() string
22
+ }
23
+
24
+ // Options tunes a single enqueue. Each adapter translates these into its own
25
+ // backend's equivalent — never rely on one for correctness, only for tuning.
26
+ type Options struct {
27
+ // MaxRetry is how many times a failed job is retried before it is parked
28
+ // for inspection. Zero means "use the backend default".
29
+ MaxRetry int
30
+ // RunAt delays the job until this time. Zero means "as soon as possible".
31
+ RunAt time.Time
32
+ }
33
+
34
+ // Handler processes one job. raw is the JSON-encoded Job.
35
+ type Handler func(ctx context.Context, raw []byte) error
36
+
37
+ // Enqueuer is what application code depends on — nothing else.
38
+ //
39
+ // Contract: a job is delivered if, and only if, the database transaction
40
+ // running on ctx commits. The Postgres-backed adapter gets this for free
41
+ // (the job row joins that same transaction); a Redis-backed adapter cannot
42
+ // provide it without an outbox, and says so in its own doc comment.
43
+ type Enqueuer interface {
44
+ Enqueue(ctx context.Context, j Job, opts *Options) error
45
+ }
46
+
47
+ // Registry is what cmd/worker registers handlers on.
48
+ type Registry interface {
49
+ Handle(kind string, h Handler)
50
+ }
51
+
52
+ // mux is the kind -> handler table every adapter dispatches through, so a
53
+ // handler written against queue.Handler runs unchanged on any backend.
54
+ type mux struct {
55
+ mu sync.RWMutex
56
+ handlers map[string]Handler
57
+ }
58
+
59
+ func newMux() *mux { return &mux{handlers: make(map[string]Handler)} }
60
+
61
+ // Handle panics on a duplicate kind: two handlers for one kind means one of
62
+ // them silently never runs, which is far worse to debug than a boot failure.
63
+ func (m *mux) Handle(kind string, h Handler) {
64
+ m.mu.Lock()
65
+ defer m.mu.Unlock()
66
+ if _, dup := m.handlers[kind]; dup {
67
+ panic("queue: duplicate handler registered for kind " + kind)
68
+ }
69
+ m.handlers[kind] = h
70
+ }
71
+
72
+ func (m *mux) dispatch(ctx context.Context, kind string, raw []byte) error {
73
+ m.mu.RLock()
74
+ h, ok := m.handlers[kind]
75
+ m.mu.RUnlock()
76
+ if !ok {
77
+ // Retrying cannot help until a binary that knows this kind is
78
+ // deployed — the error is returned so the job is retried/parked
79
+ // rather than dropped, which keeps the payload recoverable.
80
+ return fmt.Errorf("queue: no handler registered for kind %q", kind)
81
+ }
82
+ return h(ctx, raw)
83
+ }
84
+
85
+ // encode is shared by every adapter so a payload written by one backend is
86
+ // readable by another during a switch-over, when both run side by side.
87
+ func encode(j Job) ([]byte, error) { return json.Marshal(j) }
@@ -0,0 +1,148 @@
1
+ package queue
2
+
3
+ import (
4
+ "context"
5
+ "database/sql"
6
+ "encoding/json"
7
+ "log/slog"
8
+ "regexp"
9
+
10
+ "{{goModule}}/internal/shared/tx"
11
+
12
+ "github.com/riverqueue/river"
13
+ "github.com/riverqueue/river/riverdriver/riverdatabasesql"
14
+ "gorm.io/gorm"
15
+ )
16
+
17
+ // envelope carries any queue.Job through River as a single River job kind.
18
+ // River binds a worker to one concrete args type, so one envelope plus our
19
+ // own kind dispatch keeps handlers byte-identical across backends.
20
+ //
21
+ // ponytail: the real kind travels as a River tag rather than a River kind —
22
+ // enough to filter on in River's UI. Promote a job to its own River type the
23
+ // day it needs per-kind queue or retry configuration.
24
+ type envelope struct {
25
+ K string `json:"kind"`
26
+ Raw json.RawMessage `json:"payload"`
27
+ }
28
+
29
+ func (envelope) Kind() string { return "queue.envelope" }
30
+
31
+ type envelopeWorker struct {
32
+ river.WorkerDefaults[envelope]
33
+ m *mux
34
+ }
35
+
36
+ func (w *envelopeWorker) Work(ctx context.Context, job *river.Job[envelope]) error {
37
+ return w.m.dispatch(ctx, job.Args.K, job.Args.Raw)
38
+ }
39
+
40
+ // River keeps jobs as rows in the same Postgres database as your data. That
41
+ // is the entire point: Enqueue joins the caller's transaction, so a job is
42
+ // delivered if and only if the write that scheduled it commits.
43
+ //
44
+ // It runs on River's database/sql driver so it can share GORM's existing
45
+ // connection pool. ponytail: that driver cannot use Postgres LISTEN/NOTIFY,
46
+ // so cmd/worker polls (sub-second pickup instead of instant). If a job ever
47
+ // needs instant pickup, give cmd/worker its own pgxpool + riverpgxv5 client
48
+ // and keep this one for inserting.
49
+ type River struct {
50
+ client *river.Client[*sql.Tx]
51
+ db *gorm.DB
52
+ m *mux
53
+ }
54
+
55
+ // NewRiverEnqueuer builds an insert-only client, for cmd/api.
56
+ func NewRiverEnqueuer(db *gorm.DB) (*River, error) {
57
+ sqlDB, err := db.DB()
58
+ if err != nil {
59
+ return nil, err
60
+ }
61
+ client, err := river.NewClient(riverdatabasesql.New(sqlDB), &river.Config{
62
+ Logger: slog.Default(),
63
+ })
64
+ if err != nil {
65
+ return nil, err
66
+ }
67
+ return &River{client: client, db: db, m: newMux()}, nil
68
+ }
69
+
70
+ // NewRiverWorker builds a client that also works jobs, for cmd/worker.
71
+ // Register every handler with Handle before calling Start.
72
+ func NewRiverWorker(db *gorm.DB, concurrency int) (*River, error) {
73
+ m := newMux()
74
+ workers := river.NewWorkers()
75
+ river.AddWorker(workers, &envelopeWorker{m: m})
76
+
77
+ sqlDB, err := db.DB()
78
+ if err != nil {
79
+ return nil, err
80
+ }
81
+ client, err := river.NewClient(riverdatabasesql.New(sqlDB), &river.Config{
82
+ Logger: slog.Default(),
83
+ Queues: map[string]river.QueueConfig{river.QueueDefault: {MaxWorkers: concurrency}},
84
+ Workers: workers,
85
+ })
86
+ if err != nil {
87
+ return nil, err
88
+ }
89
+ return &River{client: client, db: db, m: m}, nil
90
+ }
91
+
92
+ func (r *River) Handle(kind string, h Handler) { r.m.Handle(kind, h) }
93
+
94
+ // River validates a tag as \A[\w][\w\-]+[\w]\z and rejects the whole insert
95
+ // if one doesn't match. Kinds like "email:send" don't, so map them to the
96
+ // nearest legal form and drop the tag entirely if even that fails — a job
97
+ // must never be lost over the spelling of a label in the admin UI.
98
+ var (
99
+ tagUnsafe = regexp.MustCompile(`[^\w-]`)
100
+ tagLegal = regexp.MustCompile(`\A[\w][\w\-]+[\w]\z`)
101
+ )
102
+
103
+ func kindTag(kind string) []string {
104
+ tag := tagUnsafe.ReplaceAllString(kind, "-")
105
+ if !tagLegal.MatchString(tag) {
106
+ return nil
107
+ }
108
+ return []string{tag}
109
+ }
110
+
111
+ func (r *River) Enqueue(ctx context.Context, j Job, opts *Options) error {
112
+ raw, err := encode(j)
113
+ if err != nil {
114
+ return err
115
+ }
116
+ env := envelope{K: j.Kind(), Raw: raw}
117
+
118
+ insertOpts := &river.InsertOpts{Tags: kindTag(j.Kind())}
119
+ if opts != nil {
120
+ if opts.MaxRetry > 0 {
121
+ insertOpts.MaxAttempts = opts.MaxRetry + 1 // River counts the first run as an attempt
122
+ }
123
+ if !opts.RunAt.IsZero() {
124
+ insertOpts.ScheduledAt = opts.RunAt
125
+ }
126
+ }
127
+
128
+ // Join the caller's transaction when there is one. This is the contract
129
+ // in queue.go, and the only reason the queue lives in Postgres at all.
130
+ if gdb := tx.From(ctx, r.db); gdb.Statement != nil {
131
+ if sqlTx, ok := gdb.Statement.ConnPool.(*sql.Tx); ok {
132
+ _, err = r.client.InsertTx(ctx, sqlTx, env, insertOpts)
133
+ return err
134
+ }
135
+ }
136
+ _, err = r.client.Insert(ctx, env, insertOpts)
137
+ return err
138
+ }
139
+
140
+ // Start begins working jobs. Only meaningful on a client from NewRiverWorker.
141
+ func (r *River) Start(ctx context.Context) error { return r.client.Start(ctx) }
142
+
143
+ // Stop drains in-flight jobs, giving up when ctx expires.
144
+ func (r *River) Stop(ctx context.Context) error { return r.client.Stop(ctx) }
145
+
146
+ // Close exists so cmd/api can shut the enqueuer down the same way whatever
147
+ // backend it uses — River holds nothing of its own beyond GORM's pool.
148
+ func (r *River) Close() error { return nil }
@@ -22,7 +22,7 @@ not hand-write a new one. Hand-writing a new `internal/app/<name>/` package,
22
22
  or adding a method by editing the handler/service directly, produces a shape
23
23
  that doesn't match the rest of the codebase (missing route registration,
24
24
  missing AutoMigrate wiring, a `repository` interface out of sync with its
25
- `fakeRepo` test mock, inconsistent error-catalog naming, etc.).
25
+ `repositoryStub` test mock, inconsistent error-catalog naming, etc.).
26
26
 
27
27
  ## When to use this skill
28
28
 
@@ -46,22 +46,45 @@ missing AutoMigrate wiring, a `repository` interface out of sync with its
46
46
  Run from the project root:
47
47
 
48
48
  ```bash
49
- go-scaffold generate module <name>
49
+ go-scaffold generate module <name> [--full] [--auth] [--permission <code>]
50
50
  go-scaffold generate method <module> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]
51
+ go-scaffold generate migration <name>
52
+ go-scaffold undo module <name> [-y]
51
53
  ```
52
54
 
53
- `<name>` for a module should be a singular, lowercase, one-word domain noun
54
- (e.g. `order`, not `Orders` or `order-item`) the CLI derives the Go
55
- package name, the pluralized REST route (`/orders`), and the table name
56
- from it.
55
+ `<name>` for a module is a domain noun in whatever form reads naturally —
56
+ singular or plural, any case, hyphens or underscores are all accepted and
57
+ normalized (`products` `product`, `Orders` `order`, `order-item`
58
+ `orderitem`). From it the CLI derives the Go package name, the pluralized
59
+ REST route (`/orders`, `/order-items`), the table, and the error-code prefix
60
+ (`ORDER_ITEM_NOT_FOUND`). The only names it refuses are the ones that
61
+ wouldn't compile: starting with a digit, or a Go keyword / predeclared type
62
+ (`type`, `string`, `error`, ...).
57
63
 
58
64
  ### `generate module <name>`
59
65
 
60
- What you get: `internal/app/<name>/{model/,dto,errors,repository,service,handler,service_test,handler_test}.go`,
61
- a route registered under{{#if apiPrefix}} `/{{apiPrefix}}`{{else}} no prefix{{/if}}
62
- in `cmd/api/main.go`, the model added to the `AutoMigrate` call, and a new
63
- file in `migrations/`. There's no per-domain versioning — the route prefix
64
- is a single project-wide choice made at `create` time.
66
+ **Minimal is the default** — the module is created with no endpoints, and
67
+ what lands in `cmd/api/wiring.go` is an *empty route group* under{{#if apiPrefix}} `/{{apiPrefix}}`{{else}} no prefix{{/if}}
68
+ waiting for `generate method`. You still get every file:
69
+ `internal/app/<pkg>/{model/,dto,errors,repository,service,handler,service_test,handler_test,repository_test}.go`
70
+ the full data-access surface exists (the repository and its interface are
71
+ complete) so `generate method` has something to call. Plus the model added
72
+ to the `AutoMigrate` call and a new `migrations/<version>_create_<plural>.{up,down}.sql`.
73
+
74
+ `--full` swaps in a CRUD skeleton instead: list/get/create/update/delete
75
+ already routed and wired, with DTO fields and business rules left as TODOs.
76
+ With OpenAPI enabled it also writes `docs/<plural>/{collection,item,schemas}.yaml`
77
+ and wires them into `docs/openapi.yaml`. Use it only when a full CRUD surface
78
+ is actually intended — otherwise prefer minimal plus explicit `generate method`
79
+ calls.
80
+
81
+ `--auth` puts the module's routes behind a valid access token (requires
82
+ `go-scaffold add auth` in this project first). `--permission <code>` also
83
+ requires that permission via `authz.Require` and seeds it in its own
84
+ migration — it needs `add rbac`, and `--auth` must be passed alongside it.
85
+
86
+ There's no per-domain versioning — the route prefix is a single
87
+ project-wide choice made at `create` time.
65
88
 
66
89
  ### `generate method <module> <name>`
67
90
 
@@ -72,11 +95,35 @@ and shape depend on `--type`:
72
95
  | `--type` | Route | Notes |
73
96
  |---|---|---|
74
97
  | `get --get-mode all` | `GET /<plural>/<kebab-name>` | list-style, reuses `FindAll` — TODO to add real filtering |
75
- | `get --get-mode one --field <f>` | `GET /<plural>/<f>/:<f>` | adds a real `FindBy<F>` query to the repository (and its `repository` interface + `fakeRepo` test stub); `--field` can't be `id` |
98
+ | `get --get-mode one --field <f>` | `GET /<plural>/<f>/:<f>` | adds a real `FindBy<F>` query to the repository (and its `repository` interface + `repositoryStub` test stub); `--field` can't be `id` |
76
99
  | `post` | `POST /<plural>/<kebab-name>` | adds a body DTO; service body is a TODO stub (`apperror.NewInternal()` until implemented) |
77
100
  | `put` / `patch` | `<VERB> /<plural>/:id/<kebab-name>` | finds the record by id, TODO before saving (safe no-op until implemented) |
78
101
  | `delete` | `DELETE /<plural>/:id/<kebab-name>` | TODO stub (`apperror.NewInternal()` until implemented) |
79
102
 
103
+ ### `generate migration <name>`
104
+
105
+ Reserves a timestamped `migrations/<version>_<name>.{up,down}.sql` pair,
106
+ both TODO stubs — the CLI doesn't guess at columns, you write the SQL. This
107
+ is the way to make any schema change that isn't a new module: adding a
108
+ column, an index, a foreign key, a backfill, a drop.
109
+
110
+ ### `undo module <name>`
111
+
112
+ For a `generate module` that shouldn't have happened — a typo'd name, a
113
+ domain decided against. Deletes `internal/app/<pkg>/`, the module's
114
+ `migrations/<version>_create_<plural>.{up,down}.sql` pair, and reverses
115
+ everything `generate module` wired up in `cmd/api/wiring.go` (and in
116
+ `docs/openapi.yaml` + `docs/<plural>/` when OpenAPI is enabled). `-y` skips
117
+ the confirmation prompt.
118
+
119
+ The migration files go because `migrations/embed.go` is a `//go:embed *`: a
120
+ typo's migration left behind runs on every database created from then on.
121
+ That's only safe while those files exist nowhere else, so `undo` refuses —
122
+ deleting nothing — when they're committed to git, or when the database is
123
+ already at or past that version. Neither is a bug to work around: a domain
124
+ that has shipped is retired with `generate migration drop_<table>` and a
125
+ reviewed data removal, not with this command. The table is never dropped.
126
+
80
127
  What you still do by hand: real field names on the model/DTOs (the generated
81
128
  ones are placeholders), any foreign key to another domain (see
82
129
  `docs/architect/patterns.md` for the 3 rules), and the actual business logic
@@ -0,0 +1,13 @@
1
+ # Everything the build stage doesn't need. Keeping the context small is most of
2
+ # what makes `docker build` fast on a repeat run.
3
+ .git
4
+ .github
5
+ bin
6
+ docs
7
+ *.md
8
+ .env
9
+ .env.*
10
+ !.env.example
11
+ docker-compose.yml
12
+ Dockerfile
13
+ .dockerignore
@@ -11,17 +11,26 @@ DB_MAX_OPEN_CONNS=10
11
11
  DB_MAX_IDLE_CONNS=10
12
12
  DB_CONN_MAX_LIFETIME_MIN=5
13
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
14
+ # read only by `go test`, never by cmd/api — a separate database on purpose:
15
+ # repository tests run inside a transaction that's always rolled back (see
16
+ # repositoryDBForTest in any repository_test.go), so nothing here ever
17
+ # persists — but keeping it separate from DB_DSN above is cheap insurance
18
+ # against a migration state mismatch or a future test that forgets to roll
19
+ # back. Create it once: make db-create DB_NAME={{dbName}}_test
20
+ # then apply the same migrations DB_DSN above got: make migrate-up-test
18
21
  TEST_DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable
19
22
 
23
+ # with TEST_DB_DSN unset, repository tests skip themselves — convenient locally,
24
+ # a false green in CI. REQUIRE_TEST_DB=true turns that skip into a failure, so a
25
+ # missing or unmigrated test database is reported instead of quietly halving the
26
+ # suite. CI sets it; leave it off locally unless you want the same strictness.
27
+ # REQUIRE_TEST_DB=true
28
+
20
29
  # comma-separated frontend origins allowed to call this API with credentials (cookies)
21
30
  CORS_ALLOWED_ORIGINS=http://localhost:3000
22
- {{#if observability}}
23
31
 
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}}
32
+ # comma-separated CIDRs of the proxies in front of this app (load balancer,
33
+ # ingress, CDN). Empty = trust nobody, so c.ClientIP() is the peer address.
34
+ # Leave it empty unless you actually run behind a proxy: anything listed here
35
+ # is allowed to set X-Forwarded-For, and the auth rate limiter keys on that.
36
+ TRUSTED_PROXIES=
@@ -0,0 +1,20 @@
1
+ version: 2
2
+ updates:
3
+ # Go dependencies drift quietly: nothing fails, the project just ages until
4
+ # an upgrade becomes a project of its own. Weekly PRs keep each bump small
5
+ # enough to read, and CI already builds, vets, lints and tests every one.
6
+ - package-ecosystem: gomod
7
+ directory: /
8
+ schedule:
9
+ interval: weekly
10
+ open-pull-requests-limit: 5
11
+ groups:
12
+ # one PR for the routine patch/minor churn; majors stay separate so
13
+ # they get read properly
14
+ go-minor-and-patch:
15
+ update-types: [minor, patch]
16
+
17
+ - package-ecosystem: github-actions
18
+ directory: /
19
+ schedule:
20
+ interval: weekly
@@ -41,10 +41,20 @@ jobs:
41
41
 
42
42
  - uses: golangci/golangci-lint-action@v6
43
43
  with:
44
- version: latest
44
+ # keep in step with GOLANGCI_LINT_VERSION in the Makefile, so a rule
45
+ # that passes locally passes here too
46
+ version: v2.12.2
45
47
 
48
+ {{#if openapiDocs}}
49
+ # catches a broken $ref or malformed spec before it reaches anyone
50
+ # pointing a client generator or Scalar/Swagger UI at this file — those
51
+ # tools often fail with a much less specific error than this gives.
52
+ - name: Lint OpenAPI spec
53
+ run: npx --yes @redocly/cli lint docs/openapi.yaml
54
+
55
+ {{/if}}
46
56
  - name: Install migration runner
47
- run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.3
57
+ run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.19.1
48
58
 
49
59
  - name: Apply production migrations to the test database
50
60
  env: