@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
@@ -18,14 +18,16 @@ import (
18
18
  "{{module}}/pkg/apierror"
19
19
  )
20
20
 
21
- const commandReplayTestNamespace = "test.command.replay"
21
+ const (
22
+ commandReplayTestNamespace = "test.command.replay"
23
+ commandReplayTestPrincipalType = "identity"
24
+ commandReplayTestPrincipalRef = "user_123"
25
+ )
22
26
 
23
27
  var commandReplayTestNow = time.Date(2026, 5, 28, 10, 0, 0, 0, time.UTC)
24
28
 
25
29
  func TestCommandReplayExecuteSucceeds(t *testing.T) {
26
30
  service, repo := newTestCommandReplayService(t)
27
- principalType := "identity"
28
- principalRef := "user_123"
29
31
  fnCalled := 0
30
32
 
31
33
  result, err := service.Execute(context.Background(), CommandReplayBeginRequest{
@@ -33,8 +35,8 @@ func TestCommandReplayExecuteSucceeds(t *testing.T) {
33
35
  CommandScope: " quote:123 ",
34
36
  IdempotencyKey: " key-123 ",
35
37
  RequestPayload: json.RawMessage(`{"currency":"USD","amount":100}`),
36
- PrincipalType: &principalType,
37
- PrincipalRef: &principalRef,
38
+ PrincipalType: " identity ",
39
+ PrincipalRef: " user_123 ",
38
40
  }, func(ctx context.Context, record *CommandReplayRecord) (*CommandReplayCommandResult, error) {
39
41
  fnCalled++
40
42
  if record.ID == 0 {
@@ -61,7 +63,7 @@ func TestCommandReplayExecuteSucceeds(t *testing.T) {
61
63
  t.Fatalf("Execute() ResponsePayload = %s, want canonical object", result.ResponsePayload)
62
64
  }
63
65
 
64
- stored := repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
66
+ stored := repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
65
67
  if stored.Status != CommandReplayStatusSucceeded {
66
68
  t.Fatalf("stored Status = %q, want succeeded", stored.Status)
67
69
  }
@@ -77,11 +79,11 @@ func TestCommandReplayExecuteSucceeds(t *testing.T) {
77
79
  if stored.CompletedAt == nil || !stored.CompletedAt.Equal(commandReplayTestNow) {
78
80
  t.Fatalf("stored CompletedAt = %v, want %s", stored.CompletedAt, commandReplayTestNow)
79
81
  }
80
- if stored.PrincipalType == nil || *stored.PrincipalType != principalType {
81
- t.Fatalf("stored PrincipalType = %v, want %q", stored.PrincipalType, principalType)
82
+ if stored.PrincipalType != commandReplayTestPrincipalType {
83
+ t.Fatalf("stored PrincipalType = %q, want %q", stored.PrincipalType, commandReplayTestPrincipalType)
82
84
  }
83
- if stored.PrincipalRef == nil || *stored.PrincipalRef != principalRef {
84
- t.Fatalf("stored PrincipalRef = %v, want %q", stored.PrincipalRef, principalRef)
85
+ if stored.PrincipalRef != commandReplayTestPrincipalRef {
86
+ t.Fatalf("stored PrincipalRef = %q, want %q", stored.PrincipalRef, commandReplayTestPrincipalRef)
85
87
  }
86
88
  }
87
89
 
@@ -140,6 +142,29 @@ func TestCommandReplayExecuteRejectsHashConflict(t *testing.T) {
140
142
  assertAPIError(t, err, http.StatusConflict, errCodeCommandReplayConflict)
141
143
  }
142
144
 
145
+ func TestCommandReplayExecuteIsolatesSameKeyByPrincipal(t *testing.T) {
146
+ service, repo := newTestCommandReplayService(t)
147
+ first := beginRequest(json.RawMessage(`{"amount":100}`))
148
+ second := beginRequest(json.RawMessage(`{"amount":100}`))
149
+ second.PrincipalRef = "user_456"
150
+
151
+ firstResult, err := service.Execute(context.Background(), first, successCommandReplayFunc(t))
152
+ if err != nil {
153
+ t.Fatalf("Execute(first) error = %v", err)
154
+ }
155
+ secondResult, err := service.Execute(context.Background(), second, successCommandReplayFunc(t))
156
+ if err != nil {
157
+ t.Fatalf("Execute(second) error = %v", err)
158
+ }
159
+ if firstResult.Replayed || secondResult.Replayed {
160
+ t.Fatalf("new principal-scoped commands must not replay: first=%v second=%v", firstResult.Replayed, secondResult.Replayed)
161
+ }
162
+ if repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123").RequestHash !=
163
+ repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, "user_456", "quote:123", "key-123").RequestHash {
164
+ t.Fatal("principal unexpectedly changed the payload-only request fingerprint")
165
+ }
166
+ }
167
+
143
168
  func TestCommandReplayExecuteRejectsExpiredRecord(t *testing.T) {
144
169
  service, repo := newTestCommandReplayService(t)
145
170
  repo.seed(seedCommandReplayRecord(t, json.RawMessage(`{"amount":100}`), func(record *CommandReplayRecord) {
@@ -169,7 +194,7 @@ func TestCommandReplayExecuteBusinessFailureCommitsAndReturnsBusinessError(t *te
169
194
  t.Fatalf("Execute() result = %#v, want failed result", result)
170
195
  }
171
196
 
172
- stored := repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
197
+ stored := repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
173
198
  if stored.Status != CommandReplayStatusFailed {
174
199
  t.Fatalf("stored Status = %q, want failed", stored.Status)
175
200
  }
@@ -198,7 +223,7 @@ func TestCommandReplayExecuteInfrastructureErrorDoesNotMarkTerminal(t *testing.T
198
223
  t.Fatalf("Execute() error = %v, want infraErr", err)
199
224
  }
200
225
 
201
- stored := repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
226
+ stored := repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
202
227
  if stored.Status.IsTerminal() {
203
228
  t.Fatalf("stored Status = %q, want non-terminal", stored.Status)
204
229
  }
@@ -258,19 +283,17 @@ func TestCommandReplayExecuteRejectsInvalidNamespace(t *testing.T) {
258
283
  assertAPIError(t, err, http.StatusBadRequest, apierror.CodeValidationFailed)
259
284
  }
260
285
 
261
- func TestCommandReplayExecuteRejectsIncompletePrincipal(t *testing.T) {
262
- principalType := "identity"
263
- principalRef := "ipr_123"
264
- blank := " \t "
286
+ func TestCommandReplayExecuteRejectsInvalidPrincipal(t *testing.T) {
265
287
  tests := []struct {
266
288
  name string
267
- principalType *string
268
- principalRef *string
289
+ principalType string
290
+ principalRef string
269
291
  }{
270
- {name: "missing principal ref", principalType: &principalType},
271
- {name: "missing principal type", principalRef: &principalRef},
272
- {name: "blank principal pair", principalType: &blank, principalRef: &blank},
273
- {name: "blank principal ref", principalType: &principalType, principalRef: &blank},
292
+ {name: "missing principal ref", principalType: commandReplayTestPrincipalType},
293
+ {name: "missing principal type", principalRef: commandReplayTestPrincipalRef},
294
+ {name: "blank principal pair", principalType: " \t ", principalRef: " \t "},
295
+ {name: "overlong principal type", principalType: strings.Repeat("a", maxPrincipalTypeLength+1), principalRef: commandReplayTestPrincipalRef},
296
+ {name: "overlong principal ref", principalType: commandReplayTestPrincipalType, principalRef: strings.Repeat("a", maxPrincipalRefLength+1)},
274
297
  }
275
298
 
276
299
  for _, tt := range tests {
@@ -372,7 +395,7 @@ func TestCommandReplayExternalLifecycle(t *testing.T) {
372
395
  if lease.ExecutionToken == "" || lease.AttemptCount != 1 || lease.Reservation.ReferenceNo != "cpo_1" {
373
396
  t.Fatalf("lease = %#v", lease)
374
397
  }
375
- stored := repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
398
+ stored := repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
376
399
  if !stored.HasExternalReservation() || stored.ExecutionTokenHash == nil || stored.ExternalAttemptCount != 1 {
377
400
  t.Fatalf("stored external record = %#v", stored)
378
401
  }
@@ -389,7 +412,7 @@ func TestCommandReplayExternalLifecycle(t *testing.T) {
389
412
  if err != nil || result.Status != CommandReplayStatusSucceeded {
390
413
  t.Fatalf("CompleteExternal() = %#v, %v", result, err)
391
414
  }
392
- stored = repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
415
+ stored = repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
393
416
  if stored.HasExternalReservation() || stored.ExecutionTokenHash != nil || stored.Status != CommandReplayStatusSucceeded {
394
417
  t.Fatalf("completed stored record = %#v", stored)
395
418
  }
@@ -453,7 +476,7 @@ func TestCommandReplayExternalDeferAndRecoveryClaim(t *testing.T) {
453
476
  if err := service.DeferExternal(context.Background(), lease.RecordID, lease.ExecutionToken, "provider_timeout", nextRecoveryAt); err != nil {
454
477
  t.Fatalf("DeferExternal() error = %v", err)
455
478
  }
456
- stored := repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
479
+ stored := repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
457
480
  if stored.ExecutionTokenHash != nil || stored.ExternalLastErrorCode == nil || *stored.ExternalLastErrorCode != "provider_timeout" {
458
481
  t.Fatalf("deferred record = %#v", stored)
459
482
  }
@@ -586,6 +609,8 @@ func newTestCommandReplayService(t *testing.T) (*CommandReplayService, *fakeComm
586
609
  func beginRequest(payload json.RawMessage) CommandReplayBeginRequest {
587
610
  return CommandReplayBeginRequest{
588
611
  Namespace: commandReplayTestNamespace,
612
+ PrincipalType: commandReplayTestPrincipalType,
613
+ PrincipalRef: commandReplayTestPrincipalRef,
589
614
  CommandScope: "quote:123",
590
615
  IdempotencyKey: "key-123",
591
616
  RequestPayload: payload,
@@ -599,6 +624,8 @@ func seedCommandReplayRecord(t *testing.T, payload json.RawMessage, mutate func(
599
624
 
600
625
  record := &CommandReplayRecord{
601
626
  Namespace: commandReplayTestNamespace,
627
+ PrincipalType: commandReplayTestPrincipalType,
628
+ PrincipalRef: commandReplayTestPrincipalRef,
602
629
  CommandScope: "quote:123",
603
630
  IdempotencyKey: "key-123",
604
631
  RequestHash: mustCanonicalHash(t, payload),
@@ -669,18 +696,18 @@ func newFakeCommandReplayRepository() *fakeCommandReplayRepository {
669
696
  }
670
697
  }
671
698
 
672
- func (r *fakeCommandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, commandScope, idempotencyKey string) (bool, error) {
699
+ func (r *fakeCommandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (bool, error) {
673
700
  r.mu.Lock()
674
701
  defer r.mu.Unlock()
675
702
 
676
703
  return r.lockAcquired, nil
677
704
  }
678
705
 
679
- func (r *fakeCommandReplayRepository) FindByScope(ctx context.Context, namespace, commandScope, idempotencyKey string) (*CommandReplayRecord, error) {
706
+ func (r *fakeCommandReplayRepository) FindByScope(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (*CommandReplayRecord, error) {
680
707
  r.mu.Lock()
681
708
  defer r.mu.Unlock()
682
709
 
683
- record := r.records[commandReplayRecordKey(namespace, commandScope, idempotencyKey)]
710
+ record := r.records[commandReplayRecordKey(namespace, principalType, principalRef, commandScope, idempotencyKey)]
684
711
  return cloneCommandReplayRecord(record), nil
685
712
  }
686
713
 
@@ -692,7 +719,7 @@ func (r *fakeCommandReplayRepository) Create(ctx context.Context, record *Comman
692
719
  r.mu.Lock()
693
720
  defer r.mu.Unlock()
694
721
 
695
- key := commandReplayRecordKey(record.Namespace, record.CommandScope, record.IdempotencyKey)
722
+ key := commandReplayRecordKey(record.Namespace, record.PrincipalType, record.PrincipalRef, record.CommandScope, record.IdempotencyKey)
696
723
  if _, exists := r.records[key]; exists {
697
724
  return ErrCommandReplayRecordExists
698
725
  }
@@ -835,17 +862,17 @@ func (r *fakeCommandReplayRepository) seed(record *CommandReplayRecord) {
835
862
  r.nextID++
836
863
  }
837
864
  record.ID = stored.ID
838
- key := commandReplayRecordKey(stored.Namespace, stored.CommandScope, stored.IdempotencyKey)
865
+ key := commandReplayRecordKey(stored.Namespace, stored.PrincipalType, stored.PrincipalRef, stored.CommandScope, stored.IdempotencyKey)
839
866
  r.records[key] = stored
840
867
  }
841
868
 
842
- func (r *fakeCommandReplayRepository) mustFind(t *testing.T, namespace, commandScope, idempotencyKey string) *CommandReplayRecord {
869
+ func (r *fakeCommandReplayRepository) mustFind(t *testing.T, namespace, principalType, principalRef, commandScope, idempotencyKey string) *CommandReplayRecord {
843
870
  t.Helper()
844
871
 
845
872
  r.mu.Lock()
846
873
  defer r.mu.Unlock()
847
874
 
848
- record := r.records[commandReplayRecordKey(namespace, commandScope, idempotencyKey)]
875
+ record := r.records[commandReplayRecordKey(namespace, principalType, principalRef, commandScope, idempotencyKey)]
849
876
  if record == nil {
850
877
  t.Fatalf("record not found")
851
878
  }
@@ -861,8 +888,8 @@ func (r *fakeCommandReplayRepository) findByIDLocked(id int64) *CommandReplayRec
861
888
  return nil
862
889
  }
863
890
 
864
- func commandReplayRecordKey(namespace, commandScope, idempotencyKey string) string {
865
- return namespace + "\x00" + commandScope + "\x00" + idempotencyKey
891
+ func commandReplayRecordKey(namespace, principalType, principalRef, commandScope, idempotencyKey string) string {
892
+ return namespace + "\x00" + principalType + "\x00" + principalRef + "\x00" + commandScope + "\x00" + idempotencyKey
866
893
  }
867
894
 
868
895
  func cloneCommandReplayRecord(record *CommandReplayRecord) *CommandReplayRecord {
@@ -870,8 +897,6 @@ func cloneCommandReplayRecord(record *CommandReplayRecord) *CommandReplayRecord
870
897
  return nil
871
898
  }
872
899
  cloned := *record
873
- cloned.PrincipalType = cloneStringPtr(record.PrincipalType)
874
- cloned.PrincipalRef = cloneStringPtr(record.PrincipalRef)
875
900
  cloned.ErrorCode = cloneStringPtr(record.ErrorCode)
876
901
  cloned.ErrorStatus = cloneIntPtr(record.ErrorStatus)
877
902
  cloned.ResponsePayload = append(json.RawMessage(nil), record.ResponsePayload...)
@@ -18,10 +18,10 @@ type CommandReplayBeginRequest struct {
18
18
  IdempotencyKey string
19
19
  // RequestPayload 命令请求的规范 JSON 对象载荷。
20
20
  RequestPayload json.RawMessage
21
- // PrincipalType 请求主体类型。
22
- PrincipalType *string
23
- // PrincipalRef 请求主体公开引用。
24
- PrincipalRef *string
21
+ // PrincipalType 是参与幂等作用域的可信调用主体类型。
22
+ PrincipalType string
23
+ // PrincipalRef 是参与幂等作用域的可信调用主体公开引用。
24
+ PrincipalRef string
25
25
  // Metadata 扩展元数据 JSON 对象。
26
26
  Metadata json.RawMessage
27
27
  // TTL 幂等记录保留时长。
@@ -106,8 +106,8 @@ type normalizedCommandReplayBeginRequest struct {
106
106
  commandScope string
107
107
  idempotencyKey string
108
108
  requestHash string
109
- principalType *string
110
- principalRef *string
109
+ principalType string
110
+ principalRef string
111
111
  metadata json.RawMessage
112
112
  ttl time.Duration
113
113
  }
@@ -0,0 +1,24 @@
1
+ package integrationevent
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+ )
7
+
8
+ const maxSubscriberCodeLength = 64
9
+
10
+ // ReceiverConfig 定义单个入站事件接收方的稳定订阅者身份。
11
+ type ReceiverConfig struct {
12
+ SubscriberCode string
13
+ }
14
+
15
+ func (c ReceiverConfig) normalized() (ReceiverConfig, error) {
16
+ c.SubscriberCode = strings.TrimSpace(c.SubscriberCode)
17
+ if c.SubscriberCode == "" {
18
+ return ReceiverConfig{}, fmt.Errorf("integration event subscriber code is required")
19
+ }
20
+ if len(c.SubscriberCode) > maxSubscriberCodeLength {
21
+ return ReceiverConfig{}, fmt.Errorf("integration event subscriber code exceeds %d bytes", maxSubscriberCodeLength)
22
+ }
23
+ return c, nil
24
+ }
@@ -0,0 +1,53 @@
1
+ package integrationevent
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ )
7
+
8
+ var (
9
+ // ErrEnvelopeInvalid 表示接收方提交的已验证信封不满足核心持久化合同。
10
+ ErrEnvelopeInvalid = errors.New("integration event envelope is invalid")
11
+ // ErrHandlerNotRegistered 表示没有消费者为精确 source/type/schema 注册处理器。
12
+ ErrHandlerNotRegistered = errors.New("integration event handler is not registered")
13
+ // ErrIdentityConflict 表示同一全局事件身份绑定了不同规范信封。
14
+ ErrIdentityConflict = errors.New("integration event identity conflicts with the persisted envelope")
15
+ // ErrVersionConflict 表示事件版本不高于水位且无法证明是已处理事件。
16
+ ErrVersionConflict = errors.New("integration event aggregate version conflicts with the checkpoint")
17
+ // ErrReceiptStateConflict 表示持久回执处于无法安全重放的状态。
18
+ ErrReceiptStateConflict = errors.New("integration event receipt state conflicts with replay")
19
+ )
20
+
21
+ // VersionGapError 表示事件版本领先于当前连续水位,调用方应稍后重试同一事件。
22
+ type VersionGapError struct {
23
+ Expected int64
24
+ Actual int64
25
+ }
26
+
27
+ func (e *VersionGapError) Error() string {
28
+ return fmt.Sprintf("integration event aggregate version gap: expected %d, got %d", e.Expected, e.Actual)
29
+ }
30
+
31
+ // Retryable 标识版本缺口可以在前序事件到达后重试。
32
+ func (e *VersionGapError) Retryable() bool { return true }
33
+
34
+ // HandlerError 表示消费者处理失败,整个入站事务已回滚并可重试。
35
+ type HandlerError struct {
36
+ Cause error
37
+ }
38
+
39
+ func (e *HandlerError) Error() string {
40
+ return fmt.Sprintf("integration event handler failed: %v", e.Cause)
41
+ }
42
+
43
+ // Unwrap 返回消费者处理器的原始错误。
44
+ func (e *HandlerError) Unwrap() error { return e.Cause }
45
+
46
+ // Retryable 保留消费者对失败是否可重试的显式分类。
47
+ func (e *HandlerError) Retryable() bool { return IsRetryable(e.Cause) }
48
+
49
+ // IsRetryable 判断错误是否允许发送方稍后重试同一事件身份。
50
+ func IsRetryable(err error) bool {
51
+ var retryable interface{ Retryable() bool }
52
+ return errors.As(err, &retryable) && retryable.Retryable()
53
+ }
@@ -0,0 +1,71 @@
1
+ package integrationevent
2
+
3
+ import (
4
+ "encoding/json"
5
+ "time"
6
+ )
7
+
8
+ // ReceiptProcessingStatus 表示订阅者回执的持久处理状态。
9
+ type ReceiptProcessingStatus string
10
+
11
+ const (
12
+ ReceiptProcessingStatusProcessing ReceiptProcessingStatus = "processing"
13
+ ReceiptProcessingStatusProcessed ReceiptProcessingStatus = "processed"
14
+ ReceiptProcessingStatusFailed ReceiptProcessingStatus = "failed"
15
+ )
16
+
17
+ // InboxRecord 保存全局唯一事件身份及其规范信封。
18
+ type InboxRecord struct {
19
+ ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
20
+ SourceSystem string `gorm:"column:source_system;type:varchar(191);not null;uniqueIndex:uk_sys_integration_event_inbox_identity,priority:1"`
21
+ EventID string `gorm:"column:event_id;type:varchar(191);not null;uniqueIndex:uk_sys_integration_event_inbox_identity,priority:2"`
22
+ EventType string `gorm:"column:event_type;type:varchar(191);not null"`
23
+ SchemaVersion string `gorm:"column:schema_version;type:varchar(32);not null"`
24
+ ProductCode string `gorm:"column:product_code;type:varchar(50);not null"`
25
+ AggregateType string `gorm:"column:aggregate_type;type:varchar(128);not null"`
26
+ AggregateRef string `gorm:"column:aggregate_ref;type:varchar(191);not null"`
27
+ AggregateVersion int64 `gorm:"column:aggregate_version;not null"`
28
+ PayloadHash string `gorm:"column:payload_hash;type:char(64);not null"`
29
+ EventPayload json.RawMessage `gorm:"column:event_payload;type:jsonb;not null"`
30
+ ReceivedAt time.Time `gorm:"column:received_at;type:timestamptz(3);not null"`
31
+ CreatedAt time.Time `gorm:"column:created_at;type:timestamptz(3);not null"`
32
+ }
33
+
34
+ // TableName 指定全局入站事件身份表。
35
+ func (InboxRecord) TableName() string { return "sys_integration_event_inbox" }
36
+
37
+ // ReceiptRecord 保存单个订阅者对一个全局事件的处理回执。
38
+ type ReceiptRecord struct {
39
+ ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
40
+ SourceSystem string `gorm:"column:source_system;type:varchar(191);not null;uniqueIndex:uk_sys_integration_event_receipts_subscriber_event,priority:1"`
41
+ SubscriberCode string `gorm:"column:subscriber_code;type:varchar(64);not null;uniqueIndex:uk_sys_integration_event_receipts_subscriber_event,priority:2"`
42
+ EventID string `gorm:"column:event_id;type:varchar(191);not null;uniqueIndex:uk_sys_integration_event_receipts_subscriber_event,priority:3"`
43
+ Status ReceiptProcessingStatus `gorm:"column:status;type:varchar(20);not null"`
44
+ ReceiptStatus *ReceiptOutcome `gorm:"column:receipt_status;type:varchar(20)"`
45
+ ErrorCode *string `gorm:"column:error_code;type:varchar(120)"`
46
+ ProcessingStartedAt time.Time `gorm:"column:processing_started_at;type:timestamptz(3);not null"`
47
+ ProcessedAt *time.Time `gorm:"column:processed_at;type:timestamptz(3)"`
48
+ CreatedAt time.Time `gorm:"column:created_at;type:timestamptz(3);not null"`
49
+ UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz(3);not null"`
50
+ }
51
+
52
+ // TableName 指定订阅者事件回执表。
53
+ func (ReceiptRecord) TableName() string { return "sys_integration_event_receipts" }
54
+
55
+ // AggregateCheckpoint 保存单个订阅者对一个上游聚合的最后连续版本。
56
+ type AggregateCheckpoint struct {
57
+ ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
58
+ SourceSystem string `gorm:"column:source_system;type:varchar(191);not null;uniqueIndex:uk_sys_integration_aggregate_checkpoints_scope,priority:1"`
59
+ SubscriberCode string `gorm:"column:subscriber_code;type:varchar(64);not null;uniqueIndex:uk_sys_integration_aggregate_checkpoints_scope,priority:2"`
60
+ AggregateType string `gorm:"column:aggregate_type;type:varchar(128);not null;uniqueIndex:uk_sys_integration_aggregate_checkpoints_scope,priority:3"`
61
+ AggregateRef string `gorm:"column:aggregate_ref;type:varchar(191);not null;uniqueIndex:uk_sys_integration_aggregate_checkpoints_scope,priority:4"`
62
+ LastAggregateVersion int64 `gorm:"column:last_aggregate_version;not null"`
63
+ LastEventID string `gorm:"column:last_event_id;type:varchar(191);not null"`
64
+ LastPayloadHash string `gorm:"column:last_payload_hash;type:char(64);not null"`
65
+ RevisionNo int64 `gorm:"column:revision_no;not null"`
66
+ CreatedAt time.Time `gorm:"column:created_at;type:timestamptz(3);not null"`
67
+ UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz(3);not null"`
68
+ }
69
+
70
+ // TableName 指定订阅者聚合连续水位表。
71
+ func (AggregateCheckpoint) TableName() string { return "sys_integration_aggregate_checkpoints" }
@@ -0,0 +1,193 @@
1
+ package integrationevent
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "strings"
8
+ "time"
9
+
10
+ "gorm.io/gorm"
11
+ "gorm.io/gorm/clause"
12
+
13
+ "{{module}}/internal/core/database"
14
+ )
15
+
16
+ var (
17
+ inboxColumns = []string{
18
+ "id", "source_system", "event_id", "event_type", "schema_version", "product_code",
19
+ "aggregate_type", "aggregate_ref", "aggregate_version", "payload_hash", "event_payload",
20
+ "received_at", "created_at",
21
+ }
22
+ receiptColumns = []string{
23
+ "id", "source_system", "subscriber_code", "event_id", "status", "receipt_status", "error_code",
24
+ "processing_started_at", "processed_at", "created_at", "updated_at",
25
+ }
26
+ checkpointColumns = []string{
27
+ "id", "source_system", "subscriber_code", "aggregate_type", "aggregate_ref", "last_aggregate_version",
28
+ "last_event_id", "last_payload_hash", "revision_no", "created_at", "updated_at",
29
+ }
30
+ )
31
+
32
+ type repository struct {
33
+ db *gorm.DB
34
+ }
35
+
36
+ // NewRepository 创建入站事件事务仓储。
37
+ func NewRepository(db *gorm.DB) Repository {
38
+ return &repository{db: db}
39
+ }
40
+
41
+ func (r *repository) CreateOrLoadInbox(ctx context.Context, record *InboxRecord) (*InboxRecord, bool, error) {
42
+ if err := database.RequireTransaction(ctx); err != nil {
43
+ return nil, false, err
44
+ }
45
+ db := database.GetDB(ctx, r.db)
46
+ result := db.Clauses(clause.OnConflict{
47
+ Columns: []clause.Column{{Name: "source_system"}, {Name: "event_id"}},
48
+ DoNothing: true,
49
+ }).Create(record)
50
+ if result.Error != nil {
51
+ return nil, false, fmt.Errorf("create integration event inbox record: %w", result.Error)
52
+ }
53
+ if result.RowsAffected == 1 {
54
+ return record, true, nil
55
+ }
56
+
57
+ var existing InboxRecord
58
+ if err := db.Select(inboxColumns).
59
+ Clauses(clause.Locking{Strength: "UPDATE"}).
60
+ Where("source_system = ? AND event_id = ?", record.SourceSystem, record.EventID).
61
+ Take(&existing).Error; err != nil {
62
+ return nil, false, fmt.Errorf("load integration event inbox record: %w", err)
63
+ }
64
+ return &existing, false, nil
65
+ }
66
+
67
+ func (r *repository) CreateOrLockReceipt(ctx context.Context, record *ReceiptRecord) (*ReceiptRecord, bool, error) {
68
+ if err := database.RequireTransaction(ctx); err != nil {
69
+ return nil, false, err
70
+ }
71
+ db := database.GetDB(ctx, r.db)
72
+ result := db.Clauses(clause.OnConflict{
73
+ Columns: []clause.Column{
74
+ {Name: "source_system"},
75
+ {Name: "subscriber_code"},
76
+ {Name: "event_id"},
77
+ },
78
+ DoNothing: true,
79
+ }).Create(record)
80
+ if result.Error != nil {
81
+ return nil, false, fmt.Errorf("create integration event receipt: %w", result.Error)
82
+ }
83
+
84
+ var locked ReceiptRecord
85
+ if err := db.Select(receiptColumns).
86
+ Clauses(clause.Locking{Strength: "UPDATE"}).
87
+ Where("source_system = ? AND subscriber_code = ? AND event_id = ?", record.SourceSystem, record.SubscriberCode, record.EventID).
88
+ Take(&locked).Error; err != nil {
89
+ return nil, false, fmt.Errorf("lock integration event receipt: %w", err)
90
+ }
91
+ return &locked, result.RowsAffected == 1, nil
92
+ }
93
+
94
+ func (r *repository) LockAggregateScope(ctx context.Context, sourceSystem, subscriberCode, aggregateType, aggregateRef string) error {
95
+ if err := database.RequireTransaction(ctx); err != nil {
96
+ return err
97
+ }
98
+ db := database.GetDB(ctx, r.db)
99
+ switch db.Dialector.Name() {
100
+ case "postgres":
101
+ scope := strings.Join([]string{sourceSystem, subscriberCode, aggregateType, aggregateRef}, "\x1f")
102
+ if err := db.Exec("SELECT pg_advisory_xact_lock(('x' || substr(md5(?), 1, 16))::bit(64)::bigint)", scope).Error; err != nil {
103
+ return fmt.Errorf("lock integration event aggregate scope: %w", err)
104
+ }
105
+ return nil
106
+ case "sqlite":
107
+ // SQLite 的写事务串行化由数据库引擎保证,此分支仅用于模板持久化测试。
108
+ return nil
109
+ default:
110
+ return fmt.Errorf("unsupported database dialect for integration event aggregate lock: %s", db.Dialector.Name())
111
+ }
112
+ }
113
+
114
+ func (r *repository) FindCheckpointForUpdate(ctx context.Context, sourceSystem, subscriberCode, aggregateType, aggregateRef string) (*AggregateCheckpoint, error) {
115
+ if err := database.RequireTransaction(ctx); err != nil {
116
+ return nil, err
117
+ }
118
+ var checkpoint AggregateCheckpoint
119
+ err := database.GetDB(ctx, r.db).
120
+ Select(checkpointColumns).
121
+ Clauses(clause.Locking{Strength: "UPDATE"}).
122
+ Where("source_system = ? AND subscriber_code = ? AND aggregate_type = ? AND aggregate_ref = ?", sourceSystem, subscriberCode, aggregateType, aggregateRef).
123
+ Take(&checkpoint).Error
124
+ if errors.Is(err, gorm.ErrRecordNotFound) {
125
+ return nil, nil
126
+ }
127
+ if err != nil {
128
+ return nil, fmt.Errorf("load integration event aggregate checkpoint: %w", err)
129
+ }
130
+ return &checkpoint, nil
131
+ }
132
+
133
+ func (r *repository) CreateCheckpoint(ctx context.Context, checkpoint *AggregateCheckpoint) error {
134
+ if err := database.RequireTransaction(ctx); err != nil {
135
+ return err
136
+ }
137
+ if err := database.GetDB(ctx, r.db).Create(checkpoint).Error; err != nil {
138
+ return fmt.Errorf("create integration event aggregate checkpoint: %w", err)
139
+ }
140
+ return nil
141
+ }
142
+
143
+ func (r *repository) AdvanceCheckpoint(ctx context.Context, checkpoint *AggregateCheckpoint, expectedRevision int64) error {
144
+ if err := database.RequireTransaction(ctx); err != nil {
145
+ return err
146
+ }
147
+ result := database.GetDB(ctx, r.db).
148
+ Model(&AggregateCheckpoint{}).
149
+ Where(
150
+ "id = ? AND revision_no = ? AND last_aggregate_version = ?",
151
+ checkpoint.ID,
152
+ expectedRevision,
153
+ checkpoint.LastAggregateVersion-1,
154
+ ).
155
+ Updates(map[string]any{
156
+ "last_aggregate_version": checkpoint.LastAggregateVersion,
157
+ "last_event_id": checkpoint.LastEventID,
158
+ "last_payload_hash": checkpoint.LastPayloadHash,
159
+ "revision_no": checkpoint.RevisionNo,
160
+ "updated_at": checkpoint.UpdatedAt,
161
+ })
162
+ if result.Error != nil {
163
+ return fmt.Errorf("advance integration event aggregate checkpoint: %w", result.Error)
164
+ }
165
+ if result.RowsAffected != 1 {
166
+ return fmt.Errorf("integration event aggregate checkpoint revision conflict")
167
+ }
168
+ return nil
169
+ }
170
+
171
+ func (r *repository) MarkReceiptProcessed(ctx context.Context, id int64, outcome ReceiptOutcome, processedAt time.Time) error {
172
+ if err := database.RequireTransaction(ctx); err != nil {
173
+ return err
174
+ }
175
+ result := database.GetDB(ctx, r.db).
176
+ Model(&ReceiptRecord{}).
177
+ Where("id = ?", id).
178
+ Where("status = ?", ReceiptProcessingStatusProcessing).
179
+ Updates(map[string]any{
180
+ "status": ReceiptProcessingStatusProcessed,
181
+ "receipt_status": outcome,
182
+ "error_code": nil,
183
+ "processed_at": processedAt,
184
+ "updated_at": processedAt,
185
+ })
186
+ if result.Error != nil {
187
+ return fmt.Errorf("finalize integration event receipt: %w", result.Error)
188
+ }
189
+ if result.RowsAffected != 1 {
190
+ return fmt.Errorf("integration event receipt is not processing")
191
+ }
192
+ return nil
193
+ }