@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.
- package/LICENSE +21 -0
- package/README.md +143 -50
- package/dist/commands/auth.js +116 -11
- package/dist/commands/create.js +13 -1
- package/dist/commands/generate.js +23 -12
- package/dist/commands/method.js +94 -17
- 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 +368 -64
- 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/module-manifest.js +2 -0
- 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 +97 -18
- package/dist/utils/module-location.js +58 -0
- package/dist/utils/naming.js +125 -14
- package/dist/utils/observability-patcher.js +107 -0
- package/dist/utils/openapi-patcher.js +19 -1
- package/dist/utils/platform-patcher.js +98 -12
- package/dist/utils/rbac-patcher.js +60 -10
- package/dist/utils/smoke-run.js +31 -0
- package/package.json +11 -4
- 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 +29 -6
- package/templates/create/base/.golangci.yml.hbs +27 -0
- package/templates/create/base/AGENTS.md.hbs +14 -12
- package/templates/create/base/Dockerfile.hbs +42 -0
- package/templates/create/base/Makefile.hbs +52 -16
- package/templates/create/base/README.md.hbs +61 -12
- 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 +4 -9
- package/templates/create/features/docs/patterns.md.hbs +91 -14
- package/templates/create/features/docs/techstack.md.hbs +8 -3
- package/templates/generate/module/docs/item.yaml.hbs +3 -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.go.hbs +18 -4
- package/templates/generate/module/handler_test.go.hbs +88 -62
- 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/minimal/handler.go.hbs +8 -2
- package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
- package/templates/generate/module/minimal/service_test.go.hbs +39 -16
- package/templates/generate/module/model/model.go.hbs +16 -0
- 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 +109 -0
- package/templates/generate/module/service.go.hbs +15 -4
- package/templates/generate/module/service_test.go.hbs +113 -17
- package/dist/commands/remove.js +0 -89
- package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
- package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
package {{pkg}}
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"os"
|
|
7
|
+
"testing"
|
|
8
|
+
|
|
9
|
+
"{{goModule}}/internal/app/{{modulePath}}/model"
|
|
10
|
+
|
|
11
|
+
"github.com/google/uuid"
|
|
12
|
+
"gorm.io/driver/postgres"
|
|
13
|
+
"gorm.io/gorm"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
// repositoryDBForTest expects the test database schema to come from the same
|
|
17
|
+
// versioned SQL migrations used in production. Unit tests stay database-free;
|
|
18
|
+
// CI sets REQUIRE_TEST_DB=true so an unavailable/unmigrated database fails
|
|
19
|
+
// instead of becoming a false-green skip.
|
|
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
|
+
func TestRepository_CreateFindDelete(t *testing.T) {
|
|
62
|
+
repo := NewRepository(repositoryDBForTest(t))
|
|
63
|
+
ctx := context.Background()
|
|
64
|
+
id := uuid.New()
|
|
65
|
+
item := &model.{{pascalName}}{ID: id}
|
|
66
|
+
|
|
67
|
+
if err := repo.Create(ctx, item); err != nil {
|
|
68
|
+
t.Fatalf("create: %v", err)
|
|
69
|
+
}
|
|
70
|
+
found, err := repo.FindByID(ctx, id)
|
|
71
|
+
if err != nil {
|
|
72
|
+
t.Fatalf("find by id: %v", err)
|
|
73
|
+
}
|
|
74
|
+
if found.ID != id {
|
|
75
|
+
t.Fatalf("want id %s, got %s", id, found.ID)
|
|
76
|
+
}
|
|
77
|
+
if err := repo.Delete(ctx, id); err != nil {
|
|
78
|
+
t.Fatalf("delete: %v", err)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Two clients both hold version 1; the second save must be refused instead of
|
|
83
|
+
// overwriting the first. Runs against the real database because the guard is
|
|
84
|
+
// a WHERE clause — a stub would only be testing itself.
|
|
85
|
+
func TestRepository_Update_RefusesStaleVersion(t *testing.T) {
|
|
86
|
+
repo := NewRepository(repositoryDBForTest(t))
|
|
87
|
+
ctx := context.Background()
|
|
88
|
+
item := &model.{{pascalName}}{ID: uuid.New()}
|
|
89
|
+
|
|
90
|
+
if err := repo.Create(ctx, item); err != nil {
|
|
91
|
+
t.Fatalf("create: %v", err)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
first := &model.{{pascalName}}{ID: item.ID, Version: item.Version}
|
|
95
|
+
if err := repo.Update(ctx, first); err != nil {
|
|
96
|
+
t.Fatalf("first update: %v", err)
|
|
97
|
+
}
|
|
98
|
+
if first.Version != item.Version+1 {
|
|
99
|
+
t.Fatalf("want version bumped to %d, got %d", item.Version+1, first.Version)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
stale := &model.{{pascalName}}{ID: item.ID, Version: item.Version}
|
|
103
|
+
if err := repo.Update(ctx, stale); !errors.Is(err, ErrStaleVersion) {
|
|
104
|
+
t.Fatalf("want ErrStaleVersion for the second writer, got %v", err)
|
|
105
|
+
}
|
|
106
|
+
if stale.Version != item.Version {
|
|
107
|
+
t.Fatalf("a refused update must leave the caller's version alone, got %d", stale.Version)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -33,8 +33,9 @@ func NewService(repo repository) *Service {
|
|
|
33
33
|
return &Service{repo: repo}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
func (s *Service) Create(ctx context.Context) (*model.{{pascalName}}, error) {
|
|
37
|
-
// TODO:
|
|
36
|
+
func (s *Service) Create(ctx context.Context, in createInput) (*model.{{pascalName}}, error) {
|
|
37
|
+
// TODO: set real fields from in
|
|
38
|
+
_ = in
|
|
38
39
|
m := &model.{{pascalName}}{ID: id.New()}
|
|
39
40
|
if err := s.repo.Create(ctx, m); err != nil {
|
|
40
41
|
if dberr.IsDuplicate(err) {
|
|
@@ -61,13 +62,20 @@ func (s *Service) Get(ctx context.Context, id uuid.UUID) (*model.{{pascalName}},
|
|
|
61
62
|
return m, nil
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
func (s *Service) Update(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
65
|
+
func (s *Service) Update(ctx context.Context, id uuid.UUID, in updateInput) (*model.{{pascalName}}, error) {
|
|
65
66
|
m, err := s.repo.FindByID(ctx, id)
|
|
66
67
|
if err != nil {
|
|
67
68
|
return nil, wrapFindErr(err)
|
|
68
69
|
}
|
|
69
|
-
// TODO: apply real fields from
|
|
70
|
+
// TODO: apply real fields from in before saving
|
|
71
|
+
// The version the client read comes from the request, not from the row we
|
|
72
|
+
// just loaded — comparing the row against itself would always succeed and
|
|
73
|
+
// defeat the check.
|
|
74
|
+
m.Version = in.Version
|
|
70
75
|
if err := s.repo.Update(ctx, m); err != nil {
|
|
76
|
+
if errors.Is(err, ErrStaleVersion) {
|
|
77
|
+
return nil, errStale()
|
|
78
|
+
}
|
|
71
79
|
if dberr.IsDuplicate(err) {
|
|
72
80
|
return nil, errConflict()
|
|
73
81
|
}
|
|
@@ -78,6 +86,9 @@ func (s *Service) Update(ctx context.Context, id uuid.UUID) (*model.{{pascalName
|
|
|
78
86
|
|
|
79
87
|
func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
|
|
80
88
|
if err := s.repo.Delete(ctx, id); err != nil {
|
|
89
|
+
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
90
|
+
return errNotFound()
|
|
91
|
+
}
|
|
81
92
|
// deleting a {{pkg}} that's still referenced elsewhere → FK RESTRICT trips = 409, not 500
|
|
82
93
|
if dberr.IsForeignKey(err) {
|
|
83
94
|
return errHasReferences()
|
|
@@ -12,24 +12,54 @@ import (
|
|
|
12
12
|
"gorm.io/gorm"
|
|
13
13
|
)
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
// repositoryStub exposes one function per dependency operation. Tests configure
|
|
16
|
+
// only the calls they expect; an unexpected call panics instead of silently
|
|
17
|
+
// returning a shared zero value/error that can hide broken orchestration.
|
|
18
|
+
type repositoryStub struct {
|
|
19
|
+
createFn func(context.Context, *model.{{pascalName}}) error
|
|
20
|
+
findAllFn func(context.Context, int, int) ([]model.{{pascalName}}, error)
|
|
21
|
+
findByIDFn func(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
|
|
22
|
+
updateFn func(context.Context, *model.{{pascalName}}) error
|
|
23
|
+
deleteFn func(context.Context, uuid.UUID) error
|
|
24
|
+
// go-scaffold:repository-stub-fields
|
|
19
25
|
}
|
|
20
26
|
|
|
21
|
-
func (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if f.err != nil {
|
|
25
|
-
return nil, f.err
|
|
27
|
+
func (s *repositoryStub) Create(ctx context.Context, m *model.{{pascalName}}) error {
|
|
28
|
+
if s.createFn == nil {
|
|
29
|
+
panic("unexpected repository.Create call")
|
|
26
30
|
}
|
|
27
|
-
return
|
|
31
|
+
return s.createFn(ctx, m)
|
|
28
32
|
}
|
|
29
|
-
func (f *fakeRepo) Update(context.Context, *model.{{pascalName}}) error { return f.err }
|
|
30
|
-
func (f *fakeRepo) Delete(context.Context, uuid.UUID) error { return f.err }
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
func (s *repositoryStub) FindAll(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
|
|
35
|
+
if s.findAllFn == nil {
|
|
36
|
+
panic("unexpected repository.FindAll call")
|
|
37
|
+
}
|
|
38
|
+
return s.findAllFn(ctx, limit, offset)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func (s *repositoryStub) FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
42
|
+
if s.findByIDFn == nil {
|
|
43
|
+
panic("unexpected repository.FindByID call")
|
|
44
|
+
}
|
|
45
|
+
return s.findByIDFn(ctx, id)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func (s *repositoryStub) Update(ctx context.Context, m *model.{{pascalName}}) error {
|
|
49
|
+
if s.updateFn == nil {
|
|
50
|
+
panic("unexpected repository.Update call")
|
|
51
|
+
}
|
|
52
|
+
return s.updateFn(ctx, m)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func (s *repositoryStub) Delete(ctx context.Context, id uuid.UUID) error {
|
|
56
|
+
if s.deleteFn == nil {
|
|
57
|
+
panic("unexpected repository.Delete call")
|
|
58
|
+
}
|
|
59
|
+
return s.deleteFn(ctx, id)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// go-scaffold:repository-stub-methods
|
|
33
63
|
|
|
34
64
|
func status(t *testing.T, err error) int {
|
|
35
65
|
t.Helper()
|
|
@@ -41,25 +71,91 @@ func status(t *testing.T, err error) int {
|
|
|
41
71
|
}
|
|
42
72
|
|
|
43
73
|
func TestService_Create_Duplicate(t *testing.T) {
|
|
44
|
-
|
|
45
|
-
|
|
74
|
+
repo := &repositoryStub{
|
|
75
|
+
createFn: func(context.Context, *model.{{pascalName}}) error {
|
|
76
|
+
return gorm.ErrDuplicatedKey
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
svc := NewService(repo)
|
|
80
|
+
|
|
81
|
+
_, err := svc.Create(context.Background(), createInput{})
|
|
82
|
+
|
|
46
83
|
if got := status(t, err); got != 409 {
|
|
47
84
|
t.Fatalf("want 409 conflict, got %d", got)
|
|
48
85
|
}
|
|
49
86
|
}
|
|
50
87
|
|
|
51
88
|
func TestService_Get_NotFound(t *testing.T) {
|
|
52
|
-
|
|
89
|
+
repo := &repositoryStub{
|
|
90
|
+
findByIDFn: func(context.Context, uuid.UUID) (*model.{{pascalName}}, error) {
|
|
91
|
+
return nil, gorm.ErrRecordNotFound
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
svc := NewService(repo)
|
|
95
|
+
|
|
53
96
|
_, err := svc.Get(context.Background(), uuid.New())
|
|
97
|
+
|
|
54
98
|
if got := status(t, err); got != 404 {
|
|
55
99
|
t.Fatalf("want 404, got %d", got)
|
|
56
100
|
}
|
|
57
101
|
}
|
|
58
102
|
|
|
59
103
|
func TestService_List_DBError_Becomes500(t *testing.T) {
|
|
60
|
-
|
|
104
|
+
repo := &repositoryStub{
|
|
105
|
+
findAllFn: func(context.Context, int, int) ([]model.{{pascalName}}, error) {
|
|
106
|
+
return nil, errors.New("connection refused")
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
svc := NewService(repo)
|
|
110
|
+
|
|
61
111
|
_, err := svc.List(context.Background(), 20, 0)
|
|
112
|
+
|
|
62
113
|
if got := status(t, err); got != 500 {
|
|
63
114
|
t.Fatalf("want 500, got %d", got)
|
|
64
115
|
}
|
|
65
116
|
}
|
|
117
|
+
|
|
118
|
+
// The check that stops one user's save from silently erasing another's: the
|
|
119
|
+
// repository reports the row moved on, the client must see 409 rather than a
|
|
120
|
+
// 500 or, worse, a success.
|
|
121
|
+
func TestService_Update_StaleVersion(t *testing.T) {
|
|
122
|
+
repo := &repositoryStub{
|
|
123
|
+
findByIDFn: func(_ context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
124
|
+
return &model.{{pascalName}}{ID: id, Version: 7}, nil
|
|
125
|
+
},
|
|
126
|
+
updateFn: func(context.Context, *model.{{pascalName}}) error {
|
|
127
|
+
return ErrStaleVersion
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
svc := NewService(repo)
|
|
131
|
+
|
|
132
|
+
_, err := svc.Update(context.Background(), uuid.New(), updateInput{Version: 3})
|
|
133
|
+
|
|
134
|
+
if got := status(t, err); got != 409 {
|
|
135
|
+
t.Fatalf("want 409 stale, got %d", got)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The version compared against the database has to be the one the client sent,
|
|
140
|
+
// not the one just read back — comparing the row against itself would always
|
|
141
|
+
// match and the guard would never fire.
|
|
142
|
+
func TestService_Update_SendsClientVersion(t *testing.T) {
|
|
143
|
+
var seen int
|
|
144
|
+
repo := &repositoryStub{
|
|
145
|
+
findByIDFn: func(_ context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
146
|
+
return &model.{{pascalName}}{ID: id, Version: 7}, nil
|
|
147
|
+
},
|
|
148
|
+
updateFn: func(_ context.Context, m *model.{{pascalName}}) error {
|
|
149
|
+
seen = m.Version
|
|
150
|
+
return nil
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
svc := NewService(repo)
|
|
154
|
+
|
|
155
|
+
if _, err := svc.Update(context.Background(), uuid.New(), updateInput{Version: 3}); err != nil {
|
|
156
|
+
t.Fatalf("update: %v", err)
|
|
157
|
+
}
|
|
158
|
+
if seen != 3 {
|
|
159
|
+
t.Fatalf("want the client's version 3 to reach the repository, got %d", seen)
|
|
160
|
+
}
|
|
161
|
+
}
|
package/dist/commands/remove.js
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.removeModule = removeModule;
|
|
7
|
-
const path_1 = __importDefault(require("path"));
|
|
8
|
-
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
9
|
-
const picocolors_1 = __importDefault(require("picocolors"));
|
|
10
|
-
const prompts_1 = require("@inquirer/prompts");
|
|
11
|
-
const config_1 = require("../utils/config");
|
|
12
|
-
const naming_1 = require("../utils/naming");
|
|
13
|
-
const main_patcher_1 = require("../utils/main-patcher");
|
|
14
|
-
const openapi_patcher_1 = require("../utils/openapi-patcher");
|
|
15
|
-
const template_renderer_1 = require("../utils/template-renderer");
|
|
16
|
-
const generate_wizard_1 = require("../prompts/generate-wizard");
|
|
17
|
-
// removeModule is the inverse of generateModule: deletes the domain package and
|
|
18
|
-
// pulls its wiring back out of main.go / openapi.yaml / migrations, so dropping
|
|
19
|
-
// a domain is one command instead of hand-editing 3+ files (the error-prone
|
|
20
|
-
// path that produced the duplicate-registration bug in the first place).
|
|
21
|
-
async function removeModule(rawName, opts, projectDir = process.cwd()) {
|
|
22
|
-
const config = (0, config_1.readConfig)(projectDir);
|
|
23
|
-
const naming = (0, naming_1.resolveModuleNaming)(rawName ?? (await (0, generate_wizard_1.promptModuleName)()));
|
|
24
|
-
const modulePath = naming.pkg;
|
|
25
|
-
const moduleDir = path_1.default.join(projectDir, "internal", "app", modulePath);
|
|
26
|
-
if (!fs_extra_1.default.existsSync(moduleDir)) {
|
|
27
|
-
throw new Error(`module "${naming.pkg}" not found at internal/app/${modulePath} — nothing to remove`);
|
|
28
|
-
}
|
|
29
|
-
if (!opts.yes) {
|
|
30
|
-
const ok = await (0, prompts_1.confirm)({
|
|
31
|
-
message: `Remove module "${naming.pkg}"? Deletes internal/app/${modulePath}/, its migration, and un-wires main.go/openapi.yaml`,
|
|
32
|
-
default: false,
|
|
33
|
-
});
|
|
34
|
-
if (!ok)
|
|
35
|
-
throw new Error("removal cancelled");
|
|
36
|
-
}
|
|
37
|
-
// detect --auth/--permission from the generated handler.go itself (not
|
|
38
|
-
// stored anywhere else) — unpatchMainGo needs the exact same flags used at
|
|
39
|
-
// generate-time to reconstruct the identical line it's removing.
|
|
40
|
-
const handlerGoPath = path_1.default.join(moduleDir, "handler.go");
|
|
41
|
-
let auth;
|
|
42
|
-
let permission;
|
|
43
|
-
if (fs_extra_1.default.existsSync(handlerGoPath)) {
|
|
44
|
-
const handlerContent = fs_extra_1.default.readFileSync(handlerGoPath, "utf8");
|
|
45
|
-
auth = /jwtSecret\s+string/.test(handlerContent);
|
|
46
|
-
permission = handlerContent.match(/h\.authz\.Require\("([^"]+)"\)/)?.[1];
|
|
47
|
-
}
|
|
48
|
-
// 1. the domain package
|
|
49
|
-
fs_extra_1.default.removeSync(moduleDir);
|
|
50
|
-
// 2. main.go wiring
|
|
51
|
-
(0, main_patcher_1.unpatchMainGo)(path_1.default.join(projectDir, "cmd", "api", "main.go"), {
|
|
52
|
-
goModule: config.goModule,
|
|
53
|
-
modulePath,
|
|
54
|
-
pkg: naming.pkg,
|
|
55
|
-
pascalName: naming.pascalName,
|
|
56
|
-
auth,
|
|
57
|
-
permission,
|
|
58
|
-
});
|
|
59
|
-
// 3. openapi index + per-module docs
|
|
60
|
-
const openapiPath = path_1.default.join(projectDir, "docs", "openapi.yaml");
|
|
61
|
-
if (fs_extra_1.default.existsSync(openapiPath)) {
|
|
62
|
-
(0, openapi_patcher_1.unpatchOpenapiIndex)(openapiPath, naming, config.apiPrefix);
|
|
63
|
-
fs_extra_1.default.removeSync(path_1.default.join(projectDir, "docs", naming.plural));
|
|
64
|
-
}
|
|
65
|
-
// 4. migration files (up + down) — including the --permission seed
|
|
66
|
-
// migration, if this module was generated with one.
|
|
67
|
-
const migrationsDir = path_1.default.join(projectDir, "migrations");
|
|
68
|
-
const removedMigrations = [];
|
|
69
|
-
if (fs_extra_1.default.existsSync(migrationsDir)) {
|
|
70
|
-
for (const f of fs_extra_1.default.readdirSync(migrationsDir)) {
|
|
71
|
-
if (f.endsWith(`_create_${naming.plural}.up.sql`) ||
|
|
72
|
-
f.endsWith(`_create_${naming.plural}.down.sql`) ||
|
|
73
|
-
f.endsWith(`_add_${naming.plural}_permission.up.sql`) ||
|
|
74
|
-
f.endsWith(`_add_${naming.plural}_permission.down.sql`)) {
|
|
75
|
-
fs_extra_1.default.removeSync(path_1.default.join(migrationsDir, f));
|
|
76
|
-
removedMigrations.push(f);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
(0, template_renderer_1.gofmtTree)(projectDir);
|
|
81
|
-
console.log(picocolors_1.default.green(`\nremoved module "${naming.pkg}"`));
|
|
82
|
-
console.log(` deleted internal/app/${modulePath}/`);
|
|
83
|
-
console.log(` un-wired cmd/api/main.go`);
|
|
84
|
-
if (fs_extra_1.default.existsSync(openapiPath))
|
|
85
|
-
console.log(` un-wired docs/openapi.yaml + deleted docs/${naming.plural}/`);
|
|
86
|
-
if (removedMigrations.length)
|
|
87
|
-
console.log(` deleted ${removedMigrations.join(", ")}`);
|
|
88
|
-
console.log(picocolors_1.default.yellow(`\nnote: the ${naming.plural} table (if migrated) is untouched — drop it yourself, or add a down migration`));
|
|
89
|
-
}
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
package queue
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"github.com/hibiken/asynq"
|
|
5
|
-
)
|
|
6
|
-
|
|
7
|
-
// Client enqueues tasks to Redis. Created by cmd/api; each domain or platform
|
|
8
|
-
// package defines its own task types and calls Client.Enqueue.
|
|
9
|
-
type Client struct {
|
|
10
|
-
inner *asynq.Client
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
func NewClient(redisURL string) (*Client, error) {
|
|
14
|
-
opt, err := asynq.ParseRedisURI(redisURL)
|
|
15
|
-
if err != nil {
|
|
16
|
-
return nil, err
|
|
17
|
-
}
|
|
18
|
-
return &Client{inner: asynq.NewClient(opt)}, nil
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
func (c *Client) Enqueue(task *asynq.Task, opts ...asynq.Option) (string, error) {
|
|
22
|
-
info, err := c.inner.Enqueue(task, opts...)
|
|
23
|
-
if err != nil {
|
|
24
|
-
return "", err
|
|
25
|
-
}
|
|
26
|
-
return info.ID, nil
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
func (c *Client) Close() error {
|
|
30
|
-
return c.inner.Close()
|
|
31
|
-
}
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
package queue
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"context"
|
|
5
|
-
"fmt"
|
|
6
|
-
"log/slog"
|
|
7
|
-
|
|
8
|
-
"github.com/hibiken/asynq"
|
|
9
|
-
)
|
|
10
|
-
|
|
11
|
-
// Server runs the background worker. Created by cmd/worker; handlers register
|
|
12
|
-
// their task types on the mux before Start is called.
|
|
13
|
-
type Server struct {
|
|
14
|
-
inner *asynq.Server
|
|
15
|
-
mux *asynq.ServeMux
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// slogAdapter wraps *slog.Logger to implement asynq.Logger. Asynq calls these
|
|
19
|
-
// like the stdlib `log` package (a plain message, sometimes several args
|
|
20
|
-
// meant to be concatenated) — fmt.Sprint joins them into one message string
|
|
21
|
-
// rather than passing them as slog key-value pairs, which would otherwise
|
|
22
|
-
// misinterpret a lone message argument as a key with no value.
|
|
23
|
-
type slogAdapter struct {
|
|
24
|
-
inner *slog.Logger
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
func (a *slogAdapter) Debug(args ...interface{}) { a.inner.Debug(fmt.Sprint(args...)) }
|
|
28
|
-
func (a *slogAdapter) Info(args ...interface{}) { a.inner.Info(fmt.Sprint(args...)) }
|
|
29
|
-
func (a *slogAdapter) Warn(args ...interface{}) { a.inner.Warn(fmt.Sprint(args...)) }
|
|
30
|
-
func (a *slogAdapter) Error(args ...interface{}) { a.inner.Error(fmt.Sprint(args...)) }
|
|
31
|
-
func (a *slogAdapter) Fatal(args ...interface{}) { a.inner.Error(fmt.Sprint(args...)) }
|
|
32
|
-
|
|
33
|
-
func NewServer(redisURL string) (*Server, error) {
|
|
34
|
-
opt, err := asynq.ParseRedisURI(redisURL)
|
|
35
|
-
if err != nil {
|
|
36
|
-
return nil, err
|
|
37
|
-
}
|
|
38
|
-
srv := &Server{
|
|
39
|
-
inner: asynq.NewServer(opt, asynq.Config{
|
|
40
|
-
Concurrency: 10,
|
|
41
|
-
Logger: &slogAdapter{inner: slog.Default()},
|
|
42
|
-
}),
|
|
43
|
-
mux: asynq.NewServeMux(),
|
|
44
|
-
}
|
|
45
|
-
// Global error handler for tasks that panic or return a non-Retryable error.
|
|
46
|
-
srv.mux.Use(func(next asynq.Handler) asynq.Handler {
|
|
47
|
-
return asynq.HandlerFunc(func(ctx context.Context, task *asynq.Task) error {
|
|
48
|
-
err := next.ProcessTask(ctx, task)
|
|
49
|
-
if err != nil {
|
|
50
|
-
slog.Error("task failed", "type", task.Type(), "error", err)
|
|
51
|
-
}
|
|
52
|
-
return err
|
|
53
|
-
})
|
|
54
|
-
})
|
|
55
|
-
return srv, nil
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
func (s *Server) Handle(pattern string, handler asynq.Handler) {
|
|
59
|
-
s.mux.Handle(pattern, handler)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
func (s *Server) Start() error {
|
|
63
|
-
return s.inner.Start(s.mux)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
func (s *Server) Shutdown() {
|
|
67
|
-
s.inner.Shutdown()
|
|
68
|
-
}
|