@stackonward/cli 1.4.0 → 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 +14093 -603
- package/dist/index.js.map +1 -1
- 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/deploy.config.json +27 -0
- package/dist/resources/templates/nuxt/package.json +8 -8
- package/package.json +5 -3
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
package integrationevent
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"fmt"
|
|
7
|
+
"regexp"
|
|
8
|
+
"strings"
|
|
9
|
+
"time"
|
|
10
|
+
|
|
11
|
+
"{{module}}/internal/common/canonicaljson"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const (
|
|
15
|
+
maxSourceSystemLength = 191
|
|
16
|
+
maxEventIDLength = 191
|
|
17
|
+
maxEventTypeLength = 191
|
|
18
|
+
maxSchemaVersionLength = 32
|
|
19
|
+
maxProductCodeLength = 50
|
|
20
|
+
maxAggregateTypeLength = 128
|
|
21
|
+
maxAggregateRefLength = 191
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
var productCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{1,49}$`)
|
|
25
|
+
|
|
26
|
+
// ReceiverService 原子执行入站事件幂等、连续版本校验与消费者处理。
|
|
27
|
+
type ReceiverService struct {
|
|
28
|
+
repo Repository
|
|
29
|
+
txManager TransactionManager
|
|
30
|
+
registry *HandlerRegistry
|
|
31
|
+
config ReceiverConfig
|
|
32
|
+
now func() time.Time
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// NewReceiverService 创建入站事件接收服务。
|
|
36
|
+
func NewReceiverService(repo Repository, txManager TransactionManager, registry *HandlerRegistry, config ReceiverConfig) (*ReceiverService, error) {
|
|
37
|
+
if repo == nil {
|
|
38
|
+
return nil, fmt.Errorf("integration event repository is required")
|
|
39
|
+
}
|
|
40
|
+
if txManager == nil {
|
|
41
|
+
return nil, fmt.Errorf("integration event transaction manager is required")
|
|
42
|
+
}
|
|
43
|
+
if registry == nil {
|
|
44
|
+
return nil, fmt.Errorf("integration event handler registry is required")
|
|
45
|
+
}
|
|
46
|
+
normalizedConfig, err := config.normalized()
|
|
47
|
+
if err != nil {
|
|
48
|
+
return nil, err
|
|
49
|
+
}
|
|
50
|
+
return &ReceiverService{
|
|
51
|
+
repo: repo,
|
|
52
|
+
txManager: txManager,
|
|
53
|
+
registry: registry,
|
|
54
|
+
config: normalizedConfig,
|
|
55
|
+
now: func() time.Time { return time.Now().UTC() },
|
|
56
|
+
}, nil
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Receive 按全局事件身份和订阅者聚合水位处理一个已验证信封。
|
|
60
|
+
func (s *ReceiverService) Receive(ctx context.Context, envelope Envelope) (*ReceiveResult, error) {
|
|
61
|
+
normalized, payloadHash, err := normalizeEnvelope(envelope)
|
|
62
|
+
if err != nil {
|
|
63
|
+
return nil, err
|
|
64
|
+
}
|
|
65
|
+
handler, registered := s.registry.resolve(normalized.SourceSystem, normalized.EventType, normalized.SchemaVersion)
|
|
66
|
+
if !registered {
|
|
67
|
+
return nil, ErrHandlerNotRegistered
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
var result *ReceiveResult
|
|
71
|
+
err = s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
|
|
72
|
+
now := s.now().UTC()
|
|
73
|
+
incoming := &InboxRecord{
|
|
74
|
+
SourceSystem: normalized.SourceSystem,
|
|
75
|
+
EventID: normalized.EventID,
|
|
76
|
+
EventType: normalized.EventType,
|
|
77
|
+
SchemaVersion: normalized.SchemaVersion,
|
|
78
|
+
ProductCode: normalized.ProductCode,
|
|
79
|
+
AggregateType: normalized.AggregateType,
|
|
80
|
+
AggregateRef: normalized.AggregateRef,
|
|
81
|
+
AggregateVersion: normalized.AggregateVersion,
|
|
82
|
+
PayloadHash: payloadHash,
|
|
83
|
+
EventPayload: normalized.CanonicalEnvelope,
|
|
84
|
+
ReceivedAt: now,
|
|
85
|
+
CreatedAt: now,
|
|
86
|
+
}
|
|
87
|
+
persisted, _, persistErr := s.repo.CreateOrLoadInbox(txCtx, incoming)
|
|
88
|
+
if persistErr != nil {
|
|
89
|
+
return persistErr
|
|
90
|
+
}
|
|
91
|
+
if matches, matchErr := inboxMatchesEnvelope(persisted, incoming); matchErr != nil {
|
|
92
|
+
return matchErr
|
|
93
|
+
} else if !matches {
|
|
94
|
+
return ErrIdentityConflict
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
receipt, receiptCreated, receiptErr := s.repo.CreateOrLockReceipt(txCtx, &ReceiptRecord{
|
|
98
|
+
SourceSystem: normalized.SourceSystem,
|
|
99
|
+
SubscriberCode: s.config.SubscriberCode,
|
|
100
|
+
EventID: normalized.EventID,
|
|
101
|
+
Status: ReceiptProcessingStatusProcessing,
|
|
102
|
+
ProcessingStartedAt: now,
|
|
103
|
+
CreatedAt: now,
|
|
104
|
+
UpdatedAt: now,
|
|
105
|
+
})
|
|
106
|
+
if receiptErr != nil {
|
|
107
|
+
return receiptErr
|
|
108
|
+
}
|
|
109
|
+
if !receiptCreated {
|
|
110
|
+
if receipt.Status != ReceiptProcessingStatusProcessed || receipt.ReceiptStatus == nil {
|
|
111
|
+
return ErrReceiptStateConflict
|
|
112
|
+
}
|
|
113
|
+
result = s.resultFor(normalized, ReceiptOutcomeDuplicate)
|
|
114
|
+
return nil
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if lockErr := s.repo.LockAggregateScope(txCtx, normalized.SourceSystem, s.config.SubscriberCode, normalized.AggregateType, normalized.AggregateRef); lockErr != nil {
|
|
118
|
+
return lockErr
|
|
119
|
+
}
|
|
120
|
+
checkpoint, checkpointErr := s.repo.FindCheckpointForUpdate(txCtx, normalized.SourceSystem, s.config.SubscriberCode, normalized.AggregateType, normalized.AggregateRef)
|
|
121
|
+
if checkpointErr != nil {
|
|
122
|
+
return checkpointErr
|
|
123
|
+
}
|
|
124
|
+
outcome, sequenceErr := classifySequence(checkpoint, normalized, payloadHash)
|
|
125
|
+
if sequenceErr != nil {
|
|
126
|
+
return sequenceErr
|
|
127
|
+
}
|
|
128
|
+
if outcome == ReceiptOutcomeDuplicate {
|
|
129
|
+
if finalizeErr := s.repo.MarkReceiptProcessed(txCtx, receipt.ID, ReceiptOutcomeDuplicate, now); finalizeErr != nil {
|
|
130
|
+
return finalizeErr
|
|
131
|
+
}
|
|
132
|
+
result = s.resultFor(normalized, ReceiptOutcomeDuplicate)
|
|
133
|
+
return nil
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
handlerEnvelope := normalized
|
|
137
|
+
handlerEnvelope.CanonicalEnvelope = append(json.RawMessage(nil), normalized.CanonicalEnvelope...)
|
|
138
|
+
if handleErr := handler.Handle(txCtx, handlerEnvelope); handleErr != nil {
|
|
139
|
+
return &HandlerError{Cause: handleErr}
|
|
140
|
+
}
|
|
141
|
+
if checkpoint == nil {
|
|
142
|
+
checkpoint = &AggregateCheckpoint{
|
|
143
|
+
SourceSystem: normalized.SourceSystem,
|
|
144
|
+
SubscriberCode: s.config.SubscriberCode,
|
|
145
|
+
AggregateType: normalized.AggregateType,
|
|
146
|
+
AggregateRef: normalized.AggregateRef,
|
|
147
|
+
LastAggregateVersion: normalized.AggregateVersion,
|
|
148
|
+
LastEventID: normalized.EventID,
|
|
149
|
+
LastPayloadHash: payloadHash,
|
|
150
|
+
RevisionNo: 1,
|
|
151
|
+
CreatedAt: now,
|
|
152
|
+
UpdatedAt: now,
|
|
153
|
+
}
|
|
154
|
+
if createErr := s.repo.CreateCheckpoint(txCtx, checkpoint); createErr != nil {
|
|
155
|
+
return createErr
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
expectedRevision := checkpoint.RevisionNo
|
|
159
|
+
checkpoint.LastAggregateVersion = normalized.AggregateVersion
|
|
160
|
+
checkpoint.LastEventID = normalized.EventID
|
|
161
|
+
checkpoint.LastPayloadHash = payloadHash
|
|
162
|
+
checkpoint.RevisionNo++
|
|
163
|
+
checkpoint.UpdatedAt = now
|
|
164
|
+
if advanceErr := s.repo.AdvanceCheckpoint(txCtx, checkpoint, expectedRevision); advanceErr != nil {
|
|
165
|
+
return advanceErr
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if finalizeErr := s.repo.MarkReceiptProcessed(txCtx, receipt.ID, ReceiptOutcomeProcessed, now); finalizeErr != nil {
|
|
169
|
+
return finalizeErr
|
|
170
|
+
}
|
|
171
|
+
result = s.resultFor(normalized, ReceiptOutcomeProcessed)
|
|
172
|
+
return nil
|
|
173
|
+
})
|
|
174
|
+
if err != nil {
|
|
175
|
+
return nil, err
|
|
176
|
+
}
|
|
177
|
+
if result == nil {
|
|
178
|
+
return nil, fmt.Errorf("integration event receiver transaction produced no result")
|
|
179
|
+
}
|
|
180
|
+
return result, nil
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func (s *ReceiverService) resultFor(envelope Envelope, outcome ReceiptOutcome) *ReceiveResult {
|
|
184
|
+
return &ReceiveResult{
|
|
185
|
+
SourceSystem: envelope.SourceSystem,
|
|
186
|
+
SubscriberCode: s.config.SubscriberCode,
|
|
187
|
+
EventID: envelope.EventID,
|
|
188
|
+
Outcome: outcome,
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
func normalizeEnvelope(envelope Envelope) (Envelope, string, error) {
|
|
193
|
+
normalized := Envelope{
|
|
194
|
+
SourceSystem: strings.TrimSpace(envelope.SourceSystem),
|
|
195
|
+
EventID: strings.TrimSpace(envelope.EventID),
|
|
196
|
+
EventType: strings.TrimSpace(envelope.EventType),
|
|
197
|
+
SchemaVersion: strings.TrimSpace(envelope.SchemaVersion),
|
|
198
|
+
ProductCode: strings.TrimSpace(envelope.ProductCode),
|
|
199
|
+
AggregateType: strings.TrimSpace(envelope.AggregateType),
|
|
200
|
+
AggregateRef: strings.TrimSpace(envelope.AggregateRef),
|
|
201
|
+
AggregateVersion: envelope.AggregateVersion,
|
|
202
|
+
CanonicalEnvelope: append(json.RawMessage(nil), envelope.CanonicalEnvelope...),
|
|
203
|
+
}
|
|
204
|
+
for _, field := range []struct {
|
|
205
|
+
name string
|
|
206
|
+
value string
|
|
207
|
+
limit int
|
|
208
|
+
}{
|
|
209
|
+
{"source_system", normalized.SourceSystem, maxSourceSystemLength},
|
|
210
|
+
{"event_id", normalized.EventID, maxEventIDLength},
|
|
211
|
+
{"event_type", normalized.EventType, maxEventTypeLength},
|
|
212
|
+
{"schema_version", normalized.SchemaVersion, maxSchemaVersionLength},
|
|
213
|
+
{"product_code", normalized.ProductCode, maxProductCodeLength},
|
|
214
|
+
{"aggregate_type", normalized.AggregateType, maxAggregateTypeLength},
|
|
215
|
+
{"aggregate_ref", normalized.AggregateRef, maxAggregateRefLength},
|
|
216
|
+
} {
|
|
217
|
+
if field.value == "" || len(field.value) > field.limit {
|
|
218
|
+
return Envelope{}, "", fmt.Errorf("%w: %s is empty or exceeds %d bytes", ErrEnvelopeInvalid, field.name, field.limit)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if !productCodePattern.MatchString(normalized.ProductCode) {
|
|
222
|
+
return Envelope{}, "", fmt.Errorf("%w: product_code is invalid", ErrEnvelopeInvalid)
|
|
223
|
+
}
|
|
224
|
+
if normalized.AggregateVersion <= 0 {
|
|
225
|
+
return Envelope{}, "", fmt.Errorf("%w: aggregate_version must be positive", ErrEnvelopeInvalid)
|
|
226
|
+
}
|
|
227
|
+
canonical, err := canonicaljson.Canonicalize(normalized.CanonicalEnvelope)
|
|
228
|
+
if err != nil {
|
|
229
|
+
return Envelope{}, "", fmt.Errorf("%w: canonicalize envelope: %v", ErrEnvelopeInvalid, err)
|
|
230
|
+
}
|
|
231
|
+
if len(canonical) == 0 || canonical[0] != '{' {
|
|
232
|
+
return Envelope{}, "", fmt.Errorf("%w: canonical envelope must be a JSON object", ErrEnvelopeInvalid)
|
|
233
|
+
}
|
|
234
|
+
normalized.CanonicalEnvelope = canonical
|
|
235
|
+
payloadHash, err := canonicaljson.Hash(canonical)
|
|
236
|
+
if err != nil {
|
|
237
|
+
return Envelope{}, "", fmt.Errorf("%w: hash canonical envelope: %v", ErrEnvelopeInvalid, err)
|
|
238
|
+
}
|
|
239
|
+
return normalized, payloadHash, nil
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
func inboxMatchesEnvelope(persisted, incoming *InboxRecord) (bool, error) {
|
|
243
|
+
if persisted == nil || incoming == nil {
|
|
244
|
+
return false, fmt.Errorf("integration event inbox comparison requires both records")
|
|
245
|
+
}
|
|
246
|
+
if persisted.SourceSystem != incoming.SourceSystem ||
|
|
247
|
+
persisted.EventID != incoming.EventID ||
|
|
248
|
+
persisted.EventType != incoming.EventType ||
|
|
249
|
+
persisted.SchemaVersion != incoming.SchemaVersion ||
|
|
250
|
+
persisted.ProductCode != incoming.ProductCode ||
|
|
251
|
+
persisted.AggregateType != incoming.AggregateType ||
|
|
252
|
+
persisted.AggregateRef != incoming.AggregateRef ||
|
|
253
|
+
persisted.AggregateVersion != incoming.AggregateVersion ||
|
|
254
|
+
persisted.PayloadHash != incoming.PayloadHash {
|
|
255
|
+
return false, nil
|
|
256
|
+
}
|
|
257
|
+
equal, err := canonicaljson.Equal(persisted.EventPayload, incoming.EventPayload)
|
|
258
|
+
if err != nil {
|
|
259
|
+
return false, fmt.Errorf("compare persisted integration event envelope: %w", err)
|
|
260
|
+
}
|
|
261
|
+
return equal, nil
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
func classifySequence(checkpoint *AggregateCheckpoint, envelope Envelope, payloadHash string) (ReceiptOutcome, error) {
|
|
265
|
+
if checkpoint == nil {
|
|
266
|
+
if envelope.AggregateVersion == 1 {
|
|
267
|
+
return ReceiptOutcomeProcessed, nil
|
|
268
|
+
}
|
|
269
|
+
return "", &VersionGapError{Expected: 1, Actual: envelope.AggregateVersion}
|
|
270
|
+
}
|
|
271
|
+
expected := checkpoint.LastAggregateVersion + 1
|
|
272
|
+
switch {
|
|
273
|
+
case envelope.AggregateVersion == expected:
|
|
274
|
+
return ReceiptOutcomeProcessed, nil
|
|
275
|
+
case envelope.AggregateVersion > expected:
|
|
276
|
+
return "", &VersionGapError{Expected: expected, Actual: envelope.AggregateVersion}
|
|
277
|
+
case envelope.AggregateVersion == checkpoint.LastAggregateVersion &&
|
|
278
|
+
envelope.EventID == checkpoint.LastEventID && payloadHash == checkpoint.LastPayloadHash:
|
|
279
|
+
return ReceiptOutcomeDuplicate, nil
|
|
280
|
+
default:
|
|
281
|
+
return "", ErrVersionConflict
|
|
282
|
+
}
|
|
283
|
+
}
|