@nakedev/go-scaffold 0.5.4 → 0.8.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 (50) hide show
  1. package/README.md +16 -2
  2. package/dist/commands/auth.js +6 -1
  3. package/dist/commands/check.js +5 -1
  4. package/dist/commands/method.js +27 -1
  5. package/dist/index.js +20 -6
  6. package/dist/prompts/auth-wizard.js +33 -1
  7. package/dist/templates/create-manifest.js +8 -0
  8. package/dist/templates/rbac-manifest.js +1 -0
  9. package/dist/utils/hexagonal-method-patcher.js +79 -20
  10. package/package.json +1 -1
  11. package/templates/add/auth/docs/login.yaml.hbs +9 -1
  12. package/templates/add/auth/internal/app/user/adapters/inbound/http/handler.go.hbs +12 -2
  13. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/model.go.hbs +1 -0
  14. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository.go.hbs +97 -13
  15. package/templates/add/auth/internal/app/user/adapters/outbound/postgres/repository_test.go.hbs +169 -5
  16. package/templates/add/auth/internal/app/user/application/local_auth.go.hbs +44 -2
  17. package/templates/add/auth/internal/app/user/application/recovery_service.go.hbs +2 -1
  18. package/templates/add/auth/internal/app/user/application/service.go.hbs +1 -1
  19. package/templates/add/auth/internal/app/user/application/service_test.go.hbs +71 -12
  20. package/templates/add/auth/internal/app/user/application/user_query.go.hbs +5 -4
  21. package/templates/add/auth/internal/app/user/domain/errors.go.hbs +4 -0
  22. package/templates/add/auth/internal/app/user/ports/repository.go.hbs +21 -3
  23. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  24. package/templates/add/rbac/docs/roles.yaml.hbs +2 -1
  25. package/templates/add/rbac/docs/users.yaml.hbs +2 -1
  26. package/templates/add/rbac/internal/app/role/adapters/inbound/http/dto.go.hbs +52 -0
  27. package/templates/add/rbac/internal/app/role/adapters/inbound/http/handler.go.hbs +18 -13
  28. package/templates/add/rbac/internal/app/role/adapters/outbound/postgres/repository.go.hbs +21 -4
  29. package/templates/add/rbac/internal/app/role/application/dto.go.hbs +19 -11
  30. package/templates/add/rbac/internal/app/role/application/service.go.hbs +6 -6
  31. package/templates/add/rbac/internal/app/role/application/service_test.go.hbs +19 -1
  32. package/templates/add/rbac/internal/app/role/ports/repository.go.hbs +13 -1
  33. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +1 -1
  34. package/templates/create/base/AGENTS.md.hbs +9 -1
  35. package/templates/create/base/README.md.hbs +2 -1
  36. package/templates/create/base/internal/shared/dbq/dbq.go.hbs +47 -0
  37. package/templates/create/base/internal/shared/dbq/dbq_test.go.hbs +66 -0
  38. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +19 -2
  39. package/templates/create/features/docs/architecture.md.hbs +9 -6
  40. package/templates/create/features/docs/common/parameters.yaml.hbs +5 -0
  41. package/templates/create/features/docs/common/schemas.yaml.hbs +1 -0
  42. package/templates/create/features/docs/patterns.md.hbs +60 -0
  43. package/templates/generate/module/docs/collection.yaml.hbs +2 -1
  44. package/templates/generate/module/hexagonal/adapters/inbound/http/handler.go.hbs +8 -3
  45. package/templates/generate/module/hexagonal/adapters/outbound/postgres/repository.go.hbs +27 -4
  46. package/templates/generate/module/hexagonal/application/cqrs_test.go.hbs +3 -3
  47. package/templates/generate/module/hexagonal/application/queries.crud.go.hbs +3 -3
  48. package/templates/generate/module/hexagonal/application/service.crud.go.hbs +3 -3
  49. package/templates/generate/module/hexagonal/application/service_test.go.hbs +4 -3
  50. package/templates/generate/module/hexagonal/ports/repository.go.hbs +18 -2
@@ -47,7 +47,7 @@ internal/
47
47
  ├── platform/ # talks to real external systems
48
48
  │ └── database/ # PostgreSQL connection and pool
49
49
  ├── shared/ # pure logic/framework glue, no I/O
50
- │ ├── config/ apperror/ dberr/ httpx/ id/
50
+ │ ├── config/ apperror/ dberr/ dbq/ httpx/ id/
51
51
  │ └── middleware/ pagination/ tx/
52
52
  └── app/ # domain packages (one per feature)
53
53
  └── <domain>/
@@ -129,12 +129,15 @@ call site, no error-shape drift between domains.
129
129
  - DB errors are classified once in `shared/dberr` (`IsDuplicate`,
130
130
  `IsForeignKey`) and mapped to the right HTTP status per domain
131
131
 
132
- ## 6. Pagination
132
+ ## 6. Pagination and Filtering
133
133
 
134
- **Decision:** Shared `limit`/`offset` parsing and response envelope
135
- (`shared/pagination`), used by every list endpoint.
136
- **Rationale:** One implementation, one response shape (`{data, limit,
137
- offset}`) no per-domain reinvention.
134
+ **Decision:** Shared `limit`/`offset`/`q` parsing and response envelope
135
+ (`shared/pagination`), used by every list endpoint, with the SQL a filter
136
+ repeats in `shared/dbq`. A list takes one filter struct from `ports/` and
137
+ answers a page plus its total.
138
+ **Rationale:** One implementation, one response shape (`{data, limit, offset,
139
+ total}`) — no per-domain reinvention, and no per-domain LIKE escaping to get
140
+ wrong. See `patterns.md`, "Filtered Lists".
138
141
 
139
142
  ## 7. Persistence
140
143
 
@@ -11,3 +11,8 @@ Offset:
11
11
  name: offset
12
12
  in: query
13
13
  schema: { type: integer, default: 0, minimum: 0 }
14
+ Search:
15
+ name: q
16
+ in: query
17
+ description: "free-text search; which columns it matches is documented per endpoint"
18
+ schema: { type: string, maxLength: 100 }
@@ -4,6 +4,7 @@ PageEnvelope:
4
4
  properties:
5
5
  limit: { type: integer }
6
6
  offset: { type: integer }
7
+ total: { type: integer, description: "rows the filter matched; present on endpoints that count them" }
7
8
 
8
9
  Error:
9
10
  type: object
@@ -250,6 +250,66 @@ those markers** — they're where the next `generate method` call inserts. The
250
250
  method body is always left as a compiling `TODO` rather than guessing at
251
251
  business logic — same spirit as `generate module`'s placeholder fields.
252
252
 
253
+ ## Filtered Lists — one filter struct, a page and its total
254
+
255
+ A `crud`-surface module is generated with this already wired; a module that
256
+ grows a list later copies the same four steps. Nothing about it is magic.
257
+
258
+ **1. Parse at the boundary.** `pagination.Parse(c)` returns `Limit`, `Offset`
259
+ and `Search` (`?q=`, trimmed and capped at 100 runes). Read the filters this
260
+ endpoint owns off the query string beside it. An unreadable filter value means
261
+ *no* filter, not `400` — a list narrows on a best effort.
262
+
263
+ **2. One filter struct, in `ports/`.** `ports.ListFilter` travels whole through
264
+ handler → application → repository, so adding a filter later is one field and
265
+ not a fourth positional argument on three signatures. It lives in `ports/`
266
+ because that is the only package all three may import — `ports` importing
267
+ `application` would be a cycle. Its zero value means "the first page of
268
+ everything".
269
+
270
+ ```go
271
+ type ListFilter struct {
272
+ Search string
273
+ Limit int
274
+ Offset int
275
+ // Status, OwnerID, DateFrom … whatever this module actually filters by.
276
+ }
277
+ ```
278
+
279
+ **3. The repository returns the page *and* the total**, because a client that
280
+ pages needs both and only SQL can answer either:
281
+
282
+ ```go
283
+ FindAll(context.Context, ListFilter) ([]domain.Thing, int64, error)
284
+ ```
285
+
286
+ Build the conditions in a closure and call it once per query — conditions
287
+ accumulate on a `*gorm.DB` value, so a count reusing the page's query object
288
+ silently inherits its `LIMIT`. Use `dbq.Search(q, term, cols...)` for a
289
+ contains-search over columns on the same table, and `dbq.LikePattern(term)`
290
+ when the search spans a subquery or a join. Both escape `%` and `_`, so
291
+ someone typing `50%` searches for `50%` instead of matching every row. Always
292
+ keep a tiebreaker in the sort (`ORDER BY created_at DESC, id`) or two rows
293
+ written in the same instant can swap between page 1 and page 2 — showing one
294
+ twice and hiding the other.
295
+
296
+ The generated `FindAll` calls `dbq.Search` with no columns, which is a no-op:
297
+ `?q=` is accepted and ignored until you name the columns this list is searched
298
+ by. That TODO is the one line standing between the scaffold and a working
299
+ search.
300
+
301
+ **4. Respond with the shared envelope.** `p.ResponseWithTotal(out, total)` —
302
+ `{ data, limit, offset, total }`, the same shape for every resource. Anything
303
+ extra is a named key added to that map and documented in the endpoint's
304
+ OpenAPI file. Reuse `common/parameters.yaml#/Search`, `#/Limit` and `#/Offset`
305
+ for the query parameters.
306
+
307
+ Counts that answer a *different* question from the page — "how many are open
308
+ and how many are closed, whichever tab is showing" — belong beside the
309
+ envelope, not in it, and are computed from the filter minus the field the tabs
310
+ switch. A tab that recounted itself when clicked would always read the same
311
+ number.
312
+
253
313
  ## Testing Conventions
254
314
 
255
315
  - Unit and integration tests live in the same directory as the code under
@@ -21,9 +21,10 @@ get:
21
21
  parameters:
22
22
  - $ref: '../common/parameters.yaml#/Limit'
23
23
  - $ref: '../common/parameters.yaml#/Offset'
24
+ - $ref: '../common/parameters.yaml#/Search'
24
25
  responses:
25
26
  "200":
26
- description: paginated list
27
+ description: "paginated list; narrow it with ?q= and whatever filters this module adds"
27
28
  content:
28
29
  application/json:
29
30
  schema:
@@ -7,6 +7,7 @@ import (
7
7
 
8
8
  "{{goModule}}/internal/app/{{modulePath}}/application"
9
9
  "{{goModule}}/internal/app/{{modulePath}}/domain"
10
+ "{{goModule}}/internal/app/{{modulePath}}/ports"
10
11
  "{{goModule}}/internal/shared/apperror"
11
12
  "{{goModule}}/internal/shared/httpx"
12
13
  {{#if auth}} "{{goModule}}/internal/shared/middleware"
@@ -107,10 +108,14 @@ func (h *Handler) create(c *gin.Context) {
107
108
 
108
109
  func (h *Handler) list(c *gin.Context) {
109
110
  p := pagination.Parse(c)
111
+ // One struct all the way down, so this module's own filters are read off
112
+ // the query string here and added as fields on ports.ListFilter — never as
113
+ // extra arguments on the three signatures below.
114
+ filter := ports.ListFilter{Search: p.Search, Limit: p.Limit, Offset: p.Offset}
110
115
  {{#if cqrs}}
111
- items, err := h.queries.List(c.Request.Context(), p.Limit, p.Offset)
116
+ items, total, err := h.queries.List(c.Request.Context(), filter)
112
117
  {{else}}
113
- items, err := h.svc.List(c.Request.Context(), p.Limit, p.Offset)
118
+ items, total, err := h.svc.List(c.Request.Context(), filter)
114
119
  {{/if}}
115
120
  if err != nil {
116
121
  c.Error(appError(err))
@@ -120,7 +125,7 @@ func (h *Handler) list(c *gin.Context) {
120
125
  for i := range items {
121
126
  out[i] = toResponse(application.ToResponse(&items[i]))
122
127
  }
123
- c.JSON(http.StatusOK, p.Response(out))
128
+ c.JSON(http.StatusOK, p.ResponseWithTotal(out, total))
124
129
  }
125
130
 
126
131
  func (h *Handler) get(c *gin.Context) {
@@ -6,6 +6,7 @@ import (
6
6
 
7
7
  "{{goModule}}/internal/app/{{modulePath}}/domain"
8
8
  "{{goModule}}/internal/app/{{modulePath}}/ports"
9
+ "{{goModule}}/internal/shared/dbq"
9
10
  "{{goModule}}/internal/shared/dberr"
10
11
  "{{goModule}}/internal/shared/tx"
11
12
 
@@ -30,16 +31,38 @@ func (r *Repository) Create(ctx context.Context, m *domain.{{pascalName}}) error
30
31
  return nil
31
32
  }
32
33
 
33
- func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]domain.{{pascalName}}, error) {
34
+ // FindAll answers one page of the filter and how many rows it matched
35
+ // altogether. Two queries, because a count over a LIMITed query would only
36
+ // ever count the page.
37
+ func (r *Repository) FindAll(ctx context.Context, filter ports.ListFilter) ([]domain.{{pascalName}}, int64, error) {
38
+ // A closure rather than one *gorm.DB reused twice: conditions accumulate
39
+ // on the value, so the count would silently inherit the page's LIMIT.
40
+ matching := func() *gorm.DB {
41
+ q := tx.From(ctx, r.db).WithContext(ctx).Model(&{{pascalName}}Model{})
42
+ // TODO: name the columns this list is searched by. dbq.Search is a
43
+ // no-op until it has at least one, so ?q= is accepted and ignored
44
+ // until then. Add this module's own filters beside it.
45
+ q = dbq.Search(q, filter.Search /*, "name", "code" */)
46
+ return q
47
+ }
48
+
49
+ var total int64
50
+ if err := matching().Count(&total).Error; err != nil {
51
+ return nil, 0, mapDatabaseError(err)
52
+ }
53
+
34
54
  var rows []{{pascalName}}Model
35
- if err := tx.From(ctx, r.db).WithContext(ctx).Order("id desc").Limit(limit).Offset(offset).Find(&rows).Error; err != nil {
36
- return nil, mapDatabaseError(err)
55
+ // created_at orders it, id breaks the ties: without the tiebreaker two
56
+ // rows written in the same instant can swap between page 1 and page 2,
57
+ // showing one twice and hiding the other.
58
+ if err := matching().Order("created_at desc, id").Limit(filter.Limit).Offset(filter.Offset).Find(&rows).Error; err != nil {
59
+ return nil, 0, mapDatabaseError(err)
37
60
  }
38
61
  items := make([]domain.{{pascalName}}, len(rows))
39
62
  for i := range rows {
40
63
  items[i] = *toDomain(&rows[i])
41
64
  }
42
- return items, nil
65
+ return items, total, nil
43
66
  }
44
67
 
45
68
  func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*domain.{{pascalName}}, error) {
@@ -15,7 +15,7 @@ import (
15
15
  // which side of the CQRS boundary was called.
16
16
  type repositoryStub struct {
17
17
  createFn func(context.Context, *domain.{{pascalName}}) error
18
- findAllFn func(context.Context, int, int) ([]domain.{{pascalName}}, error)
18
+ findAllFn func(context.Context, ports.ListFilter) ([]domain.{{pascalName}}, int64, error)
19
19
  findByIDFn func(context.Context, uuid.UUID) (*domain.{{pascalName}}, error)
20
20
  updateFn func(context.Context, *domain.{{pascalName}}) error
21
21
  deleteFn func(context.Context, uuid.UUID) error
@@ -28,11 +28,11 @@ func (s *repositoryStub) Create(ctx context.Context, m *domain.{{pascalName}}) e
28
28
  }
29
29
  return s.createFn(ctx, m)
30
30
  }
31
- func (s *repositoryStub) FindAll(ctx context.Context, limit, offset int) ([]domain.{{pascalName}}, error) {
31
+ func (s *repositoryStub) FindAll(ctx context.Context, filter ports.ListFilter) ([]domain.{{pascalName}}, int64, error) {
32
32
  if s.findAllFn == nil {
33
33
  panic("unexpected repository.FindAll call")
34
34
  }
35
- return s.findAllFn(ctx, limit, offset)
35
+ return s.findAllFn(ctx, filter)
36
36
  }
37
37
  func (s *repositoryStub) FindByID(ctx context.Context, id uuid.UUID) (*domain.{{pascalName}}, error) {
38
38
  if s.findByIDFn == nil {
@@ -9,7 +9,7 @@ import (
9
9
  )
10
10
 
11
11
  type QueryPort interface {
12
- List(context.Context, int, int) ([]domain.{{pascalName}}, error)
12
+ List(context.Context, ports.ListFilter) ([]domain.{{pascalName}}, int64, error)
13
13
  Get(context.Context, uuid.UUID) (*domain.{{pascalName}}, error)
14
14
  // go-scaffold:query-interface
15
15
  }
@@ -22,8 +22,8 @@ func NewQueryHandler(repo ports.QueryRepository) *QueryHandler {
22
22
  return &QueryHandler{repo: repo}
23
23
  }
24
24
 
25
- func (h *QueryHandler) List(ctx context.Context, limit, offset int) ([]domain.{{pascalName}}, error) {
26
- return h.repo.FindAll(ctx, limit, offset)
25
+ func (h *QueryHandler) List(ctx context.Context, filter ports.ListFilter) ([]domain.{{pascalName}}, int64, error) {
26
+ return h.repo.FindAll(ctx, filter)
27
27
  }
28
28
 
29
29
  func (h *QueryHandler) Get(ctx context.Context, id uuid.UUID) (*domain.{{pascalName}}, error) {
@@ -13,7 +13,7 @@ import (
13
13
 
14
14
  type ServicePort interface {
15
15
  Create(context.Context, CreateInput) (*domain.{{pascalName}}, error)
16
- List(context.Context, int, int) ([]domain.{{pascalName}}, error)
16
+ List(context.Context, ports.ListFilter) ([]domain.{{pascalName}}, int64, error)
17
17
  Get(context.Context, uuid.UUID) (*domain.{{pascalName}}, error)
18
18
  Update(context.Context, uuid.UUID, UpdateInput) (*domain.{{pascalName}}, error)
19
19
  Delete(context.Context, uuid.UUID) error
@@ -38,8 +38,8 @@ func (s *Service) Create(ctx context.Context, in CreateInput) (*domain.{{pascalN
38
38
  return m, nil
39
39
  }
40
40
 
41
- func (s *Service) List(ctx context.Context, limit, offset int) ([]domain.{{pascalName}}, error) {
42
- return s.repo.FindAll(ctx, limit, offset)
41
+ func (s *Service) List(ctx context.Context, filter ports.ListFilter) ([]domain.{{pascalName}}, int64, error) {
42
+ return s.repo.FindAll(ctx, filter)
43
43
  }
44
44
 
45
45
  func (s *Service) Get(ctx context.Context, id uuid.UUID) (*domain.{{pascalName}}, error) {
@@ -5,6 +5,7 @@ import (
5
5
  "testing"
6
6
 
7
7
  "{{goModule}}/internal/app/{{modulePath}}/domain"
8
+ "{{goModule}}/internal/app/{{modulePath}}/ports"
8
9
 
9
10
  "github.com/google/uuid"
10
11
  )
@@ -15,7 +16,7 @@ import (
15
16
  // orchestration.
16
17
  type repositoryStub struct {
17
18
  createFn func(context.Context, *domain.{{pascalName}}) error
18
- findAllFn func(context.Context, int, int) ([]domain.{{pascalName}}, error)
19
+ findAllFn func(context.Context, ports.ListFilter) ([]domain.{{pascalName}}, int64, error)
19
20
  findByIDFn func(context.Context, uuid.UUID) (*domain.{{pascalName}}, error)
20
21
  updateFn func(context.Context, *domain.{{pascalName}}) error
21
22
  deleteFn func(context.Context, uuid.UUID) error
@@ -28,11 +29,11 @@ func (s *repositoryStub) Create(ctx context.Context, m *domain.{{pascalName}}) e
28
29
  }
29
30
  return s.createFn(ctx, m)
30
31
  }
31
- func (s *repositoryStub) FindAll(ctx context.Context, limit, offset int) ([]domain.{{pascalName}}, error) {
32
+ func (s *repositoryStub) FindAll(ctx context.Context, filter ports.ListFilter) ([]domain.{{pascalName}}, int64, error) {
32
33
  if s.findAllFn == nil {
33
34
  panic("unexpected repository.FindAll call")
34
35
  }
35
- return s.findAllFn(ctx, limit, offset)
36
+ return s.findAllFn(ctx, filter)
36
37
  }
37
38
  func (s *repositoryStub) FindByID(ctx context.Context, id uuid.UUID) (*domain.{{pascalName}}, error) {
38
39
  if s.findByIDFn == nil {
@@ -11,10 +11,26 @@ import (
11
11
  "github.com/google/uuid"
12
12
  )
13
13
 
14
+ // ListFilter is the whole question the list endpoint asks. The zero value is
15
+ // "the first page of everything", so a caller only sets what it narrows by.
16
+ //
17
+ // Put this module's own filters here as fields rather than as arguments: one
18
+ // struct travels handler -> application -> repository, so adding a filter
19
+ // later is a field and not a fourth parameter on three signatures.
20
+ type ListFilter struct {
21
+ // Search is ?q= as shared/pagination parsed it, empty when the caller
22
+ // sent none.
23
+ Search string
24
+ Limit int
25
+ Offset int
26
+ }
27
+
14
28
  // Repository is the complete persistence port for service-style modules.
15
29
  type Repository interface {
16
30
  Create(context.Context, *domain.{{pascalName}}) error
17
- FindAll(context.Context, int, int) ([]domain.{{pascalName}}, error)
31
+ // FindAll answers one page and the total the filter matched, which is what
32
+ // a client needs to draw "page 3 of 12".
33
+ FindAll(context.Context, ListFilter) ([]domain.{{pascalName}}, int64, error)
18
34
  FindByID(context.Context, uuid.UUID) (*domain.{{pascalName}}, error)
19
35
  Update(context.Context, *domain.{{pascalName}}) error
20
36
  Delete(context.Context, uuid.UUID) error
@@ -32,7 +48,7 @@ type CommandRepository interface {
32
48
  }
33
49
 
34
50
  type QueryRepository interface {
35
- FindAll(context.Context, int, int) ([]domain.{{pascalName}}, error)
51
+ FindAll(context.Context, ListFilter) ([]domain.{{pascalName}}, int64, error)
36
52
  FindByID(context.Context, uuid.UUID) (*domain.{{pascalName}}, error)
37
53
  // go-scaffold:query-repository-interface
38
54
  }