@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
@@ -0,0 +1,433 @@
1
+ package integrationevent
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "sync"
9
+ "testing"
10
+ "time"
11
+ )
12
+
13
+ func TestReceiverServiceProcessesContinuousVersionsAndReturnsDurableDuplicate(t *testing.T) {
14
+ service, repo, calls := newFakeReceiverService(t, "subscriber-a", nil)
15
+
16
+ first, err := service.Receive(context.Background(), fakeEnvelope("event-1", 1, json.RawMessage(`{"value":1,"nested":{"b":2,"a":1}}`)))
17
+ if err != nil {
18
+ t.Fatalf("Receive(version 1) error = %v", err)
19
+ }
20
+ if first.Outcome != ReceiptOutcomeProcessed {
21
+ t.Fatalf("Receive(version 1) outcome = %q, want processed", first.Outcome)
22
+ }
23
+ duplicate, err := service.Receive(context.Background(), fakeEnvelope("event-1", 1, json.RawMessage(`{"nested":{"a":1,"b":2},"value":1}`)))
24
+ if err != nil {
25
+ t.Fatalf("Receive(duplicate) error = %v", err)
26
+ }
27
+ if duplicate.Outcome != ReceiptOutcomeDuplicate {
28
+ t.Fatalf("Receive(duplicate) outcome = %q, want duplicate", duplicate.Outcome)
29
+ }
30
+ second, err := service.Receive(context.Background(), fakeEnvelope("event-2", 2, json.RawMessage(`{"value":2}`)))
31
+ if err != nil {
32
+ t.Fatalf("Receive(version 2) error = %v", err)
33
+ }
34
+ if second.Outcome != ReceiptOutcomeProcessed {
35
+ t.Fatalf("Receive(version 2) outcome = %q, want processed", second.Outcome)
36
+ }
37
+ if *calls != 2 {
38
+ t.Fatalf("handler calls = %d, want 2", *calls)
39
+ }
40
+ checkpoint := repo.checkpoints[fakeCheckpointKey("subscriber-a")]
41
+ if checkpoint == nil || checkpoint.LastAggregateVersion != 2 || checkpoint.RevisionNo != 2 {
42
+ t.Fatalf("checkpoint = %#v, want version/revision 2", checkpoint)
43
+ }
44
+ }
45
+
46
+ func TestReceiverServiceRejectsGlobalIdentityWithDifferentEnvelope(t *testing.T) {
47
+ service, repo, calls := newFakeReceiverService(t, "subscriber-a", nil)
48
+ if _, err := service.Receive(context.Background(), fakeEnvelope("event-1", 1, json.RawMessage(`{"value":1}`))); err != nil {
49
+ t.Fatalf("Receive(first) error = %v", err)
50
+ }
51
+ _, err := service.Receive(context.Background(), fakeEnvelope("event-1", 1, json.RawMessage(`{"value":2}`)))
52
+ if !errors.Is(err, ErrIdentityConflict) {
53
+ t.Fatalf("Receive(conflict) error = %v, want ErrIdentityConflict", err)
54
+ }
55
+ if *calls != 1 {
56
+ t.Fatalf("handler calls = %d, want 1", *calls)
57
+ }
58
+ if len(repo.inboxes) != 1 || len(repo.receipts) != 1 {
59
+ t.Fatalf("persistent counts = inbox %d receipt %d, want 1/1", len(repo.inboxes), len(repo.receipts))
60
+ }
61
+ }
62
+
63
+ func TestReceiverServiceVersionGapRollsBackInboxAndReceipt(t *testing.T) {
64
+ service, repo, calls := newFakeReceiverService(t, "subscriber-a", nil)
65
+ _, err := service.Receive(context.Background(), fakeEnvelope("event-2", 2, json.RawMessage(`{"value":2}`)))
66
+ var gap *VersionGapError
67
+ if !errors.As(err, &gap) || !IsRetryable(err) {
68
+ t.Fatalf("Receive(gap) error = %v, want retryable VersionGapError", err)
69
+ }
70
+ if gap.Expected != 1 || gap.Actual != 2 {
71
+ t.Fatalf("gap = %#v, want expected 1 actual 2", gap)
72
+ }
73
+ if len(repo.inboxes) != 0 || len(repo.receipts) != 0 || len(repo.checkpoints) != 0 {
74
+ t.Fatalf("gap persisted state: inbox=%d receipt=%d checkpoint=%d", len(repo.inboxes), len(repo.receipts), len(repo.checkpoints))
75
+ }
76
+ if *calls != 0 {
77
+ t.Fatalf("handler calls = %d, want 0", *calls)
78
+ }
79
+ }
80
+
81
+ func TestReceiverServiceRejectsOldUnprovenVersion(t *testing.T) {
82
+ service, repo, calls := newFakeReceiverService(t, "subscriber-a", nil)
83
+ if _, err := service.Receive(context.Background(), fakeEnvelope("event-1", 1, json.RawMessage(`{"value":1}`))); err != nil {
84
+ t.Fatalf("Receive(version 1) error = %v", err)
85
+ }
86
+ if _, err := service.Receive(context.Background(), fakeEnvelope("event-2", 2, json.RawMessage(`{"value":2}`))); err != nil {
87
+ t.Fatalf("Receive(version 2) error = %v", err)
88
+ }
89
+ _, err := service.Receive(context.Background(), fakeEnvelope("event-other", 1, json.RawMessage(`{"value":1}`)))
90
+ if !errors.Is(err, ErrVersionConflict) {
91
+ t.Fatalf("Receive(old) error = %v, want ErrVersionConflict", err)
92
+ }
93
+ if _, exists := repo.inboxes["source-a\x1fevent-other"]; exists {
94
+ t.Fatal("old unproven event inbox survived rollback")
95
+ }
96
+ if *calls != 2 {
97
+ t.Fatalf("handler calls = %d, want 2", *calls)
98
+ }
99
+ }
100
+
101
+ func TestReceiverServiceHandlerFailureRollsBackAllCoreState(t *testing.T) {
102
+ handlerErr := fakeRetryableHandlerError{cause: errors.New("projection unavailable")}
103
+ service, repo, calls := newFakeReceiverService(t, "subscriber-a", handlerErr)
104
+ _, err := service.Receive(context.Background(), fakeEnvelope("event-1", 1, json.RawMessage(`{"value":1}`)))
105
+ var receivedHandlerErr *HandlerError
106
+ if !errors.As(err, &receivedHandlerErr) || !errors.Is(err, handlerErr) || !IsRetryable(err) {
107
+ t.Fatalf("Receive(handler failure) error = %v, want retryable HandlerError", err)
108
+ }
109
+ if len(repo.inboxes) != 0 || len(repo.receipts) != 0 || len(repo.checkpoints) != 0 {
110
+ t.Fatalf("handler failure persisted state: inbox=%d receipt=%d checkpoint=%d", len(repo.inboxes), len(repo.receipts), len(repo.checkpoints))
111
+ }
112
+ if *calls != 1 {
113
+ t.Fatalf("handler calls = %d, want 1", *calls)
114
+ }
115
+ }
116
+
117
+ type fakeRetryableHandlerError struct {
118
+ cause error
119
+ }
120
+
121
+ func (e fakeRetryableHandlerError) Error() string { return e.cause.Error() }
122
+
123
+ func (e fakeRetryableHandlerError) Unwrap() error { return e.cause }
124
+
125
+ func (e fakeRetryableHandlerError) Retryable() bool { return true }
126
+
127
+ func TestReceiverServiceKeepsSubscriberReceiptsAndCheckpointsIndependent(t *testing.T) {
128
+ repo := newFakeRepository()
129
+ txManager := &fakeTransactionManager{repo: repo}
130
+ first, firstCalls := newFakeReceiverServiceWithDependencies(t, repo, txManager, "subscriber-a")
131
+ second, secondCalls := newFakeReceiverServiceWithDependencies(t, repo, txManager, "subscriber-b")
132
+ envelope := fakeEnvelope("event-1", 1, json.RawMessage(`{"value":1}`))
133
+
134
+ if _, err := first.Receive(context.Background(), envelope); err != nil {
135
+ t.Fatalf("first subscriber Receive() error = %v", err)
136
+ }
137
+ if _, err := second.Receive(context.Background(), envelope); err != nil {
138
+ t.Fatalf("second subscriber Receive() error = %v", err)
139
+ }
140
+ if len(repo.inboxes) != 1 || len(repo.receipts) != 2 || len(repo.checkpoints) != 2 {
141
+ t.Fatalf("persistent counts = inbox %d receipt %d checkpoint %d, want 1/2/2", len(repo.inboxes), len(repo.receipts), len(repo.checkpoints))
142
+ }
143
+ if *firstCalls != 1 || *secondCalls != 1 {
144
+ t.Fatalf("handler calls = %d/%d, want 1/1", *firstCalls, *secondCalls)
145
+ }
146
+ }
147
+
148
+ func TestReceiverServiceRequiresExactRegisteredHandler(t *testing.T) {
149
+ repo := newFakeRepository()
150
+ registry := NewHandlerRegistry()
151
+ service, err := NewReceiverService(repo, &fakeTransactionManager{repo: repo}, registry, ReceiverConfig{SubscriberCode: "subscriber-a"})
152
+ if err != nil {
153
+ t.Fatalf("NewReceiverService() error = %v", err)
154
+ }
155
+ _, err = service.Receive(context.Background(), fakeEnvelope("event-1", 1, json.RawMessage(`{"value":1}`)))
156
+ if !errors.Is(err, ErrHandlerNotRegistered) {
157
+ t.Fatalf("Receive() error = %v, want ErrHandlerNotRegistered", err)
158
+ }
159
+ if len(repo.inboxes) != 0 {
160
+ t.Fatalf("unregistered event persisted %d inbox rows", len(repo.inboxes))
161
+ }
162
+ }
163
+
164
+ func TestReceiverServiceRejectsInvalidEnvelopeBeforeTransaction(t *testing.T) {
165
+ service, repo, calls := newFakeReceiverService(t, "subscriber-a", nil)
166
+ testCases := []struct {
167
+ name string
168
+ mutate func(*Envelope)
169
+ }{
170
+ {name: "nonpositive version", mutate: func(envelope *Envelope) { envelope.AggregateVersion = 0 }},
171
+ {name: "invalid product code", mutate: func(envelope *Envelope) { envelope.ProductCode = "Product A" }},
172
+ {name: "non-object envelope", mutate: func(envelope *Envelope) { envelope.CanonicalEnvelope = json.RawMessage(`[]`) }},
173
+ {name: "invalid JSON", mutate: func(envelope *Envelope) { envelope.CanonicalEnvelope = json.RawMessage(`{"id":`) }},
174
+ }
175
+ for _, testCase := range testCases {
176
+ t.Run(testCase.name, func(t *testing.T) {
177
+ envelope := fakeEnvelope("event-1", 1, json.RawMessage(`{"value":1}`))
178
+ testCase.mutate(&envelope)
179
+ _, err := service.Receive(context.Background(), envelope)
180
+ if !errors.Is(err, ErrEnvelopeInvalid) {
181
+ t.Fatalf("Receive() error = %v, want ErrEnvelopeInvalid", err)
182
+ }
183
+ })
184
+ }
185
+ if len(repo.inboxes) != 0 || *calls != 0 {
186
+ t.Fatalf("invalid envelopes changed state: inbox=%d handler_calls=%d", len(repo.inboxes), *calls)
187
+ }
188
+ }
189
+
190
+ func TestReceiverServiceAdvancesCheckpointForConsumerRegisteredNoOpHandler(t *testing.T) {
191
+ repo := newFakeRepository()
192
+ registry := NewHandlerRegistry()
193
+ if err := registry.Register(HandlerRegistration{
194
+ SourceSystem: "source-a", EventType: "resource.changed", SchemaVersion: "1.0.0",
195
+ Handler: HandlerFunc(func(context.Context, Envelope) error {
196
+ return nil
197
+ }),
198
+ }); err != nil {
199
+ t.Fatalf("Register() error = %v", err)
200
+ }
201
+ service, err := NewReceiverService(
202
+ repo,
203
+ &fakeTransactionManager{repo: repo},
204
+ registry,
205
+ ReceiverConfig{SubscriberCode: "subscriber-a"},
206
+ )
207
+ if err != nil {
208
+ t.Fatalf("NewReceiverService() error = %v", err)
209
+ }
210
+ if _, err := service.Receive(context.Background(), fakeEnvelope("event-1", 1, json.RawMessage(`{"value":1}`))); err != nil {
211
+ t.Fatalf("Receive() error = %v", err)
212
+ }
213
+ checkpoint := repo.checkpoints[fakeCheckpointKey("subscriber-a")]
214
+ if checkpoint == nil || checkpoint.LastAggregateVersion != 1 || checkpoint.LastEventID != "event-1" {
215
+ t.Fatalf("checkpoint = %#v, want no-op event at version 1", checkpoint)
216
+ }
217
+ }
218
+
219
+ func TestHandlerRegistryRejectsDuplicateExactRegistration(t *testing.T) {
220
+ registry := NewHandlerRegistry()
221
+ registration := HandlerRegistration{
222
+ SourceSystem: "source-a", EventType: "resource.changed", SchemaVersion: "1.0.0",
223
+ Handler: HandlerFunc(func(context.Context, Envelope) error { return nil }),
224
+ }
225
+ if err := registry.Register(registration); err != nil {
226
+ t.Fatalf("Register(first) error = %v", err)
227
+ }
228
+ if err := registry.Register(registration); err == nil {
229
+ t.Fatal("Register(duplicate) error = nil, want duplicate rejection")
230
+ }
231
+ }
232
+
233
+ func newFakeReceiverService(t *testing.T, subscriberCode string, handlerErr error) (*ReceiverService, *fakeRepository, *int) {
234
+ t.Helper()
235
+ repo := newFakeRepository()
236
+ txManager := &fakeTransactionManager{repo: repo}
237
+ service, calls := newFakeReceiverServiceWithDependencies(t, repo, txManager, subscriberCode)
238
+ if handlerErr != nil {
239
+ registry := NewHandlerRegistry()
240
+ calls = new(int)
241
+ if err := registry.Register(HandlerRegistration{
242
+ SourceSystem: "source-a", EventType: "resource.changed", SchemaVersion: "1.0.0",
243
+ Handler: HandlerFunc(func(context.Context, Envelope) error {
244
+ (*calls)++
245
+ return handlerErr
246
+ }),
247
+ }); err != nil {
248
+ t.Fatalf("Register() error = %v", err)
249
+ }
250
+ var err error
251
+ service, err = NewReceiverService(repo, txManager, registry, ReceiverConfig{SubscriberCode: subscriberCode})
252
+ if err != nil {
253
+ t.Fatalf("NewReceiverService() error = %v", err)
254
+ }
255
+ }
256
+ return service, repo, calls
257
+ }
258
+
259
+ func newFakeReceiverServiceWithDependencies(t *testing.T, repo *fakeRepository, txManager *fakeTransactionManager, subscriberCode string) (*ReceiverService, *int) {
260
+ t.Helper()
261
+ registry := NewHandlerRegistry()
262
+ calls := new(int)
263
+ if err := registry.Register(HandlerRegistration{
264
+ SourceSystem: "source-a", EventType: "resource.changed", SchemaVersion: "1.0.0",
265
+ Handler: HandlerFunc(func(context.Context, Envelope) error {
266
+ (*calls)++
267
+ return nil
268
+ }),
269
+ }); err != nil {
270
+ t.Fatalf("Register() error = %v", err)
271
+ }
272
+ service, err := NewReceiverService(repo, txManager, registry, ReceiverConfig{SubscriberCode: subscriberCode})
273
+ if err != nil {
274
+ t.Fatalf("NewReceiverService() error = %v", err)
275
+ }
276
+ service.now = func() time.Time { return time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC) }
277
+ return service, calls
278
+ }
279
+
280
+ func fakeEnvelope(eventID string, version int64, data json.RawMessage) Envelope {
281
+ raw := json.RawMessage(fmt.Sprintf(`{"specversion":"1.0","id":%q,"source":"source-a","type":"resource.changed","dataschema":"schema:1.0.0","aggregateversion":%d,"data":%s}`, eventID, version, data))
282
+ return Envelope{
283
+ SourceSystem: "source-a",
284
+ EventID: eventID,
285
+ EventType: "resource.changed",
286
+ SchemaVersion: "1.0.0",
287
+ ProductCode: "product_a",
288
+ AggregateType: "resource",
289
+ AggregateRef: "resource-1",
290
+ AggregateVersion: version,
291
+ CanonicalEnvelope: raw,
292
+ }
293
+ }
294
+
295
+ func fakeCheckpointKey(subscriberCode string) string {
296
+ return "source-a\x1f" + subscriberCode + "\x1fresource\x1fresource-1"
297
+ }
298
+
299
+ type fakeRepository struct {
300
+ nextID int64
301
+ inboxes map[string]*InboxRecord
302
+ receipts map[string]*ReceiptRecord
303
+ checkpoints map[string]*AggregateCheckpoint
304
+ }
305
+
306
+ func newFakeRepository() *fakeRepository {
307
+ return &fakeRepository{
308
+ inboxes: make(map[string]*InboxRecord),
309
+ receipts: make(map[string]*ReceiptRecord),
310
+ checkpoints: make(map[string]*AggregateCheckpoint),
311
+ }
312
+ }
313
+
314
+ func (r *fakeRepository) CreateOrLoadInbox(_ context.Context, record *InboxRecord) (*InboxRecord, bool, error) {
315
+ key := record.SourceSystem + "\x1f" + record.EventID
316
+ if existing, found := r.inboxes[key]; found {
317
+ clone := *existing
318
+ clone.EventPayload = append(json.RawMessage(nil), existing.EventPayload...)
319
+ return &clone, false, nil
320
+ }
321
+ r.nextID++
322
+ clone := *record
323
+ clone.ID = r.nextID
324
+ clone.EventPayload = append(json.RawMessage(nil), record.EventPayload...)
325
+ r.inboxes[key] = &clone
326
+ returned := clone
327
+ return &returned, true, nil
328
+ }
329
+
330
+ func (r *fakeRepository) CreateOrLockReceipt(_ context.Context, record *ReceiptRecord) (*ReceiptRecord, bool, error) {
331
+ key := record.SourceSystem + "\x1f" + record.SubscriberCode + "\x1f" + record.EventID
332
+ if existing, found := r.receipts[key]; found {
333
+ clone := *existing
334
+ return &clone, false, nil
335
+ }
336
+ r.nextID++
337
+ clone := *record
338
+ clone.ID = r.nextID
339
+ r.receipts[key] = &clone
340
+ returned := clone
341
+ return &returned, true, nil
342
+ }
343
+
344
+ func (r *fakeRepository) LockAggregateScope(context.Context, string, string, string, string) error {
345
+ return nil
346
+ }
347
+
348
+ func (r *fakeRepository) FindCheckpointForUpdate(_ context.Context, sourceSystem, subscriberCode, aggregateType, aggregateRef string) (*AggregateCheckpoint, error) {
349
+ key := sourceSystem + "\x1f" + subscriberCode + "\x1f" + aggregateType + "\x1f" + aggregateRef
350
+ checkpoint := r.checkpoints[key]
351
+ if checkpoint == nil {
352
+ return nil, nil
353
+ }
354
+ clone := *checkpoint
355
+ return &clone, nil
356
+ }
357
+
358
+ func (r *fakeRepository) CreateCheckpoint(_ context.Context, checkpoint *AggregateCheckpoint) error {
359
+ key := checkpoint.SourceSystem + "\x1f" + checkpoint.SubscriberCode + "\x1f" + checkpoint.AggregateType + "\x1f" + checkpoint.AggregateRef
360
+ if _, exists := r.checkpoints[key]; exists {
361
+ return errors.New("checkpoint exists")
362
+ }
363
+ r.nextID++
364
+ clone := *checkpoint
365
+ clone.ID = r.nextID
366
+ r.checkpoints[key] = &clone
367
+ return nil
368
+ }
369
+
370
+ func (r *fakeRepository) AdvanceCheckpoint(_ context.Context, checkpoint *AggregateCheckpoint, expectedRevision int64) error {
371
+ key := checkpoint.SourceSystem + "\x1f" + checkpoint.SubscriberCode + "\x1f" + checkpoint.AggregateType + "\x1f" + checkpoint.AggregateRef
372
+ existing := r.checkpoints[key]
373
+ if existing == nil || existing.RevisionNo != expectedRevision {
374
+ return errors.New("checkpoint revision conflict")
375
+ }
376
+ clone := *checkpoint
377
+ r.checkpoints[key] = &clone
378
+ return nil
379
+ }
380
+
381
+ func (r *fakeRepository) MarkReceiptProcessed(_ context.Context, id int64, outcome ReceiptOutcome, processedAt time.Time) error {
382
+ for key, receipt := range r.receipts {
383
+ if receipt.ID != id {
384
+ continue
385
+ }
386
+ if receipt.Status != ReceiptProcessingStatusProcessing {
387
+ return errors.New("receipt is not processing")
388
+ }
389
+ clone := *receipt
390
+ clone.Status = ReceiptProcessingStatusProcessed
391
+ clone.ReceiptStatus = &outcome
392
+ clone.ProcessedAt = &processedAt
393
+ clone.UpdatedAt = processedAt
394
+ r.receipts[key] = &clone
395
+ return nil
396
+ }
397
+ return errors.New("receipt not found")
398
+ }
399
+
400
+ func (r *fakeRepository) clone() *fakeRepository {
401
+ clone := newFakeRepository()
402
+ clone.nextID = r.nextID
403
+ for key, record := range r.inboxes {
404
+ copied := *record
405
+ copied.EventPayload = append(json.RawMessage(nil), record.EventPayload...)
406
+ clone.inboxes[key] = &copied
407
+ }
408
+ for key, record := range r.receipts {
409
+ copied := *record
410
+ clone.receipts[key] = &copied
411
+ }
412
+ for key, record := range r.checkpoints {
413
+ copied := *record
414
+ clone.checkpoints[key] = &copied
415
+ }
416
+ return clone
417
+ }
418
+
419
+ type fakeTransactionManager struct {
420
+ mu sync.Mutex
421
+ repo *fakeRepository
422
+ }
423
+
424
+ func (m *fakeTransactionManager) WithTransaction(ctx context.Context, fn func(context.Context) error) error {
425
+ m.mu.Lock()
426
+ defer m.mu.Unlock()
427
+ snapshot := m.repo.clone()
428
+ if err := fn(ctx); err != nil {
429
+ *m.repo = *snapshot
430
+ return err
431
+ }
432
+ return nil
433
+ }
@@ -0,0 +1,67 @@
1
+ package integrationevent
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ )
8
+
9
+ // Envelope 是通过 transport 信任、schema 与字段一致性校验后的入站事件。
10
+ // CanonicalEnvelope 必须包含完整事件信封对象;核心层负责 RFC 8785 规范化与摘要。
11
+ type Envelope struct {
12
+ SourceSystem string
13
+ EventID string
14
+ EventType string
15
+ SchemaVersion string
16
+ ProductCode string
17
+ AggregateType string
18
+ AggregateRef string
19
+ AggregateVersion int64
20
+ CanonicalEnvelope json.RawMessage
21
+ }
22
+
23
+ // Handler 由消费者为精确 source/type/schema 注册,并在入站事务内执行。
24
+ // 可重试失败必须由消费者错误显式实现 Retryable() bool,核心层不猜测错误类别。
25
+ type Handler interface {
26
+ Handle(ctx context.Context, envelope Envelope) error
27
+ }
28
+
29
+ // HandlerFunc 将函数适配为入站事件处理器。
30
+ type HandlerFunc func(ctx context.Context, envelope Envelope) error
31
+
32
+ // Handle 执行入站事件函数。
33
+ func (f HandlerFunc) Handle(ctx context.Context, envelope Envelope) error {
34
+ if f == nil {
35
+ return fmt.Errorf("integration event handler function is required")
36
+ }
37
+ return f(ctx, envelope)
38
+ }
39
+
40
+ // HandlerRegistration 声明一个精确版本处理器;typed handler 与 no-op 均由消费者提供。
41
+ type HandlerRegistration struct {
42
+ SourceSystem string
43
+ EventType string
44
+ SchemaVersion string
45
+ Handler Handler
46
+ }
47
+
48
+ // ReceiptOutcome 是提交后返回给发送方的持久处理结论。
49
+ type ReceiptOutcome string
50
+
51
+ const (
52
+ ReceiptOutcomeProcessed ReceiptOutcome = "processed"
53
+ ReceiptOutcomeDuplicate ReceiptOutcome = "duplicate"
54
+ )
55
+
56
+ // ReceiveResult 返回已提交的订阅者回执身份与处理结论。
57
+ type ReceiveResult struct {
58
+ SourceSystem string
59
+ SubscriberCode string
60
+ EventID string
61
+ Outcome ReceiptOutcome
62
+ }
63
+
64
+ // TransactionManager 定义可加入外层事务的事务执行端口。
65
+ type TransactionManager interface {
66
+ WithTransaction(ctx context.Context, fn func(context.Context) error) error
67
+ }
@@ -1,5 +1,8 @@
1
1
  BEGIN;
2
2
  DROP TABLE public.sys_command_replay_records CASCADE;
3
+ DROP TABLE public.sys_integration_aggregate_checkpoints CASCADE;
4
+ DROP TABLE public.sys_integration_event_receipts CASCADE;
5
+ DROP TABLE public.sys_integration_event_inbox CASCADE;
3
6
  DROP TABLE public.sys_outbox_event_deliveries CASCADE;
4
7
  DROP TABLE public.sys_outbox_events CASCADE;
5
8
  DROP TABLE public.sys_audit_event_inbox CASCADE;
@@ -10,4 +13,7 @@ DROP TABLE public.sys_access_logs CASCADE;
10
13
  DROP FUNCTION public.reject_sys_outbox_event_delivery_identity_change();
11
14
  DROP FUNCTION public.reject_sys_outbox_event_schema_version_change();
12
15
  DROP FUNCTION public.sys_operation_logs_finalization_guard();
16
+ DROP FUNCTION public.guard_sys_integration_aggregate_checkpoint_transition();
17
+ DROP FUNCTION public.guard_sys_integration_event_receipt_transition();
18
+ DROP FUNCTION public.reject_sys_integration_event_inbox_change();
13
19
  COMMIT;