@stackonward/cli 1.3.3 → 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.
Files changed (111) hide show
  1. package/README.md +248 -0
  2. package/dist/index.js +14124 -635
  3. package/dist/index.js.map +1 -1
  4. package/dist/resources/components/nuxt/site-source/app/app.vue +4 -2
  5. package/dist/resources/components/nuxt/site-source/app/assets/css/site-shell.css +50 -50
  6. package/dist/resources/components/nuxt/site-source/app/components/{site/ApplicationHeader.vue → app/Header.vue} +3 -3
  7. package/dist/resources/components/nuxt/site-source/app/components/{site/ApplicationShell.vue → app/Shell.vue} +2 -2
  8. package/dist/resources/components/nuxt/site-source/app/components/{site/ApplicationSidebar.vue → app/Sidebar.vue} +2 -2
  9. package/dist/resources/components/nuxt/site-source/app/components/{ui/CheckoutStatus.vue → checkout/Status.vue} +8 -3
  10. package/dist/resources/components/nuxt/site-source/app/components/{ui/CheckoutSummary.vue → checkout/Summary.vue} +11 -11
  11. package/dist/resources/components/nuxt/site-source/app/components/{site/CookieConsent.vue → consent/Banner.vue} +3 -3
  12. package/dist/resources/components/nuxt/site-source/app/components/{site/ContentArticlePage.vue → content/Article.vue} +3 -3
  13. package/dist/resources/components/nuxt/site-source/app/components/{site/ContentListPage.vue → content/List.vue} +2 -2
  14. package/dist/resources/components/nuxt/site-source/app/components/{ui/GoogleSignInButton.vue → google/SignInButton.vue} +3 -3
  15. package/dist/resources/components/nuxt/site-source/app/components/{site/LocaleSwitcher.vue → locale/Menu.vue} +2 -2
  16. package/dist/resources/components/nuxt/site-source/app/components/site/{MarketingFooter.vue → Footer.vue} +8 -8
  17. package/dist/resources/components/nuxt/site-source/app/components/site/{MarketingHeader.vue → Header.vue} +27 -27
  18. package/dist/resources/components/nuxt/site-source/app/components/site/Shell.vue +29 -0
  19. package/dist/resources/components/nuxt/site-source/app/components/{site/ThemeSwitcher.vue → theme/Menu.vue} +2 -2
  20. package/dist/resources/components/nuxt/site-source/app/components/ui/{BaseButton.vue → Button.vue} +1 -8
  21. package/dist/resources/components/nuxt/site-source/app/components/ui/InfiniteScroll.vue +2 -2
  22. package/dist/resources/components/nuxt/site-source/app/components/{ui/YoutubeEmbed.vue → youtube/Embed.vue} +10 -2
  23. package/dist/resources/components/nuxt/site-source/app/composables/useConsent.ts +8 -10
  24. package/dist/resources/components/nuxt/site-source/app/composables/useNavigation.ts +9 -14
  25. package/dist/resources/components/nuxt/site-source/app/composables/useSeo.ts +3 -1
  26. package/dist/resources/components/nuxt/site-source/app/composables/useSiteShell.ts +19 -22
  27. package/dist/resources/components/nuxt/site-source/app/consent/types.ts +59 -0
  28. package/dist/resources/components/nuxt/site-source/app/content/types.ts +17 -0
  29. package/dist/resources/components/nuxt/site-source/app/layouts/app.vue +2 -2
  30. package/dist/resources/components/nuxt/site-source/app/layouts/default.vue +2 -2
  31. package/dist/resources/components/nuxt/site-source/app/navigation/types.ts +68 -0
  32. package/dist/resources/components/nuxt/site-source/app/plugins/consent.ts +2 -2
  33. package/dist/resources/components/nuxt/site-source/app/site/config.ts +35 -0
  34. package/dist/resources/components/registry.json +63 -51
  35. package/dist/resources/deploy/ci/github/caller.yml +38 -0
  36. package/dist/resources/deploy/ci/onedev/import.yml +14 -0
  37. package/dist/resources/deploy/nginx/cache.conf +17 -0
  38. package/dist/resources/deploy/nginx/compression.conf +23 -0
  39. package/dist/resources/deploy/nginx/proxy.conf +15 -0
  40. package/dist/resources/deploy/profiles/go-service/compose.yaml +19 -0
  41. package/dist/resources/deploy/profiles/nuxt-node/Dockerfile +17 -0
  42. package/dist/resources/deploy/profiles/static-files/nginx/site.conf.template +55 -0
  43. package/dist/resources/deploy/profiles/static-nginx/Dockerfile +18 -0
  44. package/dist/resources/deploy/profiles/static-nginx/nginx/default.conf.template +28 -0
  45. package/dist/resources/deploy/targets/compose-host/deploy.config.example.json +114 -0
  46. package/dist/resources/deploy/traffic/nginx/stackonward-nginx-traffic +96 -0
  47. package/dist/resources/registry.json +30 -30
  48. package/dist/resources/templates/base/.claude/docs/architecture.md +4 -0
  49. package/dist/resources/templates/base/AGENTS.md +15 -0
  50. package/dist/resources/templates/base/CLAUDE.md +15 -0
  51. package/dist/resources/templates/go-backend/.dockerignore +25 -3
  52. package/dist/resources/templates/go-backend/deploy.config.json +26 -0
  53. package/dist/resources/templates/go-backend/docs/runtime-provenance.json +9 -6
  54. package/dist/resources/templates/go-backend/docs/source-provenance.json +33 -22
  55. package/dist/resources/templates/go-backend/internal/common/enums/register_locale.go +7 -0
  56. package/dist/resources/templates/go-backend/internal/common/enums/register_locale_test.go +30 -0
  57. package/dist/resources/templates/go-backend/internal/common/locale/locale.go +102 -76
  58. package/dist/resources/templates/go-backend/internal/common/locale/locale_test.go +168 -45
  59. package/dist/resources/templates/go-backend/internal/core/commandreplay/model.go +5 -5
  60. package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence.go +20 -7
  61. package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence_test.go +51 -22
  62. package/dist/resources/templates/go-backend/internal/core/commandreplay/repository.go +2 -2
  63. package/dist/resources/templates/go-backend/internal/core/commandreplay/service.go +13 -24
  64. package/dist/resources/templates/go-backend/internal/core/commandreplay/service_persistence_test.go +98 -13
  65. package/dist/resources/templates/go-backend/internal/core/commandreplay/service_test.go +61 -36
  66. package/dist/resources/templates/go-backend/internal/core/commandreplay/types.go +6 -6
  67. package/dist/resources/templates/go-backend/internal/core/integrationevent/config.go +24 -0
  68. package/dist/resources/templates/go-backend/internal/core/integrationevent/errors.go +53 -0
  69. package/dist/resources/templates/go-backend/internal/core/integrationevent/model.go +71 -0
  70. package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence.go +193 -0
  71. package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_postgres_test.go +178 -0
  72. package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_test.go +168 -0
  73. package/dist/resources/templates/go-backend/internal/core/integrationevent/provider.go +16 -0
  74. package/dist/resources/templates/go-backend/internal/core/integrationevent/registry.go +69 -0
  75. package/dist/resources/templates/go-backend/internal/core/integrationevent/repository.go +17 -0
  76. package/dist/resources/templates/go-backend/internal/core/integrationevent/service.go +283 -0
  77. package/dist/resources/templates/go-backend/internal/core/integrationevent/service_test.go +433 -0
  78. package/dist/resources/templates/go-backend/internal/core/integrationevent/types.go +67 -0
  79. package/dist/resources/templates/go-backend/migrations/000001_infrastructure.down.sql +6 -0
  80. package/dist/resources/templates/go-backend/migrations/000001_infrastructure.up.sql +176 -16
  81. package/dist/resources/templates/go-backend/tests/infrastructure.sql +109 -6
  82. package/dist/resources/templates/nuxt/.claude/docs/stack.md +5 -1
  83. package/dist/resources/templates/nuxt/.dockerignore +19 -0
  84. package/dist/resources/templates/nuxt/README.md +3 -3
  85. package/dist/resources/templates/nuxt/app/app.config.ts +1 -1
  86. package/dist/resources/templates/nuxt/app/i18n/locales/en.json +1 -1
  87. package/dist/resources/templates/nuxt/app/pages/app/index.vue +1 -1
  88. package/dist/resources/templates/nuxt/app/pages/authors/[slug].vue +2 -2
  89. package/dist/resources/templates/nuxt/app/pages/blog/[slug].vue +4 -3
  90. package/dist/resources/templates/nuxt/app/pages/blog/category/[category].vue +2 -2
  91. package/dist/resources/templates/nuxt/app/pages/blog/index.vue +2 -2
  92. package/dist/resources/templates/nuxt/app/pages/legal/cookies.vue +3 -3
  93. package/dist/resources/templates/nuxt/app/pages/legal/privacy.vue +2 -2
  94. package/dist/resources/templates/nuxt/app/pages/legal/terms.vue +2 -2
  95. package/dist/resources/templates/nuxt/app/pages/ui/index.vue +45 -45
  96. package/dist/resources/templates/nuxt/deploy.config.json +27 -0
  97. package/dist/resources/templates/nuxt/nuxt.config.ts +4 -10
  98. package/dist/resources/templates/nuxt/package.json +8 -8
  99. package/dist/resources/templates/nuxt/test/e2e/rendering.test.ts +1 -0
  100. package/dist/resources/templates/nuxt/test/unit/consent.test.ts +4 -7
  101. package/package.json +5 -3
  102. package/dist/resources/components/nuxt/site-source/app/components/site/MarketingShell.vue +0 -33
  103. package/dist/resources/components/nuxt/site-source/app/types/site-shell.ts +0 -172
  104. /package/dist/resources/components/nuxt/site-source/app/components/{site/ConsentSettingsButton.vue → consent/Settings.vue} +0 -0
  105. /package/dist/resources/components/nuxt/site-source/app/components/{site/StaticPage.vue → content/Document.vue} +0 -0
  106. /package/dist/resources/components/nuxt/site-source/app/components/{site/ToastViewport.vue → feedback/Toast.vue} +0 -0
  107. /package/dist/resources/components/nuxt/site-source/app/components/site/{SiteBrand.vue → Brand.vue} +0 -0
  108. /package/dist/resources/components/nuxt/site-source/app/components/ui/{BaseGradientBorder.vue → GradientBorder.vue} +0 -0
  109. /package/dist/resources/components/nuxt/site-source/app/components/ui/{BaseModal.vue → Modal.vue} +0 -0
  110. /package/dist/resources/components/nuxt/site-source/app/components/ui/{BasePopover.vue → Popover.vue} +0 -0
  111. /package/dist/resources/components/nuxt/site-source/app/components/ui/{BaseText.vue → Text.vue} +0 -0
@@ -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
- missing, err := repo.FindByScope(ctx, "test.command.checkout", "quote:create", "idem-missing")
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-2", expiresAt)))
52
- require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.checkout", "quote:update", "idem-1", expiresAt)))
53
- require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("test.command.fulfillment", "quote:create", "idem-1", expiresAt)))
54
- require.NoError(t, repo.Create(ctx, newPersistenceTestCommandReplayRecord("other.command.checkout", "quote:create", "idem-1", expiresAt)))
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(5), total)
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 uk_sys_command_replay_records_namespace_scope_key
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: cloneStringPtr(normalized.principalType),
116
- PrincipalRef: cloneStringPtr(normalized.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: cloneStringPtr(normalized.principalType), PrincipalRef: cloneStringPtr(normalized.principalRef),
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 := normalizeOptionalString("principalType", req.PrincipalType, maxPrincipalTypeLength)
618
+ principalType, err := normalizeRequiredString("principalType", req.PrincipalType, maxPrincipalTypeLength)
613
619
  if err != nil {
614
620
  return nil, err
615
621
  }
616
- principalRef, err := normalizeOptionalString("principalRef", req.PrincipalRef, maxPrincipalRefLength)
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")
@@ -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 uk_sys_command_replay_records_namespace_scope_key
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 uk_sys_command_replay_records_namespace_scope_key
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)