@stackonward/cli 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +255 -0
- package/dist/index.js +14335 -603
- package/dist/index.js.map +1 -1
- package/dist/resources/deploy/ci/github/caller.yml +41 -0
- package/dist/resources/deploy/ci/onedev/import.yml +40 -0
- package/dist/resources/deploy/nginx/cache.conf +17 -0
- package/dist/resources/deploy/nginx/compression.conf +23 -0
- package/dist/resources/deploy/nginx/proxy.conf +15 -0
- package/dist/resources/deploy/profiles/go-service/compose.yaml +19 -0
- package/dist/resources/deploy/profiles/nuxt-node/Dockerfile +17 -0
- package/dist/resources/deploy/profiles/static-files/nginx/site.conf.template +55 -0
- package/dist/resources/deploy/profiles/static-nginx/Dockerfile +18 -0
- package/dist/resources/deploy/profiles/static-nginx/nginx/default.conf.template +28 -0
- package/dist/resources/deploy/targets/compose-host/deploy.config.example.json +114 -0
- package/dist/resources/deploy/traffic/nginx/stackonward-nginx-traffic +96 -0
- package/dist/resources/registry.json +30 -30
- package/dist/resources/templates/base/.claude/docs/architecture.md +4 -0
- package/dist/resources/templates/base/AGENTS.md +15 -0
- package/dist/resources/templates/base/CLAUDE.md +15 -0
- package/dist/resources/templates/go-backend/.dockerignore +25 -3
- package/dist/resources/templates/go-backend/deploy.config.json +26 -0
- package/dist/resources/templates/go-backend/docs/runtime-provenance.json +9 -6
- package/dist/resources/templates/go-backend/docs/source-provenance.json +33 -22
- package/dist/resources/templates/go-backend/internal/common/enums/register_locale.go +7 -0
- package/dist/resources/templates/go-backend/internal/common/enums/register_locale_test.go +30 -0
- package/dist/resources/templates/go-backend/internal/common/locale/locale.go +102 -76
- package/dist/resources/templates/go-backend/internal/common/locale/locale_test.go +168 -45
- package/dist/resources/templates/go-backend/internal/core/commandreplay/model.go +5 -5
- package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence.go +20 -7
- package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence_test.go +51 -22
- package/dist/resources/templates/go-backend/internal/core/commandreplay/repository.go +2 -2
- package/dist/resources/templates/go-backend/internal/core/commandreplay/service.go +13 -24
- package/dist/resources/templates/go-backend/internal/core/commandreplay/service_persistence_test.go +98 -13
- package/dist/resources/templates/go-backend/internal/core/commandreplay/service_test.go +61 -36
- package/dist/resources/templates/go-backend/internal/core/commandreplay/types.go +6 -6
- package/dist/resources/templates/go-backend/internal/core/integrationevent/config.go +24 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/errors.go +53 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/model.go +71 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence.go +193 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_postgres_test.go +178 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_test.go +168 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/provider.go +16 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/registry.go +69 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/repository.go +17 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/service.go +283 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/service_test.go +433 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/types.go +67 -0
- package/dist/resources/templates/go-backend/migrations/000001_infrastructure.down.sql +6 -0
- package/dist/resources/templates/go-backend/migrations/000001_infrastructure.up.sql +176 -16
- package/dist/resources/templates/go-backend/tests/infrastructure.sql +109 -6
- package/dist/resources/templates/nuxt/.claude/docs/stack.md +5 -1
- package/dist/resources/templates/nuxt/.dockerignore +19 -0
- package/dist/resources/templates/nuxt/deploy.config.json +27 -0
- package/dist/resources/templates/nuxt/package.json +8 -8
- package/package.json +5 -3
|
@@ -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;
|