@stackonward/cli 1.4.0 → 1.5.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/README.md +248 -0
- package/dist/index.js +14093 -603
- package/dist/index.js.map +1 -1
- package/dist/resources/deploy/ci/github/caller.yml +38 -0
- package/dist/resources/deploy/ci/onedev/import.yml +14 -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
|
@@ -3,73 +3,196 @@ package locale
|
|
|
3
3
|
import (
|
|
4
4
|
"encoding/json"
|
|
5
5
|
"reflect"
|
|
6
|
+
"strings"
|
|
6
7
|
"testing"
|
|
7
8
|
)
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
10
|
+
var expectedMappings = []Mapping{
|
|
11
|
+
{ExternalTag: "en", ShortCode: "en", Direction: DirectionLTR},
|
|
12
|
+
{ExternalTag: "zh-Hans", ShortCode: "cn", Direction: DirectionLTR},
|
|
13
|
+
{ExternalTag: "zh-Hant", ShortCode: "tw", Direction: DirectionLTR},
|
|
14
|
+
{ExternalTag: "ja", ShortCode: "ja", Direction: DirectionLTR},
|
|
15
|
+
{ExternalTag: "ko", ShortCode: "ko", Direction: DirectionLTR},
|
|
16
|
+
{ExternalTag: "es", ShortCode: "es", Direction: DirectionLTR},
|
|
17
|
+
{ExternalTag: "fr", ShortCode: "fr", Direction: DirectionLTR},
|
|
18
|
+
{ExternalTag: "de", ShortCode: "de", Direction: DirectionLTR},
|
|
19
|
+
{ExternalTag: "pt-BR", ShortCode: "pt", Direction: DirectionLTR},
|
|
20
|
+
{ExternalTag: "it", ShortCode: "it", Direction: DirectionLTR},
|
|
21
|
+
{ExternalTag: "ru", ShortCode: "ru", Direction: DirectionLTR},
|
|
22
|
+
{ExternalTag: "ar", ShortCode: "ar", Direction: DirectionRTL},
|
|
23
|
+
{ExternalTag: "hi", ShortCode: "hi", Direction: DirectionLTR},
|
|
24
|
+
{ExternalTag: "id", ShortCode: "id", Direction: DirectionLTR},
|
|
25
|
+
{ExternalTag: "th", ShortCode: "th", Direction: DirectionLTR},
|
|
26
|
+
{ExternalTag: "vi", ShortCode: "vi", Direction: DirectionLTR},
|
|
27
|
+
{ExternalTag: "tr", ShortCode: "tr", Direction: DirectionLTR},
|
|
28
|
+
{ExternalTag: "pl", ShortCode: "pl", Direction: DirectionLTR},
|
|
29
|
+
{ExternalTag: "nl", ShortCode: "nl", Direction: DirectionLTR},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func TestMappingsRoundTripAllSupportedLocales(t *testing.T) {
|
|
33
|
+
if got := Mappings(); !reflect.DeepEqual(got, expectedMappings) {
|
|
34
|
+
t.Fatalf("Mappings() = %#v, want %#v", got, expectedMappings)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
wantCodes := make([]string, 0, len(expectedMappings))
|
|
38
|
+
for _, mapping := range expectedMappings {
|
|
39
|
+
wantCodes = append(wantCodes, mapping.ShortCode)
|
|
40
|
+
|
|
41
|
+
if got, ok := ShortCodeForExternalTag(mapping.ExternalTag); !ok || got != mapping.ShortCode {
|
|
42
|
+
t.Errorf("ShortCodeForExternalTag(%q) = %q, %v; want %q, true", mapping.ExternalTag, got, ok, mapping.ShortCode)
|
|
43
|
+
}
|
|
44
|
+
if got, ok := ExternalTagForShortCode(mapping.ShortCode); !ok || got != mapping.ExternalTag {
|
|
45
|
+
t.Errorf("ExternalTagForShortCode(%q) = %q, %v; want %q, true", mapping.ShortCode, got, ok, mapping.ExternalTag)
|
|
46
|
+
}
|
|
47
|
+
if got, ok := DirectionForShortCode(mapping.ShortCode); !ok || got != mapping.Direction {
|
|
48
|
+
t.Errorf("DirectionForShortCode(%q) = %q, %v; want %q, true", mapping.ShortCode, got, ok, mapping.Direction)
|
|
49
|
+
}
|
|
50
|
+
if got := NormalizeCode(mapping.ExternalTag); got != mapping.ShortCode {
|
|
51
|
+
t.Errorf("NormalizeCode(%q) = %q, want %q", mapping.ExternalTag, got, mapping.ShortCode)
|
|
52
|
+
}
|
|
53
|
+
if got := NormalizeCode(mapping.ShortCode); got != mapping.ShortCode {
|
|
54
|
+
t.Errorf("NormalizeCode(%q) = %q, want %q", mapping.ShortCode, got, mapping.ShortCode)
|
|
55
|
+
}
|
|
56
|
+
if !IsShortCode(mapping.ShortCode) {
|
|
57
|
+
t.Errorf("IsShortCode(%q) = false, want true", mapping.ShortCode)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
caseVariantExternal := strings.ToUpper(mapping.ExternalTag)
|
|
61
|
+
if got, ok := ShortCodeForExternalTag(caseVariantExternal); ok || got != "" {
|
|
62
|
+
t.Errorf("ShortCodeForExternalTag(%q) accepted case variant", caseVariantExternal)
|
|
26
63
|
}
|
|
64
|
+
caseVariantShort := strings.ToUpper(mapping.ShortCode)
|
|
65
|
+
if got, ok := ExternalTagForShortCode(caseVariantShort); ok || got != "" {
|
|
66
|
+
t.Errorf("ExternalTagForShortCode(%q) accepted case variant", caseVariantShort)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if got := SupportedCodes(); !reflect.DeepEqual(got, wantCodes) {
|
|
71
|
+
t.Fatalf("SupportedCodes() = %#v, want %#v", got, wantCodes)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func TestLocaleLookupsRequireExactMatches(t *testing.T) {
|
|
76
|
+
for _, value := range []string{"zh", "pt-PT", "in", "EN", "zh-hans", "pt-br", "en-US", " en", "en_", "en!", "unknown"} {
|
|
77
|
+
if got, ok := ShortCodeForExternalTag(value); ok || got != "" {
|
|
78
|
+
t.Errorf("ShortCodeForExternalTag(%q) = %q, %v; want empty, false", value, got, ok)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for _, value := range []string{"zh", "pt-BR", "in", "EN", " en", "en!", "unknown"} {
|
|
82
|
+
if got, ok := ExternalTagForShortCode(value); ok || got != "" {
|
|
83
|
+
t.Errorf("ExternalTagForShortCode(%q) = %q, %v; want empty, false", value, got, ok)
|
|
84
|
+
}
|
|
85
|
+
if got, ok := DirectionForShortCode(value); ok || got != "" {
|
|
86
|
+
t.Errorf("DirectionForShortCode(%q) = %q, %v; want empty, false", value, got, ok)
|
|
87
|
+
}
|
|
88
|
+
if IsShortCode(value) {
|
|
89
|
+
t.Errorf("IsShortCode(%q) = true, want false", value)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for _, value := range []string{"zh", "pt-PT", "in", "EN", "zh-hans", "pt-br", "en-US", " en", "en!", "unknown"} {
|
|
93
|
+
if got := NormalizeCode(value); got != "" {
|
|
94
|
+
t.Errorf("NormalizeCode(%q) = %q, want empty", value, got)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func TestMappingsReturnsDefensiveCopy(t *testing.T) {
|
|
100
|
+
got := Mappings()
|
|
101
|
+
got[0] = Mapping{ExternalTag: "changed", ShortCode: "changed", Direction: DirectionRTL}
|
|
102
|
+
if current := Mappings()[0]; current != expectedMappings[0] {
|
|
103
|
+
t.Fatalf("Mappings() exposed mutable owner data: %#v", current)
|
|
27
104
|
}
|
|
28
105
|
}
|
|
29
106
|
|
|
30
107
|
func TestNormalizeTextMap(t *testing.T) {
|
|
31
|
-
got := NormalizeTextMap(map[string]string{
|
|
32
|
-
"en
|
|
33
|
-
"zh-
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"bad-x": "ignored",
|
|
108
|
+
got, err := NormalizeTextMap(map[string]string{
|
|
109
|
+
"en": "English",
|
|
110
|
+
"zh-Hans": " 简体中文 ",
|
|
111
|
+
"pt-BR": "Português",
|
|
112
|
+
"ar": "العربية",
|
|
37
113
|
})
|
|
114
|
+
if err != nil {
|
|
115
|
+
t.Fatalf("NormalizeTextMap() error = %v", err)
|
|
116
|
+
}
|
|
38
117
|
want := map[string]string{
|
|
39
118
|
"en": "English",
|
|
40
|
-
"cn": "
|
|
41
|
-
"
|
|
42
|
-
"
|
|
119
|
+
"cn": "简体中文",
|
|
120
|
+
"pt": "Português",
|
|
121
|
+
"ar": "العربية",
|
|
43
122
|
}
|
|
44
123
|
if !reflect.DeepEqual(got, want) {
|
|
45
|
-
t.Fatalf("NormalizeTextMap() =
|
|
124
|
+
t.Fatalf("NormalizeTextMap() = %#v, want %#v", got, want)
|
|
46
125
|
}
|
|
47
126
|
}
|
|
48
127
|
|
|
49
|
-
func
|
|
50
|
-
|
|
51
|
-
|
|
128
|
+
func TestNormalizeTextMapRejectsInvalidInput(t *testing.T) {
|
|
129
|
+
tests := []struct {
|
|
130
|
+
name string
|
|
131
|
+
input map[string]string
|
|
132
|
+
}{
|
|
133
|
+
{name: "generic Chinese alias", input: map[string]string{"zh": "中文"}},
|
|
134
|
+
{name: "unsupported Portuguese region", input: map[string]string{"pt-PT": "Português"}},
|
|
135
|
+
{name: "legacy Indonesian alias", input: map[string]string{"in": "Bahasa Indonesia"}},
|
|
136
|
+
{name: "case variant", input: map[string]string{"EN": "English"}},
|
|
137
|
+
{name: "empty text", input: map[string]string{"en": " "}},
|
|
138
|
+
{name: "duplicate canonical target", input: map[string]string{"zh-Hans": "简体中文", "cn": "中文"}},
|
|
52
139
|
}
|
|
53
|
-
|
|
54
|
-
t.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if IsShortCode("zz") {
|
|
60
|
-
t.Fatal("IsShortCode(zz) = true, want false")
|
|
140
|
+
for _, tt := range tests {
|
|
141
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
142
|
+
if _, err := NormalizeTextMap(tt.input); err == nil {
|
|
143
|
+
t.Fatal("NormalizeTextMap() error = nil, want rejection")
|
|
144
|
+
}
|
|
145
|
+
})
|
|
61
146
|
}
|
|
62
147
|
}
|
|
63
148
|
|
|
64
149
|
func TestNormalizeLocaleRootedMap(t *testing.T) {
|
|
65
|
-
got := NormalizeLocaleRootedMap(map[string]json.RawMessage{
|
|
66
|
-
"
|
|
67
|
-
"
|
|
150
|
+
got, err := NormalizeLocaleRootedMap(map[string]json.RawMessage{
|
|
151
|
+
"zh-Hant": json.RawMessage(`{"sms":{}}`),
|
|
152
|
+
"id": json.RawMessage(`{"email":{}}`),
|
|
68
153
|
})
|
|
69
|
-
if
|
|
70
|
-
t.Fatalf("
|
|
154
|
+
if err != nil {
|
|
155
|
+
t.Fatalf("NormalizeLocaleRootedMap() error = %v", err)
|
|
156
|
+
}
|
|
157
|
+
if _, ok := got["tw"]; !ok {
|
|
158
|
+
t.Fatalf("normalized map missing tw: %#v", got)
|
|
159
|
+
}
|
|
160
|
+
if _, ok := got["id"]; !ok {
|
|
161
|
+
t.Fatalf("normalized map missing id: %#v", got)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
func TestNormalizeLocaleRootedMapRejectsInvalidInput(t *testing.T) {
|
|
166
|
+
if _, err := NormalizeLocaleRootedMap(map[string]int{"zh": 1}); err == nil {
|
|
167
|
+
t.Fatal("NormalizeLocaleRootedMap() accepted zh alias")
|
|
168
|
+
}
|
|
169
|
+
if _, err := NormalizeLocaleRootedMap(map[string]int{"pt-BR": 1, "pt": 2}); err == nil {
|
|
170
|
+
t.Fatal("NormalizeLocaleRootedMap() accepted duplicate canonical target")
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
func TestNormalizeRawTextMap(t *testing.T) {
|
|
175
|
+
got, err := NormalizeRawTextMap(json.RawMessage(`{"pt-BR":"Português","ar":"العربية"}`))
|
|
176
|
+
if err != nil {
|
|
177
|
+
t.Fatalf("NormalizeRawTextMap() error = %v", err)
|
|
71
178
|
}
|
|
72
|
-
|
|
73
|
-
|
|
179
|
+
var normalized map[string]string
|
|
180
|
+
if err := json.Unmarshal(got, &normalized); err != nil {
|
|
181
|
+
t.Fatalf("json.Unmarshal() error = %v", err)
|
|
182
|
+
}
|
|
183
|
+
want := map[string]string{"pt": "Português", "ar": "العربية"}
|
|
184
|
+
if !reflect.DeepEqual(normalized, want) {
|
|
185
|
+
t.Fatalf("NormalizeRawTextMap() = %#v, want %#v", normalized, want)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func TestNormalizeRawTextMapRejectsInvalidInput(t *testing.T) {
|
|
190
|
+
for _, raw := range []json.RawMessage{
|
|
191
|
+
json.RawMessage(`null`),
|
|
192
|
+
json.RawMessage(`{"pt-PT":"Português"}`),
|
|
193
|
+
} {
|
|
194
|
+
if _, err := NormalizeRawTextMap(raw); err == nil {
|
|
195
|
+
t.Fatalf("NormalizeRawTextMap(%s) error = nil, want rejection", raw)
|
|
196
|
+
}
|
|
74
197
|
}
|
|
75
198
|
}
|
|
@@ -34,12 +34,12 @@ func (s CommandReplayStatus) IsTerminal() bool {
|
|
|
34
34
|
// CommandReplayRecord 跨领域命令幂等回放记录。
|
|
35
35
|
type CommandReplayRecord struct {
|
|
36
36
|
database.BaseModel
|
|
37
|
-
Namespace string `gorm:"column:namespace;type:varchar(200);not null;uniqueIndex:
|
|
38
|
-
CommandScope string `gorm:"column:command_scope;type:varchar(128);not null;uniqueIndex:
|
|
39
|
-
IdempotencyKey string `gorm:"column:idempotency_key;type:varchar(191);not null;uniqueIndex:
|
|
37
|
+
Namespace string `gorm:"column:namespace;type:varchar(200);not null;uniqueIndex:uk_sys_command_replay_records_principal_scope_key,priority:1;comment:命令所有者全局命名空间" json:"namespace"`
|
|
38
|
+
CommandScope string `gorm:"column:command_scope;type:varchar(128);not null;uniqueIndex:uk_sys_command_replay_records_principal_scope_key,priority:4;comment:命令幂等作用域" json:"command_scope"`
|
|
39
|
+
IdempotencyKey string `gorm:"column:idempotency_key;type:varchar(191);not null;uniqueIndex:uk_sys_command_replay_records_principal_scope_key,priority:5;comment:请求幂等键" json:"idempotency_key"`
|
|
40
40
|
RequestHash string `gorm:"column:request_hash;type:varchar(64);not null;comment:请求载荷指纹" json:"request_hash"`
|
|
41
|
-
PrincipalType
|
|
42
|
-
PrincipalRef
|
|
41
|
+
PrincipalType string `gorm:"column:principal_type;type:varchar(32);not null;uniqueIndex:uk_sys_command_replay_records_principal_scope_key,priority:2;comment:参与幂等作用域的可信调用主体类型" json:"principal_type"`
|
|
42
|
+
PrincipalRef string `gorm:"column:principal_ref;type:varchar(64);not null;uniqueIndex:uk_sys_command_replay_records_principal_scope_key,priority:3;comment:参与幂等作用域的可信调用主体公开引用" json:"principal_ref"`
|
|
43
43
|
Status CommandReplayStatus `gorm:"column:status;type:varchar(20);not null;default:'started';comment:命令执行状态" json:"status"`
|
|
44
44
|
ResponsePayload json.RawMessage `gorm:"column:response_payload;type:jsonb;not null;default:'{}';comment:回放响应载荷" json:"response_payload"`
|
|
45
45
|
ErrorCode *string `gorm:"column:error_code;type:varchar(120);comment:失败错误码" json:"error_code"`
|
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"encoding/json"
|
|
6
6
|
"errors"
|
|
7
7
|
"fmt"
|
|
8
|
+
"strconv"
|
|
8
9
|
"strings"
|
|
9
10
|
"time"
|
|
10
11
|
|
|
@@ -32,11 +33,13 @@ func NewCommandReplayRepository(db *gorm.DB) CommandReplayRepository {
|
|
|
32
33
|
return &commandReplayRepository{db: db}
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
func (r *commandReplayRepository) FindByScope(ctx context.Context, namespace, commandScope, idempotencyKey string) (*CommandReplayRecord, error) {
|
|
36
|
+
func (r *commandReplayRepository) FindByScope(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (*CommandReplayRecord, error) {
|
|
36
37
|
var record CommandReplayRecord
|
|
37
38
|
err := database.GetDB(ctx, r.db).
|
|
38
39
|
Select(commandReplayRecordColumns).
|
|
39
40
|
Where("namespace = ?", strings.TrimSpace(namespace)).
|
|
41
|
+
Where("principal_type = ?", strings.TrimSpace(principalType)).
|
|
42
|
+
Where("principal_ref = ?", strings.TrimSpace(principalRef)).
|
|
40
43
|
Where("command_scope = ?", strings.TrimSpace(commandScope)).
|
|
41
44
|
Where("idempotency_key = ?", strings.TrimSpace(idempotencyKey)).
|
|
42
45
|
First(&record).Error
|
|
@@ -65,17 +68,13 @@ func (r *commandReplayRepository) FindByIDForUpdate(ctx context.Context, id int6
|
|
|
65
68
|
return &record, nil
|
|
66
69
|
}
|
|
67
70
|
|
|
68
|
-
func (r *commandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, commandScope, idempotencyKey string) (bool, error) {
|
|
71
|
+
func (r *commandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (bool, error) {
|
|
69
72
|
db := database.GetDB(ctx, r.db)
|
|
70
73
|
if db.Dialector.Name() != "postgres" {
|
|
71
74
|
return false, fmt.Errorf("unsupported database dialect for command replay scope lock: %s", db.Dialector.Name())
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
scopeKey :=
|
|
75
|
-
strings.TrimSpace(namespace),
|
|
76
|
-
strings.TrimSpace(commandScope),
|
|
77
|
-
strings.TrimSpace(idempotencyKey),
|
|
78
|
-
}, "\x1f")
|
|
77
|
+
scopeKey := commandReplayScopeKey(namespace, principalType, principalRef, commandScope, idempotencyKey)
|
|
79
78
|
|
|
80
79
|
var acquired bool
|
|
81
80
|
if err := db.Raw(
|
|
@@ -92,6 +91,8 @@ func (r *commandReplayRepository) Create(ctx context.Context, record *CommandRep
|
|
|
92
91
|
Clauses(clause.OnConflict{
|
|
93
92
|
Columns: []clause.Column{
|
|
94
93
|
{Name: "namespace"},
|
|
94
|
+
{Name: "principal_type"},
|
|
95
|
+
{Name: "principal_ref"},
|
|
95
96
|
{Name: "command_scope"},
|
|
96
97
|
{Name: "idempotency_key"},
|
|
97
98
|
},
|
|
@@ -107,6 +108,18 @@ func (r *commandReplayRepository) Create(ctx context.Context, record *CommandRep
|
|
|
107
108
|
return nil
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
func commandReplayScopeKey(namespace, principalType, principalRef, commandScope, idempotencyKey string) string {
|
|
112
|
+
fields := [...]string{namespace, principalType, principalRef, commandScope, idempotencyKey}
|
|
113
|
+
var key strings.Builder
|
|
114
|
+
for _, field := range fields {
|
|
115
|
+
normalized := strings.TrimSpace(field)
|
|
116
|
+
key.WriteString(strconv.Itoa(len(normalized)))
|
|
117
|
+
key.WriteByte(':')
|
|
118
|
+
key.WriteString(normalized)
|
|
119
|
+
}
|
|
120
|
+
return key.String()
|
|
121
|
+
}
|
|
122
|
+
|
|
110
123
|
func (r *commandReplayRepository) MarkSucceeded(ctx context.Context, id int64, responsePayload json.RawMessage, completedAt time.Time) error {
|
|
111
124
|
result := database.GetDB(ctx, r.db).
|
|
112
125
|
Model(&CommandReplayRecord{}).
|
|
@@ -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")
|