@nakedev/go-scaffold 0.1.3 → 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 +143 -50
  3. package/dist/commands/auth.js +116 -11
  4. package/dist/commands/create.js +13 -1
  5. package/dist/commands/generate.js +23 -12
  6. package/dist/commands/method.js +94 -17
  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 +368 -64
  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/module-manifest.js +2 -0
  19. package/dist/templates/observability-manifest.js +24 -0
  20. package/dist/templates/rbac-manifest.js +1 -0
  21. package/dist/templates/worker-manifest.js +23 -6
  22. package/dist/utils/auth-patcher.js +96 -21
  23. package/dist/utils/config.js +58 -10
  24. package/dist/utils/gocheck.js +57 -5
  25. package/dist/utils/golangci-patcher.js +73 -0
  26. package/dist/utils/gomod-patcher.js +53 -0
  27. package/dist/utils/main-patcher.js +58 -4
  28. package/dist/utils/marker-patch.js +125 -3
  29. package/dist/utils/method-patcher.js +97 -18
  30. package/dist/utils/module-location.js +58 -0
  31. package/dist/utils/naming.js +125 -14
  32. package/dist/utils/observability-patcher.js +107 -0
  33. package/dist/utils/openapi-patcher.js +19 -1
  34. package/dist/utils/platform-patcher.js +98 -12
  35. package/dist/utils/rbac-patcher.js +60 -10
  36. package/dist/utils/smoke-run.js +31 -0
  37. package/package.json +11 -4
  38. package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
  39. package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
  40. package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
  41. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
  42. package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
  43. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
  44. package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
  45. package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
  46. package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
  50. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
  51. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
  52. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
  53. package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
  54. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
  55. package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
  56. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
  57. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
  58. package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
  59. package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
  60. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  61. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
  62. package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
  63. package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
  64. package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
  65. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
  66. package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
  67. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
  68. package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
  69. package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
  70. package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
  71. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
  72. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
  73. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
  74. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
  75. package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
  76. package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
  77. package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
  78. package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
  79. package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
  80. package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
  81. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
  82. package/templates/create/base/.dockerignore.hbs +13 -0
  83. package/templates/create/base/.env.example.hbs +18 -9
  84. package/templates/create/base/.github/dependabot.yml.hbs +20 -0
  85. package/templates/create/base/.github/workflows/ci.yml.hbs +29 -6
  86. package/templates/create/base/.golangci.yml.hbs +27 -0
  87. package/templates/create/base/AGENTS.md.hbs +14 -12
  88. package/templates/create/base/Dockerfile.hbs +42 -0
  89. package/templates/create/base/Makefile.hbs +52 -16
  90. package/templates/create/base/README.md.hbs +61 -12
  91. package/templates/create/base/cmd/api/main.go.hbs +17 -130
  92. package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
  93. package/templates/create/base/go.mod.hbs +4 -4
  94. package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
  95. package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
  96. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
  97. package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
  98. package/templates/create/base/redocly.yaml.hbs +21 -0
  99. package/templates/create/features/docs/architecture.md.hbs +32 -11
  100. package/templates/create/features/docs/openapi.yaml.hbs +4 -9
  101. package/templates/create/features/docs/patterns.md.hbs +91 -14
  102. package/templates/create/features/docs/techstack.md.hbs +8 -3
  103. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  104. package/templates/generate/module/dto.go.hbs +8 -1
  105. package/templates/generate/module/errors.go.hbs +5 -0
  106. package/templates/generate/module/field-column.down.sql.hbs +2 -0
  107. package/templates/generate/module/field-column.up.sql.hbs +15 -0
  108. package/templates/generate/module/handler.go.hbs +18 -4
  109. package/templates/generate/module/handler_test.go.hbs +88 -62
  110. package/templates/generate/module/migration.down.sql.hbs +3 -1
  111. package/templates/generate/module/migration.up.sql.hbs +7 -2
  112. package/templates/generate/module/minimal/dto.go.hbs +3 -1
  113. package/templates/generate/module/minimal/handler.go.hbs +8 -2
  114. package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
  115. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  116. package/templates/generate/module/model/model.go.hbs +16 -0
  117. package/templates/generate/module/permission.up.sql.hbs +3 -1
  118. package/templates/generate/module/repository.go.hbs +60 -6
  119. package/templates/generate/module/repository_test.go.hbs +109 -0
  120. package/templates/generate/module/service.go.hbs +15 -4
  121. package/templates/generate/module/service_test.go.hbs +113 -17
  122. package/dist/commands/remove.js +0 -89
  123. package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
  124. package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
@@ -4,6 +4,7 @@ import (
4
4
  "context"
5
5
 
6
6
  "{{goModule}}/internal/app/role/model"
7
+ "{{goModule}}/internal/shared/tx"
7
8
 
8
9
  "gorm.io/gorm"
9
10
  )
@@ -21,7 +22,7 @@ func NewRepository(db *gorm.DB) *Repository {
21
22
  // PermissionCodes returns the permission codes granted to roleCode.
22
23
  func (r *Repository) PermissionCodes(ctx context.Context, roleCode string) ([]string, error) {
23
24
  var codes []string
24
- err := r.db.WithContext(ctx).
25
+ err := tx.From(ctx, r.db).WithContext(ctx).
25
26
  Model(&model.RolePermission{}).
26
27
  Where("role_code = ?", roleCode).
27
28
  Pluck("permission_code", &codes).Error
@@ -30,28 +31,28 @@ func (r *Repository) PermissionCodes(ctx context.Context, roleCode string) ([]st
30
31
 
31
32
  func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.Role, error) {
32
33
  var items []model.Role
33
- err := r.db.WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
34
+ err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
34
35
  return items, err
35
36
  }
36
37
 
37
38
  func (r *Repository) FindByCode(ctx context.Context, code string) (*model.Role, error) {
38
39
  var m model.Role
39
- if err := r.db.WithContext(ctx).First(&m, "code = ?", code).Error; err != nil {
40
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&m, "code = ?", code).Error; err != nil {
40
41
  return nil, err
41
42
  }
42
43
  return &m, nil
43
44
  }
44
45
 
45
46
  func (r *Repository) Create(ctx context.Context, m *model.Role) error {
46
- return r.db.WithContext(ctx).Create(m).Error
47
+ return tx.From(ctx, r.db).WithContext(ctx).Create(m).Error
47
48
  }
48
49
 
49
50
  // SetPermissions replaces roleCode's entire permission grant set in one
50
51
  // transaction — simpler for a checkbox-style admin UI than diffing
51
52
  // add/remove, and cheap at this scale (a handful of rows per role).
52
53
  func (r *Repository) SetPermissions(ctx context.Context, roleCode string, permissionCodes []string) error {
53
- return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
54
- if err := tx.Where("role_code = ?", roleCode).Delete(&model.RolePermission{}).Error; err != nil {
54
+ return tx.From(ctx, r.db).WithContext(ctx).Transaction(func(t *gorm.DB) error {
55
+ if err := t.Where("role_code = ?", roleCode).Delete(&model.RolePermission{}).Error; err != nil {
55
56
  return err
56
57
  }
57
58
  if len(permissionCodes) == 0 {
@@ -61,17 +62,17 @@ func (r *Repository) SetPermissions(ctx context.Context, roleCode string, permis
61
62
  for i, code := range permissionCodes {
62
63
  grants[i] = model.RolePermission{RoleCode: roleCode, PermissionCode: code}
63
64
  }
64
- return tx.Create(&grants).Error
65
+ return t.Create(&grants).Error
65
66
  })
66
67
  }
67
68
 
68
69
  func (r *Repository) Delete(ctx context.Context, code string) error {
69
- return r.db.WithContext(ctx).Delete(&model.Role{}, "code = ?", code).Error
70
+ return tx.From(ctx, r.db).WithContext(ctx).Delete(&model.Role{}, "code = ?", code).Error
70
71
  }
71
72
 
72
73
  func (r *Repository) FindAllPermissions(ctx context.Context, limit, offset int) ([]model.Permission, error) {
73
74
  var items []model.Permission
74
- err := r.db.WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
75
+ err := tx.From(ctx, r.db).WithContext(ctx).Order("code").Limit(limit).Offset(offset).Find(&items).Error
75
76
  return items, err
76
77
  }
77
78
 
@@ -80,7 +81,7 @@ func (r *Repository) FindAllPermissions(ctx context.Context, limit, offset int)
80
81
  // SetPermissions request.
81
82
  func (r *Repository) AllPermissionCodes(ctx context.Context) ([]string, error) {
82
83
  var codes []string
83
- err := r.db.WithContext(ctx).Model(&model.Permission{}).Pluck("code", &codes).Error
84
+ err := tx.From(ctx, r.db).WithContext(ctx).Model(&model.Permission{}).Pluck("code", &codes).Error
84
85
  return codes, err
85
86
  }
86
87
 
@@ -89,7 +90,7 @@ func (r *Repository) AllPermissionCodes(ctx context.Context) ([]string, error) {
89
90
  // would leave no role holding permissionCode at all.
90
91
  func (r *Repository) CountRolesWithPermission(ctx context.Context, permissionCode, excludeCode string) (int64, error) {
91
92
  var count int64
92
- err := r.db.WithContext(ctx).Model(&model.RolePermission{}).
93
+ err := tx.From(ctx, r.db).WithContext(ctx).Model(&model.RolePermission{}).
93
94
  Where("permission_code = ? AND role_code <> ?", permissionCode, excludeCode).
94
95
  Count(&count).Error
95
96
  return count, err
@@ -0,0 +1,176 @@
1
+ package role
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "testing"
7
+
8
+ "{{goModule}}/internal/app/role/model"
9
+
10
+ "github.com/google/uuid"
11
+ "gorm.io/driver/postgres"
12
+ "gorm.io/gorm"
13
+ )
14
+
15
+ // repositoryDBForTest expects the test database schema to come from the same
16
+ // versioned SQL migrations used in production. Unit tests stay database-free;
17
+ // CI sets REQUIRE_TEST_DB=true so an unavailable/unmigrated database fails
18
+ // instead of becoming a false-green skip. Same pattern as every generated
19
+ // module's repository_test.go.
20
+ func repositoryDBForTest(t *testing.T) *gorm.DB {
21
+ t.Helper()
22
+ dsn := os.Getenv("TEST_DB_DSN")
23
+ if dsn == "" {
24
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
25
+ t.Fatal("TEST_DB_DSN is required when REQUIRE_TEST_DB=true")
26
+ }
27
+ t.Skip("repository integration test skipped: set TEST_DB_DSN to a migrated PostgreSQL database")
28
+ }
29
+
30
+ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
31
+ if err != nil {
32
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
33
+ t.Fatalf("open required test database: %v", err)
34
+ }
35
+ t.Skipf("repository integration test skipped: %v", err)
36
+ }
37
+
38
+ sqlDB, err := db.DB()
39
+ if err != nil {
40
+ t.Fatalf("get SQL database handle: %v", err)
41
+ }
42
+ if err := sqlDB.Ping(); err != nil {
43
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
44
+ t.Fatalf("ping required test database: %v", err)
45
+ }
46
+ t.Skipf("repository integration test skipped: %v", err)
47
+ }
48
+
49
+ tx := db.Begin()
50
+ if tx.Error != nil {
51
+ t.Fatalf("begin test transaction: %v", tx.Error)
52
+ }
53
+ t.Cleanup(func() {
54
+ if err := tx.Rollback().Error; err != nil {
55
+ t.Errorf("rollback test transaction: %v", err)
56
+ }
57
+ })
58
+ return tx
59
+ }
60
+
61
+ // testCode returns a role/permission code that fits both varchar(20) and
62
+ // varchar(50) columns and won't collide with the seeded 'staff'/'admin'
63
+ // roles or 'role:manage'/'user:manage-role'/'user:read' permissions.
64
+ func testCode(t *testing.T) string {
65
+ t.Helper()
66
+ return "t" + uuid.New().String()[:7]
67
+ }
68
+
69
+ // seedPermission inserts directly rather than through Repository, which has
70
+ // no create method for Permission on purpose (see model/permission.go.hbs:
71
+ // "seeded by migration, not admin-created"). Tests still need rows to grant.
72
+ func seedPermission(t *testing.T, tx *gorm.DB, code string) {
73
+ t.Helper()
74
+ if err := tx.WithContext(context.Background()).Create(&model.Permission{Code: code, Description: code}).Error; err != nil {
75
+ t.Fatalf("seed permission %s: %v", code, err)
76
+ }
77
+ }
78
+
79
+ // SetPermissions deletes-then-recreates the grant set inside one transaction
80
+ // (see repository.go.hbs). A stub can assert it was called with the right
81
+ // arguments, but not that the *table* ends up holding exactly the new set —
82
+ // that's a property of the real delete+insert, not of the call.
83
+ func TestRepository_SetPermissions_ReplacesEntireSet(t *testing.T) {
84
+ tx := repositoryDBForTest(t)
85
+ repo := NewRepository(tx)
86
+ ctx := context.Background()
87
+
88
+ roleCode := testCode(t)
89
+ permA, permB, permC := testCode(t), testCode(t), testCode(t)
90
+ if err := repo.Create(ctx, &model.Role{Code: roleCode, Name: roleCode}); err != nil {
91
+ t.Fatalf("seed role: %v", err)
92
+ }
93
+ seedPermission(t, tx, permA)
94
+ seedPermission(t, tx, permB)
95
+ seedPermission(t, tx, permC)
96
+
97
+ if err := repo.SetPermissions(ctx, roleCode, []string{permA, permB}); err != nil {
98
+ t.Fatalf("grant a,b: %v", err)
99
+ }
100
+ if err := repo.SetPermissions(ctx, roleCode, []string{permC}); err != nil {
101
+ t.Fatalf("replace with c: %v", err)
102
+ }
103
+
104
+ got, err := repo.PermissionCodes(ctx, roleCode)
105
+ if err != nil {
106
+ t.Fatalf("permission codes: %v", err)
107
+ }
108
+ if len(got) != 1 || got[0] != permC {
109
+ t.Fatalf("want only [%s] after replacing the grant set, got %v", permC, got)
110
+ }
111
+ }
112
+
113
+ // role_permissions references both roles and permissions with
114
+ // ON DELETE CASCADE (add_roles.up.sql) — deleting a role should never leave
115
+ // an orphaned grant row behind, and should never surface as an FK error the
116
+ // caller has to handle.
117
+ func TestRepository_Delete_CascadesRolePermissions(t *testing.T) {
118
+ tx := repositoryDBForTest(t)
119
+ repo := NewRepository(tx)
120
+ ctx := context.Background()
121
+
122
+ roleCode := testCode(t)
123
+ permCode := testCode(t)
124
+ if err := repo.Create(ctx, &model.Role{Code: roleCode, Name: roleCode}); err != nil {
125
+ t.Fatalf("seed role: %v", err)
126
+ }
127
+ seedPermission(t, tx, permCode)
128
+ if err := repo.SetPermissions(ctx, roleCode, []string{permCode}); err != nil {
129
+ t.Fatalf("grant: %v", err)
130
+ }
131
+
132
+ if err := repo.Delete(ctx, roleCode); err != nil {
133
+ t.Fatalf("delete role: %v", err)
134
+ }
135
+
136
+ got, err := repo.PermissionCodes(ctx, roleCode)
137
+ if err != nil {
138
+ t.Fatalf("permission codes after delete: %v", err)
139
+ }
140
+ if len(got) != 0 {
141
+ t.Fatalf("want role_permissions cascade-deleted along with the role, still have %v", got)
142
+ }
143
+ }
144
+
145
+ // CountRolesWithPermission backs the "would this leave no role holding the
146
+ // permission" guard in service.go — the exclusion in its WHERE clause is
147
+ // exactly the kind of off-by-one a stub returning a canned int can't catch.
148
+ func TestRepository_CountRolesWithPermission_ExcludesGivenRole(t *testing.T) {
149
+ tx := repositoryDBForTest(t)
150
+ repo := NewRepository(tx)
151
+ ctx := context.Background()
152
+
153
+ roleA, roleB := testCode(t), testCode(t)
154
+ permCode := testCode(t)
155
+ if err := repo.Create(ctx, &model.Role{Code: roleA, Name: roleA}); err != nil {
156
+ t.Fatalf("seed role a: %v", err)
157
+ }
158
+ if err := repo.Create(ctx, &model.Role{Code: roleB, Name: roleB}); err != nil {
159
+ t.Fatalf("seed role b: %v", err)
160
+ }
161
+ seedPermission(t, tx, permCode)
162
+ if err := repo.SetPermissions(ctx, roleA, []string{permCode}); err != nil {
163
+ t.Fatalf("grant to a: %v", err)
164
+ }
165
+ if err := repo.SetPermissions(ctx, roleB, []string{permCode}); err != nil {
166
+ t.Fatalf("grant to b: %v", err)
167
+ }
168
+
169
+ count, err := repo.CountRolesWithPermission(ctx, permCode, roleA)
170
+ if err != nil {
171
+ t.Fatalf("count: %v", err)
172
+ }
173
+ if count != 1 {
174
+ t.Fatalf("want 1 (roleB only, roleA excluded), got %d", count)
175
+ }
176
+ }
@@ -77,6 +77,13 @@ func (s *Service) List(ctx context.Context, limit, offset int) ([]roleListItem,
77
77
  if err != nil {
78
78
  return nil, apperror.NewInternal()
79
79
  }
80
+ // ponytail: one query per role, not a join. A system has a handful of
81
+ // roles, so this is a handful of round trips on an admin screen nobody
82
+ // loads in a loop — the join costs more to read than it saves.
83
+ //
84
+ // Do not copy this shape into a domain whose list is user-sized. There the
85
+ // upgrade is one query with a join (or an IN over the page's ids) and a
86
+ // group-by in Go; here it would be ceremony.
80
87
  out := make([]roleListItem, len(roles))
81
88
  for i, r := range roles {
82
89
  perms, err := s.repo.PermissionCodes(ctx, r.Code)
@@ -54,6 +54,25 @@ func (a *Authz) Require(perm string) gin.HandlerFunc {
54
54
  }
55
55
  }
56
56
 
57
+ // Invalidate drops a role's cached permissions so the next request re-reads
58
+ // them. The write path (PATCH /roles/:code/permissions) runs in this same
59
+ // process, so letting the grant sit behind the TTL was a pure own-goal:
60
+ // granting a permission appeared to succeed and then did nothing for up to
61
+ // AUTHZ_CACHE_TTL_MIN, and logging in again didn't help either — the cache is
62
+ // keyed on the role, not the token — so the only way out was restarting the
63
+ // app.
64
+ //
65
+ // ponytail: same-process only. Other pods keep their copy until the TTL
66
+ // expires; that's the documented ceiling, and Redis pub/sub is the upgrade if
67
+ // instant cluster-wide invalidation ever matters.
68
+ func (a *Authz) Invalidate(roleCodes ...string) {
69
+ a.mu.Lock()
70
+ defer a.mu.Unlock()
71
+ for _, code := range roleCodes {
72
+ delete(a.cache, code)
73
+ }
74
+ }
75
+
57
76
  // ponytail: process-local cache, a handful of roles x AUTHZ_CACHE_TTL_MIN —
58
77
  // a role's permission grants take effect within the TTL, no pub/sub needed.
59
78
  // Move to Redis only if instant cross-pod invalidation turns out to matter.
@@ -57,7 +57,7 @@ func TestAuthz_Require_ForbidsMissingPermission(t *testing.T) {
57
57
  r := gin.New()
58
58
  // Require only records a *apperror.AppError via c.Error — Error(true) is
59
59
  // what actually translates that into the response status code, same as
60
- // the real router (see cmd/api/main.go).
60
+ // the real router (see cmd/api/wiring.go).
61
61
  r.Use(Error(true))
62
62
  r.GET("/x", func(c *gin.Context) { c.Set(RoleKey, "staff") }, a.Require("role:manage"), func(c *gin.Context) {
63
63
  c.Status(http.StatusOK)
@@ -3,13 +3,13 @@
3
3
  -- raise a clear, actionable error instead of a raw FK-violation.
4
4
  DO $$
5
5
  BEGIN
6
- IF EXISTS (SELECT 1 FROM users WHERE role NOT IN ('staff', 'admin')) THEN
6
+ IF EXISTS (SELECT 1 FROM user_svc.users WHERE role NOT IN ('staff', 'admin')) THEN
7
7
  RAISE EXCEPTION 'cannot roll back add_roles: users still have a role other than staff/admin — reassign them to staff/admin first';
8
8
  END IF;
9
9
  END $$;
10
10
 
11
- ALTER TABLE users DROP COLUMN role;
11
+ ALTER TABLE user_svc.users DROP COLUMN role;
12
12
 
13
- DROP TABLE role_permissions;
14
- DROP TABLE permissions;
15
- DROP TABLE roles;
13
+ DROP TABLE role_svc.role_permissions;
14
+ DROP TABLE role_svc.permissions;
15
+ DROP TABLE role_svc.roles;
@@ -1,4 +1,6 @@
1
- CREATE TABLE roles (
1
+ CREATE SCHEMA IF NOT EXISTS role_svc;
2
+
3
+ CREATE TABLE role_svc.roles (
2
4
  code VARCHAR(20) PRIMARY KEY,
3
5
  name TEXT NOT NULL,
4
6
  is_system BOOLEAN NOT NULL DEFAULT FALSE,
@@ -6,30 +8,34 @@ CREATE TABLE roles (
6
8
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
7
9
  );
8
10
 
9
- CREATE TABLE permissions (
11
+ CREATE TABLE role_svc.permissions (
10
12
  code VARCHAR(50) PRIMARY KEY,
11
13
  description TEXT NOT NULL
12
14
  );
13
15
 
14
- CREATE TABLE role_permissions (
15
- role_code VARCHAR(20) NOT NULL REFERENCES roles(code) ON DELETE CASCADE,
16
- permission_code VARCHAR(50) NOT NULL REFERENCES permissions(code) ON DELETE CASCADE,
16
+ CREATE TABLE role_svc.role_permissions (
17
+ role_code VARCHAR(20) NOT NULL REFERENCES role_svc.roles(code) ON DELETE CASCADE,
18
+ permission_code VARCHAR(50) NOT NULL REFERENCES role_svc.permissions(code) ON DELETE CASCADE,
17
19
  PRIMARY KEY (role_code, permission_code)
18
20
  );
19
21
 
20
- INSERT INTO roles (code, name, is_system) VALUES
22
+ INSERT INTO role_svc.roles (code, name, is_system) VALUES
21
23
  ('staff', 'Staff', TRUE),
22
24
  ('admin', 'Admin', TRUE);
23
25
 
24
- INSERT INTO permissions (code, description) VALUES
26
+ INSERT INTO role_svc.permissions (code, description) VALUES
25
27
  ('role:manage', 'Create roles and change their permissions'),
26
28
  ('user:manage-role', 'Change a user''s assigned role'),
27
29
  ('user:read', 'List and view other users');
28
30
 
29
31
  -- admin gets every permission that exists, staff gets none — no behavior
30
32
  -- change for existing users until an admin explicitly grants something.
31
- INSERT INTO role_permissions (role_code, permission_code)
32
- SELECT 'admin', code FROM permissions;
33
+ INSERT INTO role_svc.role_permissions (role_code, permission_code)
34
+ SELECT 'admin', code FROM role_svc.permissions;
33
35
 
34
- ALTER TABLE users
35
- ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'staff' REFERENCES roles(code);
36
+ -- cross-schema FK: role_svc's migration is the one place allowed to alter
37
+ -- user_svc's table, because this feature's whole point is "give users a
38
+ -- role" — see docs/architect/patterns.md's FK rules for why a plain FK, not
39
+ -- an app-level existence check, is the right way to enforce this.
40
+ ALTER TABLE user_svc.users
41
+ ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'staff' REFERENCES role_svc.roles(code);
@@ -2,63 +2,69 @@ package main
2
2
 
3
3
  import (
4
4
  "context"
5
- "errors"
6
5
  "log/slog"
7
6
  "os"
8
7
  "os/signal"
9
8
  "syscall"
10
9
  "time"
11
10
 
11
+ {{#if riverQueue}}
12
+ "{{goModule}}/internal/platform/database"
13
+ {{/if}}
12
14
  "{{goModule}}/internal/platform/mail"
13
15
  "{{goModule}}/internal/platform/queue"
14
16
  "{{goModule}}/internal/shared/config"
15
17
  // go-scaffold:imports
16
18
  )
17
19
 
20
+ // workerConcurrency is how many jobs run at once in this process. Scale out
21
+ // by running more worker processes before raising this.
22
+ const workerConcurrency = 10
23
+
18
24
  func main() {
19
25
  cfg := config.Load()
20
26
 
21
27
  logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: parseLevel(cfg.LogLevel)}))
22
28
  slog.SetDefault(logger)
23
29
 
24
- srv, err := queue.NewServer(cfg.RedisURL)
30
+ {{#if riverQueue}}
31
+ // The queue lives in the same database as your data — the worker needs a
32
+ // connection to it, not to a separate broker.
33
+ db, err := database.Open(cfg)
25
34
  if err != nil {
26
- logger.Error("new queue server", "error", err)
35
+ logger.Error("open db", "error", err)
27
36
  os.Exit(1)
28
37
  }
29
38
 
30
- srv.Handle(mail.TypeSendEmail, mail.HandleSendEmail(mail.Open(cfg)))
39
+ q, err := queue.NewRiverWorker(db, workerConcurrency)
40
+ {{else}}
41
+ q, err := queue.NewAsynqWorker(cfg.RedisURL, workerConcurrency)
42
+ {{/if}}
43
+ if err != nil {
44
+ logger.Error("start queue", "error", err)
45
+ os.Exit(1)
46
+ }
47
+
48
+ q.Handle(mail.KindSendEmail, mail.Handle(mail.Open(cfg)))
31
49
  // go-scaffold:queue-handlers
32
50
 
33
51
  ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
34
52
  defer stop()
35
53
 
36
- go func() {
37
- logger.Info("worker started")
38
- if err := srv.Start(); err != nil && !errors.Is(err, context.Canceled) {
39
- logger.Error("worker server", "error", err)
40
- os.Exit(1)
41
- }
42
- }()
54
+ if err := q.Start(ctx); err != nil {
55
+ logger.Error("worker start", "error", err)
56
+ os.Exit(1)
57
+ }
58
+ logger.Info("worker started", "concurrency", workerConcurrency)
43
59
 
44
60
  <-ctx.Done()
45
61
  logger.Info("shutting down worker")
46
62
 
63
+ // Give in-flight jobs a bounded window to finish before exiting.
47
64
  shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
48
65
  defer cancel()
49
- // asynq's Shutdown() blocks until in-flight tasks finish (or the process
50
- // is killed) with no context/timeout parameter of its own — run it in a
51
- // goroutine and race it against shutdownCtx so a stuck task can't hang
52
- // the process forever.
53
- done := make(chan struct{}, 1)
54
- go func() {
55
- srv.Shutdown()
56
- close(done)
57
- }()
58
- select {
59
- case <-done:
60
- case <-shutdownCtx.Done():
61
- logger.Warn("worker shutdown timed out")
66
+ if err := q.Stop(shutdownCtx); err != nil {
67
+ logger.Warn("worker shutdown timed out", "error", err)
62
68
  }
63
69
  logger.Info("worker stopped")
64
70
  }
@@ -1,6 +1,7 @@
1
1
  package mail
2
2
 
3
3
  import (
4
+ "context"
4
5
  "fmt"
5
6
  "log/slog"
6
7
  "net/smtp"
@@ -46,3 +47,23 @@ func (c *Client) Send(to, subject, body string) error {
46
47
  }
47
48
  return smtp.SendMail(c.host+":"+c.port, auth, c.from, []string{to}, []byte(msg))
48
49
  }
50
+
51
+ // SyncClient sends in the caller's goroutine, satisfying the same mailer
52
+ // interface AsyncClient does. It exists so `add auth` can stand on its own:
53
+ // a project that hasn't asked for a queue shouldn't be made to run a second
54
+ // binary just to send a verification email.
55
+ //
56
+ // The trade is real — /auth/register and /auth/forgot-password block until
57
+ // SMTP answers, where the async client returns as soon as the job is written.
58
+ // Add a worker later and the wiring in cmd/api/wiring.go switches to
59
+ // AsyncClient; nothing in the user service changes.
60
+ type SyncClient struct{ c *Client }
61
+
62
+ func NewSyncClient(c *Client) *SyncClient { return &SyncClient{c: c} }
63
+
64
+ // Send ignores ctx: there is no queue here to join a transaction, so the mail
65
+ // goes out whether or not the caller's transaction ends up committing. That is
66
+ // the guarantee the Postgres-backed queue exists to give you.
67
+ func (c *SyncClient) Send(_ context.Context, to, subject, body string) error {
68
+ return c.c.Send(to, subject, body)
69
+ }
@@ -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
  }