@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.
- package/README.md +248 -0
- package/dist/index.js +14124 -635
- package/dist/index.js.map +1 -1
- package/dist/resources/components/nuxt/site-source/app/app.vue +4 -2
- package/dist/resources/components/nuxt/site-source/app/assets/css/site-shell.css +50 -50
- package/dist/resources/components/nuxt/site-source/app/components/{site/ApplicationHeader.vue → app/Header.vue} +3 -3
- package/dist/resources/components/nuxt/site-source/app/components/{site/ApplicationShell.vue → app/Shell.vue} +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/{site/ApplicationSidebar.vue → app/Sidebar.vue} +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/{ui/CheckoutStatus.vue → checkout/Status.vue} +8 -3
- package/dist/resources/components/nuxt/site-source/app/components/{ui/CheckoutSummary.vue → checkout/Summary.vue} +11 -11
- package/dist/resources/components/nuxt/site-source/app/components/{site/CookieConsent.vue → consent/Banner.vue} +3 -3
- package/dist/resources/components/nuxt/site-source/app/components/{site/ContentArticlePage.vue → content/Article.vue} +3 -3
- package/dist/resources/components/nuxt/site-source/app/components/{site/ContentListPage.vue → content/List.vue} +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/{ui/GoogleSignInButton.vue → google/SignInButton.vue} +3 -3
- package/dist/resources/components/nuxt/site-source/app/components/{site/LocaleSwitcher.vue → locale/Menu.vue} +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/site/{MarketingFooter.vue → Footer.vue} +8 -8
- package/dist/resources/components/nuxt/site-source/app/components/site/{MarketingHeader.vue → Header.vue} +27 -27
- package/dist/resources/components/nuxt/site-source/app/components/site/Shell.vue +29 -0
- package/dist/resources/components/nuxt/site-source/app/components/{site/ThemeSwitcher.vue → theme/Menu.vue} +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/ui/{BaseButton.vue → Button.vue} +1 -8
- package/dist/resources/components/nuxt/site-source/app/components/ui/InfiniteScroll.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/{ui/YoutubeEmbed.vue → youtube/Embed.vue} +10 -2
- package/dist/resources/components/nuxt/site-source/app/composables/useConsent.ts +8 -10
- package/dist/resources/components/nuxt/site-source/app/composables/useNavigation.ts +9 -14
- package/dist/resources/components/nuxt/site-source/app/composables/useSeo.ts +3 -1
- package/dist/resources/components/nuxt/site-source/app/composables/useSiteShell.ts +19 -22
- package/dist/resources/components/nuxt/site-source/app/consent/types.ts +59 -0
- package/dist/resources/components/nuxt/site-source/app/content/types.ts +17 -0
- package/dist/resources/components/nuxt/site-source/app/layouts/app.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/layouts/default.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/navigation/types.ts +68 -0
- package/dist/resources/components/nuxt/site-source/app/plugins/consent.ts +2 -2
- package/dist/resources/components/nuxt/site-source/app/site/config.ts +35 -0
- package/dist/resources/components/registry.json +63 -51
- package/dist/resources/deploy/ci/github/caller.yml +38 -0
- package/dist/resources/deploy/ci/onedev/import.yml +14 -0
- package/dist/resources/deploy/nginx/cache.conf +17 -0
- package/dist/resources/deploy/nginx/compression.conf +23 -0
- package/dist/resources/deploy/nginx/proxy.conf +15 -0
- package/dist/resources/deploy/profiles/go-service/compose.yaml +19 -0
- package/dist/resources/deploy/profiles/nuxt-node/Dockerfile +17 -0
- package/dist/resources/deploy/profiles/static-files/nginx/site.conf.template +55 -0
- package/dist/resources/deploy/profiles/static-nginx/Dockerfile +18 -0
- package/dist/resources/deploy/profiles/static-nginx/nginx/default.conf.template +28 -0
- package/dist/resources/deploy/targets/compose-host/deploy.config.example.json +114 -0
- package/dist/resources/deploy/traffic/nginx/stackonward-nginx-traffic +96 -0
- package/dist/resources/registry.json +30 -30
- package/dist/resources/templates/base/.claude/docs/architecture.md +4 -0
- package/dist/resources/templates/base/AGENTS.md +15 -0
- package/dist/resources/templates/base/CLAUDE.md +15 -0
- package/dist/resources/templates/go-backend/.dockerignore +25 -3
- package/dist/resources/templates/go-backend/deploy.config.json +26 -0
- package/dist/resources/templates/go-backend/docs/runtime-provenance.json +9 -6
- package/dist/resources/templates/go-backend/docs/source-provenance.json +33 -22
- package/dist/resources/templates/go-backend/internal/common/enums/register_locale.go +7 -0
- package/dist/resources/templates/go-backend/internal/common/enums/register_locale_test.go +30 -0
- package/dist/resources/templates/go-backend/internal/common/locale/locale.go +102 -76
- package/dist/resources/templates/go-backend/internal/common/locale/locale_test.go +168 -45
- package/dist/resources/templates/go-backend/internal/core/commandreplay/model.go +5 -5
- package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence.go +20 -7
- package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence_test.go +51 -22
- package/dist/resources/templates/go-backend/internal/core/commandreplay/repository.go +2 -2
- package/dist/resources/templates/go-backend/internal/core/commandreplay/service.go +13 -24
- package/dist/resources/templates/go-backend/internal/core/commandreplay/service_persistence_test.go +98 -13
- package/dist/resources/templates/go-backend/internal/core/commandreplay/service_test.go +61 -36
- package/dist/resources/templates/go-backend/internal/core/commandreplay/types.go +6 -6
- package/dist/resources/templates/go-backend/internal/core/integrationevent/config.go +24 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/errors.go +53 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/model.go +71 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence.go +193 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_postgres_test.go +178 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_test.go +168 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/provider.go +16 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/registry.go +69 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/repository.go +17 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/service.go +283 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/service_test.go +433 -0
- package/dist/resources/templates/go-backend/internal/core/integrationevent/types.go +67 -0
- package/dist/resources/templates/go-backend/migrations/000001_infrastructure.down.sql +6 -0
- package/dist/resources/templates/go-backend/migrations/000001_infrastructure.up.sql +176 -16
- package/dist/resources/templates/go-backend/tests/infrastructure.sql +109 -6
- package/dist/resources/templates/nuxt/.claude/docs/stack.md +5 -1
- package/dist/resources/templates/nuxt/.dockerignore +19 -0
- package/dist/resources/templates/nuxt/README.md +3 -3
- package/dist/resources/templates/nuxt/app/app.config.ts +1 -1
- package/dist/resources/templates/nuxt/app/i18n/locales/en.json +1 -1
- package/dist/resources/templates/nuxt/app/pages/app/index.vue +1 -1
- package/dist/resources/templates/nuxt/app/pages/authors/[slug].vue +2 -2
- package/dist/resources/templates/nuxt/app/pages/blog/[slug].vue +4 -3
- package/dist/resources/templates/nuxt/app/pages/blog/category/[category].vue +2 -2
- package/dist/resources/templates/nuxt/app/pages/blog/index.vue +2 -2
- package/dist/resources/templates/nuxt/app/pages/legal/cookies.vue +3 -3
- package/dist/resources/templates/nuxt/app/pages/legal/privacy.vue +2 -2
- package/dist/resources/templates/nuxt/app/pages/legal/terms.vue +2 -2
- package/dist/resources/templates/nuxt/app/pages/ui/index.vue +45 -45
- package/dist/resources/templates/nuxt/deploy.config.json +27 -0
- package/dist/resources/templates/nuxt/nuxt.config.ts +4 -10
- package/dist/resources/templates/nuxt/package.json +8 -8
- package/dist/resources/templates/nuxt/test/e2e/rendering.test.ts +1 -0
- package/dist/resources/templates/nuxt/test/unit/consent.test.ts +4 -7
- package/package.json +5 -3
- package/dist/resources/components/nuxt/site-source/app/components/site/MarketingShell.vue +0 -33
- package/dist/resources/components/nuxt/site-source/app/types/site-shell.ts +0 -172
- /package/dist/resources/components/nuxt/site-source/app/components/{site/ConsentSettingsButton.vue → consent/Settings.vue} +0 -0
- /package/dist/resources/components/nuxt/site-source/app/components/{site/StaticPage.vue → content/Document.vue} +0 -0
- /package/dist/resources/components/nuxt/site-source/app/components/{site/ToastViewport.vue → feedback/Toast.vue} +0 -0
- /package/dist/resources/components/nuxt/site-source/app/components/site/{SiteBrand.vue → Brand.vue} +0 -0
- /package/dist/resources/components/nuxt/site-source/app/components/ui/{BaseGradientBorder.vue → GradientBorder.vue} +0 -0
- /package/dist/resources/components/nuxt/site-source/app/components/ui/{BaseModal.vue → Modal.vue} +0 -0
- /package/dist/resources/components/nuxt/site-source/app/components/ui/{BasePopover.vue → Popover.vue} +0 -0
- /package/dist/resources/components/nuxt/site-source/app/components/ui/{BaseText.vue → Text.vue} +0 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
package integrationevent
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
"time"
|
|
10
|
+
|
|
11
|
+
"github.com/jackc/pgx/v5"
|
|
12
|
+
"github.com/jackc/pgx/v5/stdlib"
|
|
13
|
+
"gorm.io/driver/postgres"
|
|
14
|
+
"gorm.io/gorm"
|
|
15
|
+
"gorm.io/gorm/logger"
|
|
16
|
+
|
|
17
|
+
"{{module}}/internal/core/database"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
func TestReceiverServiceSerializesAggregateVersionsOnPostgres(t *testing.T) {
|
|
21
|
+
db := openIntegrationEventPostgres(t)
|
|
22
|
+
firstStarted := make(chan struct{})
|
|
23
|
+
releaseFirst := make(chan struct{})
|
|
24
|
+
secondHandled := make(chan struct{})
|
|
25
|
+
registry := NewHandlerRegistry()
|
|
26
|
+
if err := registry.Register(HandlerRegistration{
|
|
27
|
+
SourceSystem: "source-a", EventType: "resource.changed", SchemaVersion: "1.0.0",
|
|
28
|
+
Handler: HandlerFunc(func(_ context.Context, envelope Envelope) error {
|
|
29
|
+
switch envelope.AggregateVersion {
|
|
30
|
+
case 1:
|
|
31
|
+
close(firstStarted)
|
|
32
|
+
<-releaseFirst
|
|
33
|
+
case 2:
|
|
34
|
+
close(secondHandled)
|
|
35
|
+
}
|
|
36
|
+
return nil
|
|
37
|
+
}),
|
|
38
|
+
}); err != nil {
|
|
39
|
+
t.Fatalf("Register() error = %v", err)
|
|
40
|
+
}
|
|
41
|
+
service, err := NewReceiverService(
|
|
42
|
+
NewRepository(db), database.NewTransactionManager(db), registry,
|
|
43
|
+
ReceiverConfig{SubscriberCode: "subscriber-a"},
|
|
44
|
+
)
|
|
45
|
+
if err != nil {
|
|
46
|
+
t.Fatalf("NewReceiverService() error = %v", err)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
firstDone := make(chan error, 1)
|
|
50
|
+
go func() {
|
|
51
|
+
_, receiveErr := service.Receive(context.Background(), fakeEnvelope("event-1", 1, []byte(`{"value":1}`)))
|
|
52
|
+
firstDone <- receiveErr
|
|
53
|
+
}()
|
|
54
|
+
select {
|
|
55
|
+
case <-firstStarted:
|
|
56
|
+
case <-time.After(5 * time.Second):
|
|
57
|
+
close(releaseFirst)
|
|
58
|
+
t.Fatal("version 1 handler did not start")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
secondDone := make(chan error, 1)
|
|
62
|
+
go func() {
|
|
63
|
+
_, receiveErr := service.Receive(context.Background(), fakeEnvelope("event-2", 2, []byte(`{"value":2}`)))
|
|
64
|
+
secondDone <- receiveErr
|
|
65
|
+
}()
|
|
66
|
+
select {
|
|
67
|
+
case <-secondHandled:
|
|
68
|
+
close(releaseFirst)
|
|
69
|
+
t.Fatal("version 2 handler ran before version 1 committed")
|
|
70
|
+
case <-time.After(100 * time.Millisecond):
|
|
71
|
+
}
|
|
72
|
+
close(releaseFirst)
|
|
73
|
+
|
|
74
|
+
for name, done := range map[string]<-chan error{"version 1": firstDone, "version 2": secondDone} {
|
|
75
|
+
select {
|
|
76
|
+
case receiveErr := <-done:
|
|
77
|
+
if receiveErr != nil {
|
|
78
|
+
t.Fatalf("%s Receive() error = %v", name, receiveErr)
|
|
79
|
+
}
|
|
80
|
+
case <-time.After(5 * time.Second):
|
|
81
|
+
t.Fatalf("%s Receive() did not complete", name)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
var checkpoint AggregateCheckpoint
|
|
86
|
+
if err := db.Take(&checkpoint).Error; err != nil {
|
|
87
|
+
t.Fatalf("load checkpoint: %v", err)
|
|
88
|
+
}
|
|
89
|
+
if checkpoint.LastAggregateVersion != 2 || checkpoint.RevisionNo != 2 {
|
|
90
|
+
t.Fatalf("checkpoint = %#v, want version/revision 2", checkpoint)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func openIntegrationEventPostgres(t *testing.T) *gorm.DB {
|
|
95
|
+
t.Helper()
|
|
96
|
+
baseDSN := strings.TrimSpace(os.Getenv("ONEX_TEST_POSTGRES_DSN"))
|
|
97
|
+
if baseDSN == "" {
|
|
98
|
+
t.Skip("ONEX_TEST_POSTGRES_DSN is not set")
|
|
99
|
+
}
|
|
100
|
+
adminConfig, err := pgx.ParseConfig(baseDSN)
|
|
101
|
+
if err != nil {
|
|
102
|
+
t.Fatalf("parse PostgreSQL test DSN: %v", err)
|
|
103
|
+
}
|
|
104
|
+
adminDB := stdlib.OpenDB(*adminConfig)
|
|
105
|
+
if err := adminDB.PingContext(context.Background()); err != nil {
|
|
106
|
+
_ = adminDB.Close()
|
|
107
|
+
t.Fatalf("ping PostgreSQL admin database: %v", err)
|
|
108
|
+
}
|
|
109
|
+
databaseName := fmt.Sprintf("integration_event_%d", time.Now().UnixNano())
|
|
110
|
+
if _, err := adminDB.ExecContext(context.Background(), `CREATE DATABASE "`+databaseName+`"`); err != nil {
|
|
111
|
+
_ = adminDB.Close()
|
|
112
|
+
t.Fatalf("create PostgreSQL test database: %v", err)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
testConfig := adminConfig.Copy()
|
|
116
|
+
testConfig.Database = databaseName
|
|
117
|
+
testDB := stdlib.OpenDB(*testConfig)
|
|
118
|
+
if err := testDB.PingContext(context.Background()); err != nil {
|
|
119
|
+
_ = testDB.Close()
|
|
120
|
+
_ = adminDB.Close()
|
|
121
|
+
t.Fatalf("ping PostgreSQL test database: %v", err)
|
|
122
|
+
}
|
|
123
|
+
db, err := gorm.Open(postgres.New(postgres.Config{Conn: testDB}), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
124
|
+
if err != nil {
|
|
125
|
+
_ = testDB.Close()
|
|
126
|
+
_ = adminDB.Close()
|
|
127
|
+
t.Fatalf("open PostgreSQL test database: %v", err)
|
|
128
|
+
}
|
|
129
|
+
t.Cleanup(func() {
|
|
130
|
+
if err := testDB.Close(); err != nil {
|
|
131
|
+
t.Errorf("close PostgreSQL test database: %v", err)
|
|
132
|
+
}
|
|
133
|
+
if _, err := adminDB.ExecContext(
|
|
134
|
+
context.Background(),
|
|
135
|
+
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
|
136
|
+
databaseName,
|
|
137
|
+
); err != nil {
|
|
138
|
+
t.Errorf("terminate PostgreSQL test sessions: %v", err)
|
|
139
|
+
}
|
|
140
|
+
if _, err := adminDB.ExecContext(context.Background(), `DROP DATABASE IF EXISTS "`+databaseName+`"`); err != nil {
|
|
141
|
+
t.Errorf("drop PostgreSQL test database: %v", err)
|
|
142
|
+
}
|
|
143
|
+
if err := adminDB.Close(); err != nil {
|
|
144
|
+
t.Errorf("close PostgreSQL admin database: %v", err)
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
statements := []string{
|
|
149
|
+
`CREATE TABLE sys_integration_event_inbox (
|
|
150
|
+
id BIGSERIAL PRIMARY KEY, source_system VARCHAR(191) NOT NULL, event_id VARCHAR(191) NOT NULL,
|
|
151
|
+
event_type VARCHAR(191) NOT NULL, schema_version VARCHAR(32) NOT NULL, product_code VARCHAR(50) NOT NULL,
|
|
152
|
+
aggregate_type VARCHAR(128) NOT NULL, aggregate_ref VARCHAR(191) NOT NULL, aggregate_version BIGINT NOT NULL,
|
|
153
|
+
payload_hash CHAR(64) NOT NULL, event_payload JSONB NOT NULL, received_at TIMESTAMPTZ(3) NOT NULL,
|
|
154
|
+
created_at TIMESTAMPTZ(3) NOT NULL, UNIQUE(source_system, event_id)
|
|
155
|
+
)`,
|
|
156
|
+
`CREATE TABLE sys_integration_event_receipts (
|
|
157
|
+
id BIGSERIAL PRIMARY KEY, source_system VARCHAR(191) NOT NULL, subscriber_code VARCHAR(64) NOT NULL,
|
|
158
|
+
event_id VARCHAR(191) NOT NULL, status VARCHAR(20) NOT NULL, receipt_status VARCHAR(20), error_code VARCHAR(120),
|
|
159
|
+
processing_started_at TIMESTAMPTZ(3) NOT NULL, processed_at TIMESTAMPTZ(3),
|
|
160
|
+
created_at TIMESTAMPTZ(3) NOT NULL, updated_at TIMESTAMPTZ(3) NOT NULL,
|
|
161
|
+
UNIQUE(source_system, subscriber_code, event_id)
|
|
162
|
+
)`,
|
|
163
|
+
`CREATE TABLE sys_integration_aggregate_checkpoints (
|
|
164
|
+
id BIGSERIAL PRIMARY KEY, source_system VARCHAR(191) NOT NULL, subscriber_code VARCHAR(64) NOT NULL,
|
|
165
|
+
aggregate_type VARCHAR(128) NOT NULL, aggregate_ref VARCHAR(191) NOT NULL,
|
|
166
|
+
last_aggregate_version BIGINT NOT NULL, last_event_id VARCHAR(191) NOT NULL,
|
|
167
|
+
last_payload_hash CHAR(64) NOT NULL, revision_no BIGINT NOT NULL,
|
|
168
|
+
created_at TIMESTAMPTZ(3) NOT NULL, updated_at TIMESTAMPTZ(3) NOT NULL,
|
|
169
|
+
UNIQUE(source_system, subscriber_code, aggregate_type, aggregate_ref)
|
|
170
|
+
)`,
|
|
171
|
+
}
|
|
172
|
+
for _, statement := range statements {
|
|
173
|
+
if err := db.Exec(statement).Error; err != nil {
|
|
174
|
+
t.Fatalf("create PostgreSQL integration event schema: %v", err)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return db
|
|
178
|
+
}
|
package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_test.go
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
package integrationevent
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
|
|
10
|
+
"gorm.io/driver/sqlite"
|
|
11
|
+
"gorm.io/gorm"
|
|
12
|
+
"gorm.io/gorm/logger"
|
|
13
|
+
|
|
14
|
+
"{{module}}/internal/core/database"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
func TestRepositoryRequiresTransactionForInboxMutation(t *testing.T) {
|
|
18
|
+
db := openSQLiteIntegrationEventDB(t)
|
|
19
|
+
repo := NewRepository(db)
|
|
20
|
+
_, _, err := repo.CreateOrLoadInbox(context.Background(), &InboxRecord{})
|
|
21
|
+
if err == nil || !strings.Contains(err.Error(), "transaction context is required") {
|
|
22
|
+
t.Fatalf("CreateOrLoadInbox() error = %v, want transaction requirement", err)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func TestReceiverServiceSQLiteCommitsHandlerAndCoreStateTogether(t *testing.T) {
|
|
27
|
+
db := openSQLiteIntegrationEventDB(t)
|
|
28
|
+
service := newSQLiteReceiverService(t, db, func(ctx context.Context, envelope Envelope) error {
|
|
29
|
+
return database.GetDB(ctx, db).Exec(
|
|
30
|
+
"INSERT INTO integration_event_test_projections (event_id, aggregate_version) VALUES (?, ?)",
|
|
31
|
+
envelope.EventID,
|
|
32
|
+
envelope.AggregateVersion,
|
|
33
|
+
).Error
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
result, err := service.Receive(context.Background(), fakeEnvelope("event-1", 1, []byte(`{"value":1}`)))
|
|
37
|
+
if err != nil {
|
|
38
|
+
t.Fatalf("Receive() error = %v", err)
|
|
39
|
+
}
|
|
40
|
+
if result.Outcome != ReceiptOutcomeProcessed {
|
|
41
|
+
t.Fatalf("Receive() outcome = %q, want processed", result.Outcome)
|
|
42
|
+
}
|
|
43
|
+
assertSQLiteCount(t, db, "sys_integration_event_inbox", 1)
|
|
44
|
+
assertSQLiteCount(t, db, "sys_integration_event_receipts", 1)
|
|
45
|
+
assertSQLiteCount(t, db, "sys_integration_aggregate_checkpoints", 1)
|
|
46
|
+
assertSQLiteCount(t, db, "integration_event_test_projections", 1)
|
|
47
|
+
|
|
48
|
+
duplicate, err := service.Receive(context.Background(), fakeEnvelope("event-1", 1, []byte(`{"value":1}`)))
|
|
49
|
+
if err != nil {
|
|
50
|
+
t.Fatalf("Receive(duplicate) error = %v", err)
|
|
51
|
+
}
|
|
52
|
+
if duplicate.Outcome != ReceiptOutcomeDuplicate {
|
|
53
|
+
t.Fatalf("Receive(duplicate) outcome = %q, want duplicate", duplicate.Outcome)
|
|
54
|
+
}
|
|
55
|
+
assertSQLiteCount(t, db, "integration_event_test_projections", 1)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func TestReceiverServiceSQLiteRollsBackHandlerFailure(t *testing.T) {
|
|
59
|
+
db := openSQLiteIntegrationEventDB(t)
|
|
60
|
+
handlerErr := errors.New("projection failed")
|
|
61
|
+
service := newSQLiteReceiverService(t, db, func(ctx context.Context, envelope Envelope) error {
|
|
62
|
+
if err := database.GetDB(ctx, db).Exec(
|
|
63
|
+
"INSERT INTO integration_event_test_projections (event_id, aggregate_version) VALUES (?, ?)",
|
|
64
|
+
envelope.EventID,
|
|
65
|
+
envelope.AggregateVersion,
|
|
66
|
+
).Error; err != nil {
|
|
67
|
+
return err
|
|
68
|
+
}
|
|
69
|
+
return handlerErr
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
_, err := service.Receive(context.Background(), fakeEnvelope("event-1", 1, []byte(`{"value":1}`)))
|
|
73
|
+
if !errors.Is(err, handlerErr) {
|
|
74
|
+
t.Fatalf("Receive() error = %v, want handler error", err)
|
|
75
|
+
}
|
|
76
|
+
assertSQLiteCount(t, db, "sys_integration_event_inbox", 0)
|
|
77
|
+
assertSQLiteCount(t, db, "sys_integration_event_receipts", 0)
|
|
78
|
+
assertSQLiteCount(t, db, "sys_integration_aggregate_checkpoints", 0)
|
|
79
|
+
assertSQLiteCount(t, db, "integration_event_test_projections", 0)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func TestReceiverServiceSQLiteRollsBackVersionGap(t *testing.T) {
|
|
83
|
+
db := openSQLiteIntegrationEventDB(t)
|
|
84
|
+
service := newSQLiteReceiverService(t, db, func(context.Context, Envelope) error {
|
|
85
|
+
t.Fatal("handler must not run for a version gap")
|
|
86
|
+
return nil
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
_, err := service.Receive(context.Background(), fakeEnvelope("event-2", 2, []byte(`{"value":2}`)))
|
|
90
|
+
var gap *VersionGapError
|
|
91
|
+
if !errors.As(err, &gap) {
|
|
92
|
+
t.Fatalf("Receive() error = %v, want VersionGapError", err)
|
|
93
|
+
}
|
|
94
|
+
assertSQLiteCount(t, db, "sys_integration_event_inbox", 0)
|
|
95
|
+
assertSQLiteCount(t, db, "sys_integration_event_receipts", 0)
|
|
96
|
+
assertSQLiteCount(t, db, "sys_integration_aggregate_checkpoints", 0)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func newSQLiteReceiverService(t *testing.T, db *gorm.DB, handler HandlerFunc) *ReceiverService {
|
|
100
|
+
t.Helper()
|
|
101
|
+
registry := NewHandlerRegistry()
|
|
102
|
+
if err := registry.Register(HandlerRegistration{
|
|
103
|
+
SourceSystem: "source-a", EventType: "resource.changed", SchemaVersion: "1.0.0", Handler: handler,
|
|
104
|
+
}); err != nil {
|
|
105
|
+
t.Fatalf("Register() error = %v", err)
|
|
106
|
+
}
|
|
107
|
+
service, err := NewReceiverService(
|
|
108
|
+
NewRepository(db),
|
|
109
|
+
database.NewTransactionManager(db),
|
|
110
|
+
registry,
|
|
111
|
+
ReceiverConfig{SubscriberCode: "subscriber-a"},
|
|
112
|
+
)
|
|
113
|
+
if err != nil {
|
|
114
|
+
t.Fatalf("NewReceiverService() error = %v", err)
|
|
115
|
+
}
|
|
116
|
+
return service
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
func openSQLiteIntegrationEventDB(t *testing.T) *gorm.DB {
|
|
120
|
+
t.Helper()
|
|
121
|
+
name := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
|
|
122
|
+
db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared&_loc=auto", name)), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
|
123
|
+
if err != nil {
|
|
124
|
+
t.Fatalf("open SQLite: %v", err)
|
|
125
|
+
}
|
|
126
|
+
statements := []string{
|
|
127
|
+
`CREATE TABLE sys_integration_event_inbox (
|
|
128
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, source_system TEXT NOT NULL, event_id TEXT NOT NULL,
|
|
129
|
+
event_type TEXT NOT NULL, schema_version TEXT NOT NULL, product_code TEXT NOT NULL,
|
|
130
|
+
aggregate_type TEXT NOT NULL, aggregate_ref TEXT NOT NULL, aggregate_version INTEGER NOT NULL,
|
|
131
|
+
payload_hash TEXT NOT NULL, event_payload TEXT NOT NULL, received_at DATETIME NOT NULL,
|
|
132
|
+
created_at DATETIME NOT NULL, UNIQUE(source_system, event_id)
|
|
133
|
+
)`,
|
|
134
|
+
`CREATE TABLE sys_integration_event_receipts (
|
|
135
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, source_system TEXT NOT NULL, subscriber_code TEXT NOT NULL,
|
|
136
|
+
event_id TEXT NOT NULL, status TEXT NOT NULL, receipt_status TEXT, error_code TEXT,
|
|
137
|
+
processing_started_at DATETIME NOT NULL, processed_at DATETIME, created_at DATETIME NOT NULL,
|
|
138
|
+
updated_at DATETIME NOT NULL, UNIQUE(source_system, subscriber_code, event_id)
|
|
139
|
+
)`,
|
|
140
|
+
`CREATE TABLE sys_integration_aggregate_checkpoints (
|
|
141
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, source_system TEXT NOT NULL, subscriber_code TEXT NOT NULL,
|
|
142
|
+
aggregate_type TEXT NOT NULL, aggregate_ref TEXT NOT NULL, last_aggregate_version INTEGER NOT NULL,
|
|
143
|
+
last_event_id TEXT NOT NULL, last_payload_hash TEXT NOT NULL, revision_no INTEGER NOT NULL,
|
|
144
|
+
created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL,
|
|
145
|
+
UNIQUE(source_system, subscriber_code, aggregate_type, aggregate_ref)
|
|
146
|
+
)`,
|
|
147
|
+
`CREATE TABLE integration_event_test_projections (
|
|
148
|
+
event_id TEXT PRIMARY KEY, aggregate_version INTEGER NOT NULL
|
|
149
|
+
)`,
|
|
150
|
+
}
|
|
151
|
+
for _, statement := range statements {
|
|
152
|
+
if err := db.Exec(statement).Error; err != nil {
|
|
153
|
+
t.Fatalf("create SQLite integration event schema: %v", err)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return db
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func assertSQLiteCount(t *testing.T, db *gorm.DB, table string, want int64) {
|
|
160
|
+
t.Helper()
|
|
161
|
+
var count int64
|
|
162
|
+
if err := db.Table(table).Count(&count).Error; err != nil {
|
|
163
|
+
t.Fatalf("count %s: %v", table, err)
|
|
164
|
+
}
|
|
165
|
+
if count != want {
|
|
166
|
+
t.Fatalf("%s count = %d, want %d", table, count, want)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
package integrationevent
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"github.com/google/wire"
|
|
5
|
+
|
|
6
|
+
"{{module}}/internal/core/database"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// ProviderSet 提供入站事件仓储、处理器注册表与接收服务。
|
|
10
|
+
// ReceiverConfig 由具体消费者提供,处理器由消费者在启动阶段精确注册。
|
|
11
|
+
var ProviderSet = wire.NewSet(
|
|
12
|
+
NewRepository,
|
|
13
|
+
NewHandlerRegistry,
|
|
14
|
+
NewReceiverService,
|
|
15
|
+
wire.Bind(new(TransactionManager), new(*database.TransactionManager)),
|
|
16
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
package integrationevent
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"strings"
|
|
6
|
+
"sync"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
type handlerKey struct {
|
|
10
|
+
sourceSystem string
|
|
11
|
+
eventType string
|
|
12
|
+
schemaVersion string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// HandlerRegistry 保存消费者启动时注册的精确事件版本处理器。
|
|
16
|
+
type HandlerRegistry struct {
|
|
17
|
+
mu sync.RWMutex
|
|
18
|
+
handlers map[handlerKey]Handler
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// NewHandlerRegistry 创建空的处理器注册表。
|
|
22
|
+
func NewHandlerRegistry() *HandlerRegistry {
|
|
23
|
+
return &HandlerRegistry{handlers: make(map[handlerKey]Handler)}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Register 注册精确 source/type/schema 处理器,重复注册会失败。
|
|
27
|
+
func (r *HandlerRegistry) Register(registration HandlerRegistration) error {
|
|
28
|
+
key, err := normalizeHandlerKey(registration.SourceSystem, registration.EventType, registration.SchemaVersion)
|
|
29
|
+
if err != nil {
|
|
30
|
+
return err
|
|
31
|
+
}
|
|
32
|
+
if registration.Handler == nil {
|
|
33
|
+
return fmt.Errorf("integration event handler is required")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
r.mu.Lock()
|
|
37
|
+
defer r.mu.Unlock()
|
|
38
|
+
if _, exists := r.handlers[key]; exists {
|
|
39
|
+
return fmt.Errorf("integration event handler is already registered for %s/%s/%s", key.sourceSystem, key.eventType, key.schemaVersion)
|
|
40
|
+
}
|
|
41
|
+
r.handlers[key] = registration.Handler
|
|
42
|
+
return nil
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
func (r *HandlerRegistry) resolve(sourceSystem, eventType, schemaVersion string) (Handler, bool) {
|
|
46
|
+
if r == nil {
|
|
47
|
+
return nil, false
|
|
48
|
+
}
|
|
49
|
+
key, err := normalizeHandlerKey(sourceSystem, eventType, schemaVersion)
|
|
50
|
+
if err != nil {
|
|
51
|
+
return nil, false
|
|
52
|
+
}
|
|
53
|
+
r.mu.RLock()
|
|
54
|
+
defer r.mu.RUnlock()
|
|
55
|
+
handler, exists := r.handlers[key]
|
|
56
|
+
return handler, exists
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func normalizeHandlerKey(sourceSystem, eventType, schemaVersion string) (handlerKey, error) {
|
|
60
|
+
key := handlerKey{
|
|
61
|
+
sourceSystem: strings.TrimSpace(sourceSystem),
|
|
62
|
+
eventType: strings.TrimSpace(eventType),
|
|
63
|
+
schemaVersion: strings.TrimSpace(schemaVersion),
|
|
64
|
+
}
|
|
65
|
+
if key.sourceSystem == "" || key.eventType == "" || key.schemaVersion == "" {
|
|
66
|
+
return handlerKey{}, fmt.Errorf("integration event handler key is incomplete")
|
|
67
|
+
}
|
|
68
|
+
return key, nil
|
|
69
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
package integrationevent
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"time"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// Repository 定义入站事件身份、订阅者回执与聚合水位的事务仓储。
|
|
9
|
+
type Repository interface {
|
|
10
|
+
CreateOrLoadInbox(ctx context.Context, record *InboxRecord) (*InboxRecord, bool, error)
|
|
11
|
+
CreateOrLockReceipt(ctx context.Context, record *ReceiptRecord) (*ReceiptRecord, bool, error)
|
|
12
|
+
LockAggregateScope(ctx context.Context, sourceSystem, subscriberCode, aggregateType, aggregateRef string) error
|
|
13
|
+
FindCheckpointForUpdate(ctx context.Context, sourceSystem, subscriberCode, aggregateType, aggregateRef string) (*AggregateCheckpoint, error)
|
|
14
|
+
CreateCheckpoint(ctx context.Context, checkpoint *AggregateCheckpoint) error
|
|
15
|
+
AdvanceCheckpoint(ctx context.Context, checkpoint *AggregateCheckpoint, expectedRevision int64) error
|
|
16
|
+
MarkReceiptProcessed(ctx context.Context, id int64, outcome ReceiptOutcome, processedAt time.Time) error
|
|
17
|
+
}
|