@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.
- package/LICENSE +21 -0
- package/README.md +133 -44
- package/dist/commands/auth.js +116 -11
- package/dist/commands/create.js +13 -1
- package/dist/commands/generate.js +21 -11
- package/dist/commands/method.js +32 -3
- package/dist/commands/observability.js +114 -0
- package/dist/commands/rbac.js +19 -2
- package/dist/commands/undo.js +331 -0
- package/dist/commands/worker.js +92 -32
- package/dist/index.js +366 -63
- package/dist/prompts/auth-wizard.js +29 -0
- package/dist/prompts/create-wizard.js +5 -2
- package/dist/prompts/generate-wizard.js +57 -0
- package/dist/prompts/worker-wizard.js +25 -0
- package/dist/templates/auth-manifest.js +20 -3
- package/dist/templates/create-manifest.js +13 -20
- package/dist/templates/observability-manifest.js +24 -0
- package/dist/templates/rbac-manifest.js +1 -0
- package/dist/templates/worker-manifest.js +23 -6
- package/dist/utils/auth-patcher.js +96 -21
- package/dist/utils/config.js +58 -10
- package/dist/utils/gocheck.js +57 -5
- package/dist/utils/golangci-patcher.js +73 -0
- package/dist/utils/gomod-patcher.js +53 -0
- package/dist/utils/main-patcher.js +58 -4
- package/dist/utils/marker-patch.js +125 -3
- package/dist/utils/method-patcher.js +17 -2
- package/dist/utils/module-location.js +37 -1
- package/dist/utils/naming.js +50 -2
- package/dist/utils/observability-patcher.js +107 -0
- package/dist/utils/platform-patcher.js +98 -12
- package/dist/utils/rbac-patcher.js +60 -10
- package/package.json +3 -5
- package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
- package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
- package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
- package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
- package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
- package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
- package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
- package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
- package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
- package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
- package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
- package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
- package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
- package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
- package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
- package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
- package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
- package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
- package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
- package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
- package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
- package/templates/create/base/.dockerignore.hbs +13 -0
- package/templates/create/base/.env.example.hbs +18 -9
- package/templates/create/base/.github/dependabot.yml.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +12 -2
- package/templates/create/base/.golangci.yml.hbs +27 -0
- package/templates/create/base/AGENTS.md.hbs +8 -4
- package/templates/create/base/Dockerfile.hbs +42 -0
- package/templates/create/base/Makefile.hbs +43 -13
- package/templates/create/base/README.md.hbs +45 -8
- package/templates/create/base/cmd/api/main.go.hbs +17 -130
- package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
- package/templates/create/base/go.mod.hbs +4 -4
- package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
- package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
- package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
- package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
- package/templates/create/base/redocly.yaml.hbs +21 -0
- package/templates/create/features/docs/architecture.md.hbs +32 -11
- package/templates/create/features/docs/openapi.yaml.hbs +0 -4
- package/templates/create/features/docs/patterns.md.hbs +82 -8
- package/templates/create/features/docs/techstack.md.hbs +8 -3
- package/templates/generate/module/dto.go.hbs +8 -1
- package/templates/generate/module/errors.go.hbs +5 -0
- package/templates/generate/module/field-column.down.sql.hbs +2 -0
- package/templates/generate/module/field-column.up.sql.hbs +15 -0
- package/templates/generate/module/handler_test.go.hbs +8 -1
- package/templates/generate/module/migration.down.sql.hbs +3 -1
- package/templates/generate/module/migration.up.sql.hbs +7 -2
- package/templates/generate/module/minimal/dto.go.hbs +3 -1
- package/templates/generate/module/model/model.go.hbs +10 -1
- package/templates/generate/module/permission.up.sql.hbs +3 -1
- package/templates/generate/module/repository.go.hbs +60 -6
- package/templates/generate/module/repository_test.go.hbs +30 -0
- package/templates/generate/module/service.go.hbs +10 -1
- package/templates/generate/module/service_test.go.hbs +45 -0
- package/dist/commands/remove.js +0 -88
- package/scripts/smoke-test.mjs +0 -2058
- package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
- package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
- package/tests/integration/default-module.test.mjs +0 -46
- package/tests/integration/generator-naming.test.mjs +0 -81
- package/tests/integration/generator-unit-test-seams.test.mjs +0 -91
- package/tests/integration/legacy-method-compat.test.mjs +0 -222
- package/tests/integration/remove-module.test.mjs +0 -58
- package/tests/unit/naming.test.mjs +0 -94
- package/tests/unit/smoke-isolation.test.mjs +0 -35
|
@@ -4,49 +4,46 @@ import (
|
|
|
4
4
|
"context"
|
|
5
5
|
"encoding/json"
|
|
6
6
|
|
|
7
|
-
"
|
|
7
|
+
"{{goModule}}/internal/platform/queue"
|
|
8
8
|
)
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
46
|
-
//
|
|
47
|
-
// it
|
|
48
|
-
func (c *AsyncClient) Send(to, subject, body string) error {
|
|
49
|
-
|
|
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
|
-
`
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 + `
|
|
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
|
|
@@ -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:
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
-
#
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|