@stackonward/cli 1.4.0 → 1.5.1
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/README.md +255 -0
- package/dist/index.js +14335 -603
- package/dist/index.js.map +1 -1
- package/dist/resources/deploy/ci/github/caller.yml +41 -0
- package/dist/resources/deploy/ci/onedev/import.yml +40 -0
- package/dist/resources/deploy/nginx/cache.conf +17 -0
- package/dist/resources/deploy/nginx/compression.conf +23 -0
- package/dist/resources/deploy/nginx/proxy.conf +15 -0
- package/dist/resources/deploy/profiles/go-service/compose.yaml +19 -0
- package/dist/resources/deploy/profiles/nuxt-node/Dockerfile +17 -0
- package/dist/resources/deploy/profiles/static-files/nginx/site.conf.template +55 -0
- package/dist/resources/deploy/profiles/static-nginx/Dockerfile +18 -0
- package/dist/resources/deploy/profiles/static-nginx/nginx/default.conf.template +28 -0
- package/dist/resources/deploy/targets/compose-host/deploy.config.example.json +114 -0
- package/dist/resources/deploy/traffic/nginx/stackonward-nginx-traffic +96 -0
- package/dist/resources/registry.json +30 -30
- package/dist/resources/templates/base/.claude/docs/architecture.md +4 -0
- package/dist/resources/templates/base/AGENTS.md +15 -0
- package/dist/resources/templates/base/CLAUDE.md +15 -0
- package/dist/resources/templates/go-backend/.dockerignore +25 -3
- package/dist/resources/templates/go-backend/deploy.config.json +26 -0
- package/dist/resources/templates/go-backend/docs/runtime-provenance.json +9 -6
- package/dist/resources/templates/go-backend/docs/source-provenance.json +33 -22
- package/dist/resources/templates/go-backend/internal/common/enums/register_locale.go +7 -0
- package/dist/resources/templates/go-backend/internal/common/enums/register_locale_test.go +30 -0
- package/dist/resources/templates/go-backend/internal/common/locale/locale.go +102 -76
- package/dist/resources/templates/go-backend/internal/common/locale/locale_test.go +168 -45
- package/dist/resources/templates/go-backend/internal/core/commandreplay/model.go +5 -5
- package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence.go +20 -7
- package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence_test.go +51 -22
- package/dist/resources/templates/go-backend/internal/core/commandreplay/repository.go +2 -2
- package/dist/resources/templates/go-backend/internal/core/commandreplay/service.go +13 -24
- package/dist/resources/templates/go-backend/internal/core/commandreplay/service_persistence_test.go +98 -13
- package/dist/resources/templates/go-backend/internal/core/commandreplay/service_test.go +61 -36
- package/dist/resources/templates/go-backend/internal/core/commandreplay/types.go +6 -6
- package/dist/resources/templates/go-backend/internal/core/integrationevent/config.go +24 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/errors.go +53 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/model.go +71 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence.go +193 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_postgres_test.go +178 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_test.go +168 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/provider.go +16 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/registry.go +69 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/repository.go +17 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/service.go +283 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/service_test.go +433 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/types.go +67 -0
- package/dist/resources/templates/go-backend/migrations/000001_infrastructure.down.sql +6 -0
- package/dist/resources/templates/go-backend/migrations/000001_infrastructure.up.sql +176 -16
- package/dist/resources/templates/go-backend/tests/infrastructure.sql +109 -6
- package/dist/resources/templates/nuxt/.claude/docs/stack.md +5 -1
- package/dist/resources/templates/nuxt/.dockerignore +19 -0
- package/dist/resources/templates/nuxt/deploy.config.json +27 -0
- package/dist/resources/templates/nuxt/package.json +8 -8
- package/package.json +5 -3
|
@@ -22,17 +22,21 @@ func TestCommandReplayRepositoryCreateAndFindByScope(t *testing.T) {
|
|
|
22
22
|
repo := NewCommandReplayRepository(db)
|
|
23
23
|
ctx := context.Background()
|
|
24
24
|
|
|
25
|
-
record := newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:create", "idem-1", time.Now().Add(time.Hour))
|
|
25
|
+
record := newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:create", "idem-1", time.Now().Add(time.Hour))
|
|
26
26
|
require.NoError(t, repo.Create(ctx, record))
|
|
27
27
|
|
|
28
|
-
found, err := repo.FindByScope(ctx, " test.command.checkout ", " quote:create ", " idem-1 ")
|
|
28
|
+
found, err := repo.FindByScope(ctx, " test.command.checkout ", " identity ", " user_123 ", " quote:create ", " idem-1 ")
|
|
29
29
|
require.NoError(t, err)
|
|
30
30
|
require.NotNil(t, found)
|
|
31
31
|
require.Equal(t, "test.command.checkout", found.Namespace)
|
|
32
32
|
require.Equal(t, "quote:create", found.CommandScope)
|
|
33
33
|
require.Equal(t, "idem-1", found.IdempotencyKey)
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
wrongPrincipal, err := repo.FindByScope(ctx, "test.command.checkout", "identity", "user_456", "quote:create", "idem-1")
|
|
36
|
+
require.NoError(t, err)
|
|
37
|
+
require.Nil(t, wrongPrincipal)
|
|
38
|
+
|
|
39
|
+
missing, err := repo.FindByScope(ctx, "test.command.checkout", "identity", "user_123", "quote:create", "idem-missing")
|
|
36
40
|
require.NoError(t, err)
|
|
37
41
|
require.Nil(t, missing)
|
|
38
42
|
}
|
|
@@ -43,41 +47,64 @@ func TestCommandReplayRepositoryDuplicateCreateReturnsSentinel(t *testing.T) {
|
|
|
43
47
|
ctx := context.Background()
|
|
44
48
|
expiresAt := time.Now().Add(time.Hour)
|
|
45
49
|
|
|
46
|
-
require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:create", "idem-1", expiresAt)))
|
|
47
|
-
err := repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:create", "idem-1", expiresAt))
|
|
50
|
+
require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:create", "idem-1", expiresAt)))
|
|
51
|
+
err := repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:create", "idem-1", expiresAt))
|
|
48
52
|
require.Error(t, err)
|
|
49
53
|
require.True(t, errors.Is(err, ErrCommandReplayRecordExists))
|
|
50
54
|
|
|
51
|
-
require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:create", "idem-
|
|
52
|
-
require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:
|
|
53
|
-
require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.
|
|
54
|
-
require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("
|
|
55
|
+
require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_456", "quote:create", "idem-1", expiresAt)))
|
|
56
|
+
require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "service", "worker_123", "quote:create", "idem-1", expiresAt)))
|
|
57
|
+
require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:create", "idem-2", expiresAt)))
|
|
58
|
+
require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:update", "idem-1", expiresAt)))
|
|
59
|
+
require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.fulfillment", "identity", "user_123", "quote:create", "idem-1", expiresAt)))
|
|
60
|
+
require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("other.command.checkout", "identity", "user_123", "quote:create", "idem-1", expiresAt)))
|
|
55
61
|
var total int64
|
|
56
62
|
require.NoError(t, db.Model(&CommandReplayRecord{}).Count(&total).Error)
|
|
57
|
-
require.Equal(t, int64(
|
|
63
|
+
require.Equal(t, int64(7), total)
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
func TestCommandReplayRepositoryTryAcquireScopeLockRejectsSQLite(t *testing.T) {
|
|
61
67
|
db := newCommandReplayTestDB(t)
|
|
62
68
|
repo := NewCommandReplayRepository(db)
|
|
63
69
|
|
|
64
|
-
acquired, err := repo.TryAcquireScopeLock(context.Background(), "test.command.checkout", "quote:create", "idem-1")
|
|
70
|
+
acquired, err := repo.TryAcquireScopeLock(context.Background(), "test.command.checkout", "identity", "user_123", "quote:create", "idem-1")
|
|
65
71
|
require.Error(t, err)
|
|
66
72
|
require.False(t, acquired)
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
func TestCommandReplayScopeKeyPreservesFiveFieldBoundaries(t *testing.T) {
|
|
76
|
+
base := commandReplayScopeKey(" test.command.checkout ", " identity ", " user_123 ", " quote:create ", " idem-1 ")
|
|
77
|
+
require.Equal(t, commandReplayScopeKey("test.command.checkout", "identity", "user_123", "quote:create", "idem-1"), base)
|
|
78
|
+
|
|
79
|
+
variants := []string{
|
|
80
|
+
commandReplayScopeKey("other.command.checkout", "identity", "user_123", "quote:create", "idem-1"),
|
|
81
|
+
commandReplayScopeKey("test.command.checkout", "service", "user_123", "quote:create", "idem-1"),
|
|
82
|
+
commandReplayScopeKey("test.command.checkout", "identity", "user_456", "quote:create", "idem-1"),
|
|
83
|
+
commandReplayScopeKey("test.command.checkout", "identity", "user_123", "quote:update", "idem-1"),
|
|
84
|
+
commandReplayScopeKey("test.command.checkout", "identity", "user_123", "quote:create", "idem-2"),
|
|
85
|
+
}
|
|
86
|
+
for _, variant := range variants {
|
|
87
|
+
require.NotEqual(t, base, variant)
|
|
88
|
+
}
|
|
89
|
+
require.NotEqual(
|
|
90
|
+
t,
|
|
91
|
+
commandReplayScopeKey("test.command.checkout", "ab", "c", "quote:create", "idem-1"),
|
|
92
|
+
commandReplayScopeKey("test.command.checkout", "a", "bc", "quote:create", "idem-1"),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
69
96
|
func TestCommandReplayRepositoryMarkTerminalStates(t *testing.T) {
|
|
70
97
|
db := newCommandReplayTestDB(t)
|
|
71
98
|
repo := NewCommandReplayRepository(db)
|
|
72
99
|
ctx := context.Background()
|
|
73
100
|
expiresAt := time.Now().Add(time.Hour)
|
|
74
101
|
|
|
75
|
-
succeededRecord := newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:create", "idem-success", expiresAt)
|
|
102
|
+
succeededRecord := newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:create", "idem-success", expiresAt)
|
|
76
103
|
require.NoError(t, repo.Create(ctx, succeededRecord))
|
|
77
104
|
succeededAt := time.Now().Add(time.Minute)
|
|
78
105
|
require.NoError(t, repo.MarkSucceeded(ctx, succeededRecord.ID, json.RawMessage(`{"quote_no":"quote_1"}`), succeededAt))
|
|
79
106
|
|
|
80
|
-
loaded, err := repo.FindByScope(ctx, "test.command.checkout", "quote:create", "idem-success")
|
|
107
|
+
loaded, err := repo.FindByScope(ctx, "test.command.checkout", "identity", "user_123", "quote:create", "idem-success")
|
|
81
108
|
require.NoError(t, err)
|
|
82
109
|
require.NotNil(t, loaded)
|
|
83
110
|
require.Equal(t, CommandReplayStatusSucceeded, loaded.Status)
|
|
@@ -88,12 +115,12 @@ func TestCommandReplayRepositoryMarkTerminalStates(t *testing.T) {
|
|
|
88
115
|
err = repo.MarkFailed(ctx, succeededRecord.ID, "COMMERCE_QUOTE_FAILED", http.StatusConflict, json.RawMessage(`{"error":"failed"}`), time.Now().Add(2*time.Minute))
|
|
89
116
|
require.Error(t, err)
|
|
90
117
|
|
|
91
|
-
failedRecord := newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:create", "idem-failed", expiresAt)
|
|
118
|
+
failedRecord := newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:create", "idem-failed", expiresAt)
|
|
92
119
|
require.NoError(t, repo.Create(ctx, failedRecord))
|
|
93
120
|
failedAt := time.Now().Add(3 * time.Minute)
|
|
94
121
|
require.NoError(t, repo.MarkFailed(ctx, failedRecord.ID, " COMMERCE_QUOTE_FAILED ", http.StatusConflict, json.RawMessage(`{"error":"failed"}`), failedAt))
|
|
95
122
|
|
|
96
|
-
loaded, err = repo.FindByScope(ctx, "test.command.checkout", "quote:create", "idem-failed")
|
|
123
|
+
loaded, err = repo.FindByScope(ctx, "test.command.checkout", "identity", "user_123", "quote:create", "idem-failed")
|
|
97
124
|
require.NoError(t, err)
|
|
98
125
|
require.NotNil(t, loaded)
|
|
99
126
|
require.Equal(t, CommandReplayStatusFailed, loaded.Status)
|
|
@@ -116,13 +143,13 @@ func TestCommandReplayRepositoryJoinsAmbientTransaction(t *testing.T) {
|
|
|
116
143
|
rollbackErr := errors.New("rollback command idempotency transaction")
|
|
117
144
|
|
|
118
145
|
err := manager.WithTransaction(ctx, func(txCtx context.Context) error {
|
|
119
|
-
record := newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:create", "idem-rollback", time.Now().Add(time.Hour))
|
|
146
|
+
record := newPersistenceTestCommandReplayRecord("test.command.checkout", "identity", "user_123", "quote:create", "idem-rollback", time.Now().Add(time.Hour))
|
|
120
147
|
require.NoError(t, repo.Create(txCtx, record))
|
|
121
148
|
return rollbackErr
|
|
122
149
|
})
|
|
123
150
|
require.ErrorIs(t, err, rollbackErr)
|
|
124
151
|
|
|
125
|
-
found, err := repo.FindByScope(ctx, "test.command.checkout", "quote:create", "idem-rollback")
|
|
152
|
+
found, err := repo.FindByScope(ctx, "test.command.checkout", "identity", "user_123", "quote:create", "idem-rollback")
|
|
126
153
|
require.NoError(t, err)
|
|
127
154
|
require.Nil(t, found)
|
|
128
155
|
}
|
|
@@ -147,8 +174,8 @@ func createCommandReplayRecordSchema(t *testing.T, db *gorm.DB) {
|
|
|
147
174
|
command_scope TEXT NOT NULL,
|
|
148
175
|
idempotency_key TEXT NOT NULL,
|
|
149
176
|
request_hash TEXT NOT NULL,
|
|
150
|
-
principal_type TEXT,
|
|
151
|
-
principal_ref TEXT,
|
|
177
|
+
principal_type TEXT NOT NULL,
|
|
178
|
+
principal_ref TEXT NOT NULL,
|
|
152
179
|
status TEXT NOT NULL DEFAULT 'started',
|
|
153
180
|
response_payload TEXT NOT NULL DEFAULT '{}',
|
|
154
181
|
error_code TEXT,
|
|
@@ -166,14 +193,16 @@ func createCommandReplayRecordSchema(t *testing.T, db *gorm.DB) {
|
|
|
166
193
|
)
|
|
167
194
|
`).Error)
|
|
168
195
|
require.NoError(t, db.Exec(`
|
|
169
|
-
CREATE UNIQUE INDEX
|
|
170
|
-
ON sys_command_replay_records (namespace, command_scope, idempotency_key)
|
|
196
|
+
CREATE UNIQUE INDEX uk_sys_command_replay_records_principal_scope_key
|
|
197
|
+
ON sys_command_replay_records (namespace, principal_type, principal_ref, command_scope, idempotency_key)
|
|
171
198
|
`).Error)
|
|
172
199
|
}
|
|
173
200
|
|
|
174
|
-
func newPersistenceTestCommandReplayRecord(namespace, commandScope, idempotencyKey string, expiresAt time.Time) *CommandReplayRecord {
|
|
201
|
+
func newPersistenceTestCommandReplayRecord(namespace, principalType, principalRef, commandScope, idempotencyKey string, expiresAt time.Time) *CommandReplayRecord {
|
|
175
202
|
return &CommandReplayRecord{
|
|
176
203
|
Namespace: namespace,
|
|
204
|
+
PrincipalType: principalType,
|
|
205
|
+
PrincipalRef: principalRef,
|
|
177
206
|
CommandScope: commandScope,
|
|
178
207
|
IdempotencyKey: idempotencyKey,
|
|
179
208
|
RequestHash: strings.Repeat("a", 64),
|
|
@@ -8,8 +8,8 @@ import (
|
|
|
8
8
|
|
|
9
9
|
// CommandReplayRepository 命令幂等回放仓储接口。
|
|
10
10
|
type CommandReplayRepository interface {
|
|
11
|
-
FindByScope(ctx context.Context, namespace, commandScope, idempotencyKey string) (*CommandReplayRecord, error)
|
|
12
|
-
TryAcquireScopeLock(ctx context.Context, namespace, commandScope, idempotencyKey string) (bool, error)
|
|
11
|
+
FindByScope(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (*CommandReplayRecord, error)
|
|
12
|
+
TryAcquireScopeLock(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (bool, error)
|
|
13
13
|
Create(ctx context.Context, record *CommandReplayRecord) error
|
|
14
14
|
MarkSucceeded(ctx context.Context, id int64, responsePayload json.RawMessage, completedAt time.Time) error
|
|
15
15
|
MarkFailed(ctx context.Context, id int64, errorCode string, errorStatus int, responsePayload json.RawMessage, completedAt time.Time) error
|
|
@@ -74,6 +74,8 @@ func (s *CommandReplayService) Execute(ctx context.Context, req CommandReplayBeg
|
|
|
74
74
|
acquired, lockErr := s.repo.TryAcquireScopeLock(
|
|
75
75
|
txCtx,
|
|
76
76
|
normalized.namespace,
|
|
77
|
+
normalized.principalType,
|
|
78
|
+
normalized.principalRef,
|
|
77
79
|
normalized.commandScope,
|
|
78
80
|
normalized.idempotencyKey,
|
|
79
81
|
)
|
|
@@ -87,6 +89,8 @@ func (s *CommandReplayService) Execute(ctx context.Context, req CommandReplayBeg
|
|
|
87
89
|
existing, findErr := s.repo.FindByScope(
|
|
88
90
|
txCtx,
|
|
89
91
|
normalized.namespace,
|
|
92
|
+
normalized.principalType,
|
|
93
|
+
normalized.principalRef,
|
|
90
94
|
normalized.commandScope,
|
|
91
95
|
normalized.idempotencyKey,
|
|
92
96
|
)
|
|
@@ -112,8 +116,8 @@ func (s *CommandReplayService) Execute(ctx context.Context, req CommandReplayBeg
|
|
|
112
116
|
CommandScope: normalized.commandScope,
|
|
113
117
|
IdempotencyKey: normalized.idempotencyKey,
|
|
114
118
|
RequestHash: normalized.requestHash,
|
|
115
|
-
PrincipalType:
|
|
116
|
-
PrincipalRef:
|
|
119
|
+
PrincipalType: normalized.principalType,
|
|
120
|
+
PrincipalRef: normalized.principalRef,
|
|
117
121
|
Status: CommandReplayStatusStarted,
|
|
118
122
|
ResponsePayload: json.RawMessage(`{}`),
|
|
119
123
|
ExpiresAt: expiresAt,
|
|
@@ -126,6 +130,8 @@ func (s *CommandReplayService) Execute(ctx context.Context, req CommandReplayBeg
|
|
|
126
130
|
duplicate, reloadErr := s.repo.FindByScope(
|
|
127
131
|
txCtx,
|
|
128
132
|
normalized.namespace,
|
|
133
|
+
normalized.principalType,
|
|
134
|
+
normalized.principalRef,
|
|
129
135
|
normalized.commandScope,
|
|
130
136
|
normalized.idempotencyKey,
|
|
131
137
|
)
|
|
@@ -219,14 +225,14 @@ func (s *CommandReplayService) BeginExternal(
|
|
|
219
225
|
var lease *CommandReplayExternalLease
|
|
220
226
|
err = s.withTransaction(ctx, func(txCtx context.Context) error {
|
|
221
227
|
now := s.now().UTC()
|
|
222
|
-
acquired, lockErr := s.repo.TryAcquireScopeLock(txCtx, normalized.namespace, normalized.commandScope, normalized.idempotencyKey)
|
|
228
|
+
acquired, lockErr := s.repo.TryAcquireScopeLock(txCtx, normalized.namespace, normalized.principalType, normalized.principalRef, normalized.commandScope, normalized.idempotencyKey)
|
|
223
229
|
if lockErr != nil {
|
|
224
230
|
return apierror.Internal("Failed to acquire command replay scope lock")
|
|
225
231
|
}
|
|
226
232
|
if !acquired {
|
|
227
233
|
return apierror.Conflict(errCodeCommandReplayInProgress, "Command replay is already in progress")
|
|
228
234
|
}
|
|
229
|
-
existing, findErr := s.repo.FindByScope(txCtx, normalized.namespace, normalized.commandScope, normalized.idempotencyKey)
|
|
235
|
+
existing, findErr := s.repo.FindByScope(txCtx, normalized.namespace, normalized.principalType, normalized.principalRef, normalized.commandScope, normalized.idempotencyKey)
|
|
230
236
|
if findErr != nil {
|
|
231
237
|
return apierror.Internal("Failed to load command replay record")
|
|
232
238
|
}
|
|
@@ -514,7 +520,7 @@ func newCommandReplayRecord(normalized *normalizedCommandReplayBeginRequest, now
|
|
|
514
520
|
return &CommandReplayRecord{
|
|
515
521
|
Namespace: normalized.namespace, CommandScope: normalized.commandScope,
|
|
516
522
|
IdempotencyKey: normalized.idempotencyKey, RequestHash: normalized.requestHash,
|
|
517
|
-
PrincipalType:
|
|
523
|
+
PrincipalType: normalized.principalType, PrincipalRef: normalized.principalRef,
|
|
518
524
|
Status: CommandReplayStatusStarted, ResponsePayload: json.RawMessage(`{}`),
|
|
519
525
|
ExpiresAt: now.Add(normalized.ttl), Metadata: normalized.metadata,
|
|
520
526
|
}
|
|
@@ -609,17 +615,14 @@ func normalizeCommandReplayBeginRequest(req CommandReplayBeginRequest) (*normali
|
|
|
609
615
|
if err != nil {
|
|
610
616
|
return nil, err
|
|
611
617
|
}
|
|
612
|
-
principalType, err :=
|
|
618
|
+
principalType, err := normalizeRequiredString("principalType", req.PrincipalType, maxPrincipalTypeLength)
|
|
613
619
|
if err != nil {
|
|
614
620
|
return nil, err
|
|
615
621
|
}
|
|
616
|
-
principalRef, err :=
|
|
622
|
+
principalRef, err := normalizeRequiredString("principalRef", req.PrincipalRef, maxPrincipalRefLength)
|
|
617
623
|
if err != nil {
|
|
618
624
|
return nil, err
|
|
619
625
|
}
|
|
620
|
-
if (principalType == nil) != (principalRef == nil) {
|
|
621
|
-
return nil, apierror.BadRequest(apierror.CodeValidationFailed, "principalType and principalRef must be provided together")
|
|
622
|
-
}
|
|
623
626
|
ttl := req.TTL
|
|
624
627
|
if ttl <= 0 {
|
|
625
628
|
ttl = defaultCommandReplayTTL
|
|
@@ -789,20 +792,6 @@ func normalizeRequiredString(fieldName, value string, maxLength int) (string, er
|
|
|
789
792
|
return normalized, nil
|
|
790
793
|
}
|
|
791
794
|
|
|
792
|
-
func normalizeOptionalString(fieldName string, value *string, maxLength int) (*string, error) {
|
|
793
|
-
if value == nil {
|
|
794
|
-
return nil, nil
|
|
795
|
-
}
|
|
796
|
-
normalized := strings.TrimSpace(*value)
|
|
797
|
-
if normalized == "" {
|
|
798
|
-
return nil, apierror.BadRequest(apierror.CodeValidationFailed, fieldName+" must not be blank")
|
|
799
|
-
}
|
|
800
|
-
if utf8.RuneCountInString(normalized) > maxLength {
|
|
801
|
-
return nil, apierror.BadRequest(apierror.CodeValidationFailed, fieldName+" is too long")
|
|
802
|
-
}
|
|
803
|
-
return &normalized, nil
|
|
804
|
-
}
|
|
805
|
-
|
|
806
795
|
func (s *CommandReplayService) markSucceeded(ctx context.Context, recordID int64, responsePayload json.RawMessage, completedAt time.Time) error {
|
|
807
796
|
if markErr := s.repo.MarkSucceeded(ctx, recordID, responsePayload, completedAt); markErr != nil {
|
|
808
797
|
return apierror.Internal("Failed to mark command replay record as succeeded")
|
package/dist/resources/templates/go-backend/internal/core/commandreplay/service_persistence_test.go
CHANGED
|
@@ -46,7 +46,7 @@ func TestCommandReplayExecuteStandaloneCommitsBusinessFailure(t *testing.T) {
|
|
|
46
46
|
t.Fatalf("Execute() result = %#v, want failed result", result)
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
stored, err := repo.FindByScope(ctx, "test.command.replay", "quote:123", "key-123")
|
|
49
|
+
stored, err := repo.FindByScope(ctx, "test.command.replay", "identity", "user_123", "quote:123", "key-123")
|
|
50
50
|
if err != nil {
|
|
51
51
|
t.Fatalf("FindByScope() error = %v", err)
|
|
52
52
|
}
|
|
@@ -101,7 +101,7 @@ func TestCommandReplayExecuteStandaloneRollsBackInfrastructureError(t *testing.T
|
|
|
101
101
|
t.Fatalf("Execute() error = %v, want infraErr", err)
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
stored, err := repo.FindByScope(ctx, "test.command.replay", "quote:123", "key-123")
|
|
104
|
+
stored, err := repo.FindByScope(ctx, "test.command.replay", "identity", "user_123", "quote:123", "key-123")
|
|
105
105
|
if err != nil {
|
|
106
106
|
t.Fatalf("FindByScope() error = %v", err)
|
|
107
107
|
}
|
|
@@ -147,7 +147,7 @@ func TestCommandReplayExecuteJoinsOuterTransactionRollback(t *testing.T) {
|
|
|
147
147
|
t.Fatalf("WithTransaction() error = %v, want rollbackErr", err)
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
stored, err := repo.FindByScope(ctx, "test.command.replay", "quote:123", "key-123")
|
|
150
|
+
stored, err := repo.FindByScope(ctx, "test.command.replay", "identity", "user_123", "quote:123", "key-123")
|
|
151
151
|
if err != nil {
|
|
152
152
|
t.Fatalf("FindByScope() error = %v", err)
|
|
153
153
|
}
|
|
@@ -214,7 +214,7 @@ func TestCommandReplayExecuteRejectsConcurrentDuplicateOnPostgres(t *testing.T)
|
|
|
214
214
|
t.Fatalf("business executions = %d, want 1", executions.Load())
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
stored, err := repo.FindByScope(ctx, "test.command.replay", "quote:123", "key-123")
|
|
217
|
+
stored, err := repo.FindByScope(ctx, "test.command.replay", "identity", "user_123", "quote:123", "key-123")
|
|
218
218
|
if err != nil {
|
|
219
219
|
t.Fatalf("FindByScope() error = %v", err)
|
|
220
220
|
}
|
|
@@ -223,6 +223,89 @@ func TestCommandReplayExecuteRejectsConcurrentDuplicateOnPostgres(t *testing.T)
|
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
func TestCommandReplayExecuteAllowsConcurrentSameKeyForDifferentPrincipalsOnPostgres(t *testing.T) {
|
|
227
|
+
db := openPersistentCommandReplayPostgres(t)
|
|
228
|
+
repo := commandreplay.NewCommandReplayRepository(db)
|
|
229
|
+
service := commandreplay.NewCommandReplayService(repo, database.NewTransactionManager(db))
|
|
230
|
+
ctx := context.Background()
|
|
231
|
+
firstStarted := make(chan struct{})
|
|
232
|
+
releaseFirst := make(chan struct{})
|
|
233
|
+
firstDone := make(chan error, 1)
|
|
234
|
+
secondDone := make(chan error, 1)
|
|
235
|
+
releasedFirst := false
|
|
236
|
+
defer func() {
|
|
237
|
+
if !releasedFirst {
|
|
238
|
+
close(releaseFirst)
|
|
239
|
+
}
|
|
240
|
+
}()
|
|
241
|
+
var executions atomic.Int32
|
|
242
|
+
|
|
243
|
+
go func() {
|
|
244
|
+
_, err := service.Execute(ctx, persistentCommandReplayRequest(json.RawMessage(`{"amount":100}`)), func(context.Context, *commandreplay.CommandReplayRecord) (*commandreplay.CommandReplayCommandResult, error) {
|
|
245
|
+
executions.Add(1)
|
|
246
|
+
close(firstStarted)
|
|
247
|
+
<-releaseFirst
|
|
248
|
+
return &commandreplay.CommandReplayCommandResult{
|
|
249
|
+
Status: commandreplay.CommandReplayStatusSucceeded,
|
|
250
|
+
ResponsePayload: json.RawMessage(`{"status":"first"}`),
|
|
251
|
+
}, nil
|
|
252
|
+
})
|
|
253
|
+
firstDone <- err
|
|
254
|
+
}()
|
|
255
|
+
|
|
256
|
+
select {
|
|
257
|
+
case <-firstStarted:
|
|
258
|
+
case <-time.After(5 * time.Second):
|
|
259
|
+
t.Fatal("first principal did not enter its transaction")
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
secondRequest := persistentCommandReplayRequest(json.RawMessage(`{"amount":100}`))
|
|
263
|
+
secondRequest.PrincipalRef = "user_456"
|
|
264
|
+
go func() {
|
|
265
|
+
_, err := service.Execute(ctx, secondRequest, func(context.Context, *commandreplay.CommandReplayRecord) (*commandreplay.CommandReplayCommandResult, error) {
|
|
266
|
+
executions.Add(1)
|
|
267
|
+
return &commandreplay.CommandReplayCommandResult{
|
|
268
|
+
Status: commandreplay.CommandReplayStatusSucceeded,
|
|
269
|
+
ResponsePayload: json.RawMessage(`{"status":"second"}`),
|
|
270
|
+
}, nil
|
|
271
|
+
})
|
|
272
|
+
secondDone <- err
|
|
273
|
+
}()
|
|
274
|
+
|
|
275
|
+
select {
|
|
276
|
+
case err := <-secondDone:
|
|
277
|
+
if err != nil {
|
|
278
|
+
t.Fatalf("second principal Execute() error = %v", err)
|
|
279
|
+
}
|
|
280
|
+
case <-time.After(5 * time.Second):
|
|
281
|
+
t.Fatal("second principal was blocked by another principal's scope lock")
|
|
282
|
+
}
|
|
283
|
+
close(releaseFirst)
|
|
284
|
+
releasedFirst = true
|
|
285
|
+
|
|
286
|
+
select {
|
|
287
|
+
case err := <-firstDone:
|
|
288
|
+
if err != nil {
|
|
289
|
+
t.Fatalf("first principal Execute() error = %v", err)
|
|
290
|
+
}
|
|
291
|
+
case <-time.After(5 * time.Second):
|
|
292
|
+
t.Fatal("first principal did not complete")
|
|
293
|
+
}
|
|
294
|
+
if executions.Load() != 2 {
|
|
295
|
+
t.Fatalf("business executions = %d, want 2", executions.Load())
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for _, principalRef := range []string{"user_123", "user_456"} {
|
|
299
|
+
stored, err := repo.FindByScope(ctx, "test.command.replay", "identity", principalRef, "quote:123", "key-123")
|
|
300
|
+
if err != nil {
|
|
301
|
+
t.Fatalf("FindByScope(%s) error = %v", principalRef, err)
|
|
302
|
+
}
|
|
303
|
+
if stored == nil || stored.Status != commandreplay.CommandReplayStatusSucceeded {
|
|
304
|
+
t.Fatalf("stored record for %s = %#v, want succeeded", principalRef, stored)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
226
309
|
func newPersistentCommandReplayService(t *testing.T) (*commandreplay.CommandReplayService, commandreplay.CommandReplayRepository) {
|
|
227
310
|
t.Helper()
|
|
228
311
|
service, repo, _, _ := newPersistentCommandReplayFixture(t)
|
|
@@ -248,13 +331,15 @@ type sqliteCommandReplayRepository struct {
|
|
|
248
331
|
commandreplay.CommandReplayRepository
|
|
249
332
|
}
|
|
250
333
|
|
|
251
|
-
func (r sqliteCommandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, commandScope, idempotencyKey string) (bool, error) {
|
|
334
|
+
func (r sqliteCommandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (bool, error) {
|
|
252
335
|
return true, nil
|
|
253
336
|
}
|
|
254
337
|
|
|
255
338
|
func persistentCommandReplayRequest(payload json.RawMessage) commandreplay.CommandReplayBeginRequest {
|
|
256
339
|
return commandreplay.CommandReplayBeginRequest{
|
|
257
340
|
Namespace: "test.command.replay",
|
|
341
|
+
PrincipalType: "identity",
|
|
342
|
+
PrincipalRef: "user_123",
|
|
258
343
|
CommandScope: "quote:123",
|
|
259
344
|
IdempotencyKey: "key-123",
|
|
260
345
|
RequestPayload: payload,
|
|
@@ -275,8 +360,8 @@ func createPersistentCommandReplayRecordSchema(t *testing.T, db *gorm.DB) {
|
|
|
275
360
|
command_scope TEXT NOT NULL,
|
|
276
361
|
idempotency_key TEXT NOT NULL,
|
|
277
362
|
request_hash TEXT NOT NULL,
|
|
278
|
-
principal_type TEXT,
|
|
279
|
-
principal_ref TEXT,
|
|
363
|
+
principal_type TEXT NOT NULL,
|
|
364
|
+
principal_ref TEXT NOT NULL,
|
|
280
365
|
status TEXT NOT NULL DEFAULT 'started',
|
|
281
366
|
response_payload TEXT NOT NULL DEFAULT '{}',
|
|
282
367
|
error_code TEXT,
|
|
@@ -296,8 +381,8 @@ func createPersistentCommandReplayRecordSchema(t *testing.T, db *gorm.DB) {
|
|
|
296
381
|
t.Fatalf("create command replay schema: %v", err)
|
|
297
382
|
}
|
|
298
383
|
if err := db.Exec(`
|
|
299
|
-
CREATE UNIQUE INDEX
|
|
300
|
-
ON sys_command_replay_records (namespace, command_scope, idempotency_key)
|
|
384
|
+
CREATE UNIQUE INDEX uk_sys_command_replay_records_principal_scope_key
|
|
385
|
+
ON sys_command_replay_records (namespace, principal_type, principal_ref, command_scope, idempotency_key)
|
|
301
386
|
`).Error; err != nil {
|
|
302
387
|
t.Fatalf("create command replay unique index: %v", err)
|
|
303
388
|
}
|
|
@@ -381,8 +466,8 @@ func createPersistentCommandReplayPostgresSchema(t *testing.T, db *gorm.DB) {
|
|
|
381
466
|
command_scope VARCHAR(128) NOT NULL,
|
|
382
467
|
idempotency_key VARCHAR(191) NOT NULL,
|
|
383
468
|
request_hash CHAR(64) NOT NULL,
|
|
384
|
-
principal_type VARCHAR(32),
|
|
385
|
-
principal_ref VARCHAR(64),
|
|
469
|
+
principal_type VARCHAR(32) NOT NULL,
|
|
470
|
+
principal_ref VARCHAR(64) NOT NULL,
|
|
386
471
|
status VARCHAR(20) NOT NULL DEFAULT 'started',
|
|
387
472
|
response_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
388
473
|
error_code VARCHAR(120),
|
|
@@ -397,8 +482,8 @@ func createPersistentCommandReplayPostgresSchema(t *testing.T, db *gorm.DB) {
|
|
|
397
482
|
external_attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
398
483
|
external_last_error_code VARCHAR(120),
|
|
399
484
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
400
|
-
CONSTRAINT
|
|
401
|
-
UNIQUE (namespace, command_scope, idempotency_key)
|
|
485
|
+
CONSTRAINT uk_sys_command_replay_records_principal_scope_key
|
|
486
|
+
UNIQUE (namespace, principal_type, principal_ref, command_scope, idempotency_key)
|
|
402
487
|
)
|
|
403
488
|
`).Error; err != nil {
|
|
404
489
|
t.Fatalf("create PostgreSQL command replay schema: %v", err)
|