@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.
Files changed (55) hide show
  1. package/README.md +248 -0
  2. package/dist/index.js +14093 -603
  3. package/dist/index.js.map +1 -1
  4. package/dist/resources/deploy/ci/github/caller.yml +38 -0
  5. package/dist/resources/deploy/ci/onedev/import.yml +14 -0
  6. package/dist/resources/deploy/nginx/cache.conf +17 -0
  7. package/dist/resources/deploy/nginx/compression.conf +23 -0
  8. package/dist/resources/deploy/nginx/proxy.conf +15 -0
  9. package/dist/resources/deploy/profiles/go-service/compose.yaml +19 -0
  10. package/dist/resources/deploy/profiles/nuxt-node/Dockerfile +17 -0
  11. package/dist/resources/deploy/profiles/static-files/nginx/site.conf.template +55 -0
  12. package/dist/resources/deploy/profiles/static-nginx/Dockerfile +18 -0
  13. package/dist/resources/deploy/profiles/static-nginx/nginx/default.conf.template +28 -0
  14. package/dist/resources/deploy/targets/compose-host/deploy.config.example.json +114 -0
  15. package/dist/resources/deploy/traffic/nginx/stackonward-nginx-traffic +96 -0
  16. package/dist/resources/registry.json +30 -30
  17. package/dist/resources/templates/base/.claude/docs/architecture.md +4 -0
  18. package/dist/resources/templates/base/AGENTS.md +15 -0
  19. package/dist/resources/templates/base/CLAUDE.md +15 -0
  20. package/dist/resources/templates/go-backend/.dockerignore +25 -3
  21. package/dist/resources/templates/go-backend/deploy.config.json +26 -0
  22. package/dist/resources/templates/go-backend/docs/runtime-provenance.json +9 -6
  23. package/dist/resources/templates/go-backend/docs/source-provenance.json +33 -22
  24. package/dist/resources/templates/go-backend/internal/common/enums/register_locale.go +7 -0
  25. package/dist/resources/templates/go-backend/internal/common/enums/register_locale_test.go +30 -0
  26. package/dist/resources/templates/go-backend/internal/common/locale/locale.go +102 -76
  27. package/dist/resources/templates/go-backend/internal/common/locale/locale_test.go +168 -45
  28. package/dist/resources/templates/go-backend/internal/core/commandreplay/model.go +5 -5
  29. package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence.go +20 -7
  30. package/dist/resources/templates/go-backend/internal/core/commandreplay/persistence_test.go +51 -22
  31. package/dist/resources/templates/go-backend/internal/core/commandreplay/repository.go +2 -2
  32. package/dist/resources/templates/go-backend/internal/core/commandreplay/service.go +13 -24
  33. package/dist/resources/templates/go-backend/internal/core/commandreplay/service_persistence_test.go +98 -13
  34. package/dist/resources/templates/go-backend/internal/core/commandreplay/service_test.go +61 -36
  35. package/dist/resources/templates/go-backend/internal/core/commandreplay/types.go +6 -6
  36. package/dist/resources/templates/go-backend/internal/core/integrationevent/config.go +24 -0
  37. package/dist/resources/templates/go-backend/internal/core/integrationevent/errors.go +53 -0
  38. package/dist/resources/templates/go-backend/internal/core/integrationevent/model.go +71 -0
  39. package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence.go +193 -0
  40. package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_postgres_test.go +178 -0
  41. package/dist/resources/templates/go-backend/internal/core/integrationevent/persistence_test.go +168 -0
  42. package/dist/resources/templates/go-backend/internal/core/integrationevent/provider.go +16 -0
  43. package/dist/resources/templates/go-backend/internal/core/integrationevent/registry.go +69 -0
  44. package/dist/resources/templates/go-backend/internal/core/integrationevent/repository.go +17 -0
  45. package/dist/resources/templates/go-backend/internal/core/integrationevent/service.go +283 -0
  46. package/dist/resources/templates/go-backend/internal/core/integrationevent/service_test.go +433 -0
  47. package/dist/resources/templates/go-backend/internal/core/integrationevent/types.go +67 -0
  48. package/dist/resources/templates/go-backend/migrations/000001_infrastructure.down.sql +6 -0
  49. package/dist/resources/templates/go-backend/migrations/000001_infrastructure.up.sql +176 -16
  50. package/dist/resources/templates/go-backend/tests/infrastructure.sql +109 -6
  51. package/dist/resources/templates/nuxt/.claude/docs/stack.md +5 -1
  52. package/dist/resources/templates/nuxt/.dockerignore +19 -0
  53. package/dist/resources/templates/nuxt/deploy.config.json +27 -0
  54. package/dist/resources/templates/nuxt/package.json +8 -8
  55. package/package.json +5 -3
@@ -46,7 +46,7 @@ func TestCommandReplayExecuteStandaloneCommitsBusinessFailure(t *testing.T) {
46
46
  t.Fatalf("Execute() result = %#v, want failed result", result)
47
47
  }
48
48
 
49
- stored, err := repo.FindByScope(ctx, "test.command.replay", "quote:123", "key-123")
49
+ stored, err := repo.FindByScope(ctx, "test.command.replay", "identity", "user_123", "quote:123", "key-123")
50
50
  if err != nil {
51
51
  t.Fatalf("FindByScope() error = %v", err)
52
52
  }
@@ -101,7 +101,7 @@ func TestCommandReplayExecuteStandaloneRollsBackInfrastructureError(t *testing.T
101
101
  t.Fatalf("Execute() error = %v, want infraErr", err)
102
102
  }
103
103
 
104
- stored, err := repo.FindByScope(ctx, "test.command.replay", "quote:123", "key-123")
104
+ stored, err := repo.FindByScope(ctx, "test.command.replay", "identity", "user_123", "quote:123", "key-123")
105
105
  if err != nil {
106
106
  t.Fatalf("FindByScope() error = %v", err)
107
107
  }
@@ -147,7 +147,7 @@ func TestCommandReplayExecuteJoinsOuterTransactionRollback(t *testing.T) {
147
147
  t.Fatalf("WithTransaction() error = %v, want rollbackErr", err)
148
148
  }
149
149
 
150
- stored, err := repo.FindByScope(ctx, "test.command.replay", "quote:123", "key-123")
150
+ stored, err := repo.FindByScope(ctx, "test.command.replay", "identity", "user_123", "quote:123", "key-123")
151
151
  if err != nil {
152
152
  t.Fatalf("FindByScope() error = %v", err)
153
153
  }
@@ -214,7 +214,7 @@ func TestCommandReplayExecuteRejectsConcurrentDuplicateOnPostgres(t *testing.T)
214
214
  t.Fatalf("business executions = %d, want 1", executions.Load())
215
215
  }
216
216
 
217
- stored, err := repo.FindByScope(ctx, "test.command.replay", "quote:123", "key-123")
217
+ stored, err := repo.FindByScope(ctx, "test.command.replay", "identity", "user_123", "quote:123", "key-123")
218
218
  if err != nil {
219
219
  t.Fatalf("FindByScope() error = %v", err)
220
220
  }
@@ -223,6 +223,89 @@ func TestCommandReplayExecuteRejectsConcurrentDuplicateOnPostgres(t *testing.T)
223
223
  }
224
224
  }
225
225
 
226
+ func TestCommandReplayExecuteAllowsConcurrentSameKeyForDifferentPrincipalsOnPostgres(t *testing.T) {
227
+ db := openPersistentCommandReplayPostgres(t)
228
+ repo := commandreplay.NewCommandReplayRepository(db)
229
+ service := commandreplay.NewCommandReplayService(repo, database.NewTransactionManager(db))
230
+ ctx := context.Background()
231
+ firstStarted := make(chan struct{})
232
+ releaseFirst := make(chan struct{})
233
+ firstDone := make(chan error, 1)
234
+ secondDone := make(chan error, 1)
235
+ releasedFirst := false
236
+ defer func() {
237
+ if !releasedFirst {
238
+ close(releaseFirst)
239
+ }
240
+ }()
241
+ var executions atomic.Int32
242
+
243
+ go func() {
244
+ _, err := service.Execute(ctx, persistentCommandReplayRequest(json.RawMessage(`{"amount":100}`)), func(context.Context, *commandreplay.CommandReplayRecord) (*commandreplay.CommandReplayCommandResult, error) {
245
+ executions.Add(1)
246
+ close(firstStarted)
247
+ <-releaseFirst
248
+ return &commandreplay.CommandReplayCommandResult{
249
+ Status: commandreplay.CommandReplayStatusSucceeded,
250
+ ResponsePayload: json.RawMessage(`{"status":"first"}`),
251
+ }, nil
252
+ })
253
+ firstDone <- err
254
+ }()
255
+
256
+ select {
257
+ case <-firstStarted:
258
+ case <-time.After(5 * time.Second):
259
+ t.Fatal("first principal did not enter its transaction")
260
+ }
261
+
262
+ secondRequest := persistentCommandReplayRequest(json.RawMessage(`{"amount":100}`))
263
+ secondRequest.PrincipalRef = "user_456"
264
+ go func() {
265
+ _, err := service.Execute(ctx, secondRequest, func(context.Context, *commandreplay.CommandReplayRecord) (*commandreplay.CommandReplayCommandResult, error) {
266
+ executions.Add(1)
267
+ return &commandreplay.CommandReplayCommandResult{
268
+ Status: commandreplay.CommandReplayStatusSucceeded,
269
+ ResponsePayload: json.RawMessage(`{"status":"second"}`),
270
+ }, nil
271
+ })
272
+ secondDone <- err
273
+ }()
274
+
275
+ select {
276
+ case err := <-secondDone:
277
+ if err != nil {
278
+ t.Fatalf("second principal Execute() error = %v", err)
279
+ }
280
+ case <-time.After(5 * time.Second):
281
+ t.Fatal("second principal was blocked by another principal's scope lock")
282
+ }
283
+ close(releaseFirst)
284
+ releasedFirst = true
285
+
286
+ select {
287
+ case err := <-firstDone:
288
+ if err != nil {
289
+ t.Fatalf("first principal Execute() error = %v", err)
290
+ }
291
+ case <-time.After(5 * time.Second):
292
+ t.Fatal("first principal did not complete")
293
+ }
294
+ if executions.Load() != 2 {
295
+ t.Fatalf("business executions = %d, want 2", executions.Load())
296
+ }
297
+
298
+ for _, principalRef := range []string{"user_123", "user_456"} {
299
+ stored, err := repo.FindByScope(ctx, "test.command.replay", "identity", principalRef, "quote:123", "key-123")
300
+ if err != nil {
301
+ t.Fatalf("FindByScope(%s) error = %v", principalRef, err)
302
+ }
303
+ if stored == nil || stored.Status != commandreplay.CommandReplayStatusSucceeded {
304
+ t.Fatalf("stored record for %s = %#v, want succeeded", principalRef, stored)
305
+ }
306
+ }
307
+ }
308
+
226
309
  func newPersistentCommandReplayService(t *testing.T) (*commandreplay.CommandReplayService, commandreplay.CommandReplayRepository) {
227
310
  t.Helper()
228
311
  service, repo, _, _ := newPersistentCommandReplayFixture(t)
@@ -248,13 +331,15 @@ type sqliteCommandReplayRepository struct {
248
331
  commandreplay.CommandReplayRepository
249
332
  }
250
333
 
251
- func (r sqliteCommandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, commandScope, idempotencyKey string) (bool, error) {
334
+ func (r sqliteCommandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (bool, error) {
252
335
  return true, nil
253
336
  }
254
337
 
255
338
  func persistentCommandReplayRequest(payload json.RawMessage) commandreplay.CommandReplayBeginRequest {
256
339
  return commandreplay.CommandReplayBeginRequest{
257
340
  Namespace: "test.command.replay",
341
+ PrincipalType: "identity",
342
+ PrincipalRef: "user_123",
258
343
  CommandScope: "quote:123",
259
344
  IdempotencyKey: "key-123",
260
345
  RequestPayload: payload,
@@ -275,8 +360,8 @@ func createPersistentCommandReplayRecordSchema(t *testing.T, db *gorm.DB) {
275
360
  command_scope TEXT NOT NULL,
276
361
  idempotency_key TEXT NOT NULL,
277
362
  request_hash TEXT NOT NULL,
278
- principal_type TEXT,
279
- principal_ref TEXT,
363
+ principal_type TEXT NOT NULL,
364
+ principal_ref TEXT NOT NULL,
280
365
  status TEXT NOT NULL DEFAULT 'started',
281
366
  response_payload TEXT NOT NULL DEFAULT '{}',
282
367
  error_code TEXT,
@@ -296,8 +381,8 @@ func createPersistentCommandReplayRecordSchema(t *testing.T, db *gorm.DB) {
296
381
  t.Fatalf("create command replay schema: %v", err)
297
382
  }
298
383
  if err := db.Exec(`
299
- CREATE UNIQUE INDEX uk_sys_command_replay_records_namespace_scope_key
300
- ON sys_command_replay_records (namespace, command_scope, idempotency_key)
384
+ CREATE UNIQUE INDEX uk_sys_command_replay_records_principal_scope_key
385
+ ON sys_command_replay_records (namespace, principal_type, principal_ref, command_scope, idempotency_key)
301
386
  `).Error; err != nil {
302
387
  t.Fatalf("create command replay unique index: %v", err)
303
388
  }
@@ -381,8 +466,8 @@ func createPersistentCommandReplayPostgresSchema(t *testing.T, db *gorm.DB) {
381
466
  command_scope VARCHAR(128) NOT NULL,
382
467
  idempotency_key VARCHAR(191) NOT NULL,
383
468
  request_hash CHAR(64) NOT NULL,
384
- principal_type VARCHAR(32),
385
- principal_ref VARCHAR(64),
469
+ principal_type VARCHAR(32) NOT NULL,
470
+ principal_ref VARCHAR(64) NOT NULL,
386
471
  status VARCHAR(20) NOT NULL DEFAULT 'started',
387
472
  response_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
388
473
  error_code VARCHAR(120),
@@ -397,8 +482,8 @@ func createPersistentCommandReplayPostgresSchema(t *testing.T, db *gorm.DB) {
397
482
  external_attempt_count INTEGER NOT NULL DEFAULT 0,
398
483
  external_last_error_code VARCHAR(120),
399
484
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
400
- CONSTRAINT uk_sys_command_replay_records_namespace_scope_key
401
- UNIQUE (namespace, command_scope, idempotency_key)
485
+ CONSTRAINT uk_sys_command_replay_records_principal_scope_key
486
+ UNIQUE (namespace, principal_type, principal_ref, command_scope, idempotency_key)
402
487
  )
403
488
  `).Error; err != nil {
404
489
  t.Fatalf("create PostgreSQL command replay schema: %v", err)
@@ -18,14 +18,16 @@ import (
18
18
  "{{module}}/pkg/apierror"
19
19
  )
20
20
 
21
- const commandReplayTestNamespace = "test.command.replay"
21
+ const (
22
+ commandReplayTestNamespace = "test.command.replay"
23
+ commandReplayTestPrincipalType = "identity"
24
+ commandReplayTestPrincipalRef = "user_123"
25
+ )
22
26
 
23
27
  var commandReplayTestNow = time.Date(2026, 5, 28, 10, 0, 0, 0, time.UTC)
24
28
 
25
29
  func TestCommandReplayExecuteSucceeds(t *testing.T) {
26
30
  service, repo := newTestCommandReplayService(t)
27
- principalType := "identity"
28
- principalRef := "user_123"
29
31
  fnCalled := 0
30
32
 
31
33
  result, err := service.Execute(context.Background(), CommandReplayBeginRequest{
@@ -33,8 +35,8 @@ func TestCommandReplayExecuteSucceeds(t *testing.T) {
33
35
  CommandScope: " quote:123 ",
34
36
  IdempotencyKey: " key-123 ",
35
37
  RequestPayload: json.RawMessage(`{"currency":"USD","amount":100}`),
36
- PrincipalType: &principalType,
37
- PrincipalRef: &principalRef,
38
+ PrincipalType: " identity ",
39
+ PrincipalRef: " user_123 ",
38
40
  }, func(ctx context.Context, record *CommandReplayRecord) (*CommandReplayCommandResult, error) {
39
41
  fnCalled++
40
42
  if record.ID == 0 {
@@ -61,7 +63,7 @@ func TestCommandReplayExecuteSucceeds(t *testing.T) {
61
63
  t.Fatalf("Execute() ResponsePayload = %s, want canonical object", result.ResponsePayload)
62
64
  }
63
65
 
64
- stored := repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
66
+ stored := repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
65
67
  if stored.Status != CommandReplayStatusSucceeded {
66
68
  t.Fatalf("stored Status = %q, want succeeded", stored.Status)
67
69
  }
@@ -77,11 +79,11 @@ func TestCommandReplayExecuteSucceeds(t *testing.T) {
77
79
  if stored.CompletedAt == nil || !stored.CompletedAt.Equal(commandReplayTestNow) {
78
80
  t.Fatalf("stored CompletedAt = %v, want %s", stored.CompletedAt, commandReplayTestNow)
79
81
  }
80
- if stored.PrincipalType == nil || *stored.PrincipalType != principalType {
81
- t.Fatalf("stored PrincipalType = %v, want %q", stored.PrincipalType, principalType)
82
+ if stored.PrincipalType != commandReplayTestPrincipalType {
83
+ t.Fatalf("stored PrincipalType = %q, want %q", stored.PrincipalType, commandReplayTestPrincipalType)
82
84
  }
83
- if stored.PrincipalRef == nil || *stored.PrincipalRef != principalRef {
84
- t.Fatalf("stored PrincipalRef = %v, want %q", stored.PrincipalRef, principalRef)
85
+ if stored.PrincipalRef != commandReplayTestPrincipalRef {
86
+ t.Fatalf("stored PrincipalRef = %q, want %q", stored.PrincipalRef, commandReplayTestPrincipalRef)
85
87
  }
86
88
  }
87
89
 
@@ -140,6 +142,29 @@ func TestCommandReplayExecuteRejectsHashConflict(t *testing.T) {
140
142
  assertAPIError(t, err, http.StatusConflict, errCodeCommandReplayConflict)
141
143
  }
142
144
 
145
+ func TestCommandReplayExecuteIsolatesSameKeyByPrincipal(t *testing.T) {
146
+ service, repo := newTestCommandReplayService(t)
147
+ first := beginRequest(json.RawMessage(`{"amount":100}`))
148
+ second := beginRequest(json.RawMessage(`{"amount":100}`))
149
+ second.PrincipalRef = "user_456"
150
+
151
+ firstResult, err := service.Execute(context.Background(), first, successCommandReplayFunc(t))
152
+ if err != nil {
153
+ t.Fatalf("Execute(first) error = %v", err)
154
+ }
155
+ secondResult, err := service.Execute(context.Background(), second, successCommandReplayFunc(t))
156
+ if err != nil {
157
+ t.Fatalf("Execute(second) error = %v", err)
158
+ }
159
+ if firstResult.Replayed || secondResult.Replayed {
160
+ t.Fatalf("new principal-scoped commands must not replay: first=%v second=%v", firstResult.Replayed, secondResult.Replayed)
161
+ }
162
+ if repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123").RequestHash !=
163
+ repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, "user_456", "quote:123", "key-123").RequestHash {
164
+ t.Fatal("principal unexpectedly changed the payload-only request fingerprint")
165
+ }
166
+ }
167
+
143
168
  func TestCommandReplayExecuteRejectsExpiredRecord(t *testing.T) {
144
169
  service, repo := newTestCommandReplayService(t)
145
170
  repo.seed(seedCommandReplayRecord(t, json.RawMessage(`{"amount":100}`), func(record *CommandReplayRecord) {
@@ -169,7 +194,7 @@ func TestCommandReplayExecuteBusinessFailureCommitsAndReturnsBusinessError(t *te
169
194
  t.Fatalf("Execute() result = %#v, want failed result", result)
170
195
  }
171
196
 
172
- stored := repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
197
+ stored := repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
173
198
  if stored.Status != CommandReplayStatusFailed {
174
199
  t.Fatalf("stored Status = %q, want failed", stored.Status)
175
200
  }
@@ -198,7 +223,7 @@ func TestCommandReplayExecuteInfrastructureErrorDoesNotMarkTerminal(t *testing.T
198
223
  t.Fatalf("Execute() error = %v, want infraErr", err)
199
224
  }
200
225
 
201
- stored := repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
226
+ stored := repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
202
227
  if stored.Status.IsTerminal() {
203
228
  t.Fatalf("stored Status = %q, want non-terminal", stored.Status)
204
229
  }
@@ -258,19 +283,17 @@ func TestCommandReplayExecuteRejectsInvalidNamespace(t *testing.T) {
258
283
  assertAPIError(t, err, http.StatusBadRequest, apierror.CodeValidationFailed)
259
284
  }
260
285
 
261
- func TestCommandReplayExecuteRejectsIncompletePrincipal(t *testing.T) {
262
- principalType := "identity"
263
- principalRef := "ipr_123"
264
- blank := " \t "
286
+ func TestCommandReplayExecuteRejectsInvalidPrincipal(t *testing.T) {
265
287
  tests := []struct {
266
288
  name string
267
- principalType *string
268
- principalRef *string
289
+ principalType string
290
+ principalRef string
269
291
  }{
270
- {name: "missing principal ref", principalType: &principalType},
271
- {name: "missing principal type", principalRef: &principalRef},
272
- {name: "blank principal pair", principalType: &blank, principalRef: &blank},
273
- {name: "blank principal ref", principalType: &principalType, principalRef: &blank},
292
+ {name: "missing principal ref", principalType: commandReplayTestPrincipalType},
293
+ {name: "missing principal type", principalRef: commandReplayTestPrincipalRef},
294
+ {name: "blank principal pair", principalType: " \t ", principalRef: " \t "},
295
+ {name: "overlong principal type", principalType: strings.Repeat("a", maxPrincipalTypeLength+1), principalRef: commandReplayTestPrincipalRef},
296
+ {name: "overlong principal ref", principalType: commandReplayTestPrincipalType, principalRef: strings.Repeat("a", maxPrincipalRefLength+1)},
274
297
  }
275
298
 
276
299
  for _, tt := range tests {
@@ -372,7 +395,7 @@ func TestCommandReplayExternalLifecycle(t *testing.T) {
372
395
  if lease.ExecutionToken == "" || lease.AttemptCount != 1 || lease.Reservation.ReferenceNo != "cpo_1" {
373
396
  t.Fatalf("lease = %#v", lease)
374
397
  }
375
- stored := repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
398
+ stored := repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
376
399
  if !stored.HasExternalReservation() || stored.ExecutionTokenHash == nil || stored.ExternalAttemptCount != 1 {
377
400
  t.Fatalf("stored external record = %#v", stored)
378
401
  }
@@ -389,7 +412,7 @@ func TestCommandReplayExternalLifecycle(t *testing.T) {
389
412
  if err != nil || result.Status != CommandReplayStatusSucceeded {
390
413
  t.Fatalf("CompleteExternal() = %#v, %v", result, err)
391
414
  }
392
- stored = repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
415
+ stored = repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
393
416
  if stored.HasExternalReservation() || stored.ExecutionTokenHash != nil || stored.Status != CommandReplayStatusSucceeded {
394
417
  t.Fatalf("completed stored record = %#v", stored)
395
418
  }
@@ -453,7 +476,7 @@ func TestCommandReplayExternalDeferAndRecoveryClaim(t *testing.T) {
453
476
  if err := service.DeferExternal(context.Background(), lease.RecordID, lease.ExecutionToken, "provider_timeout", nextRecoveryAt); err != nil {
454
477
  t.Fatalf("DeferExternal() error = %v", err)
455
478
  }
456
- stored := repo.mustFind(t, commandReplayTestNamespace, "quote:123", "key-123")
479
+ stored := repo.mustFind(t, commandReplayTestNamespace, commandReplayTestPrincipalType, commandReplayTestPrincipalRef, "quote:123", "key-123")
457
480
  if stored.ExecutionTokenHash != nil || stored.ExternalLastErrorCode == nil || *stored.ExternalLastErrorCode != "provider_timeout" {
458
481
  t.Fatalf("deferred record = %#v", stored)
459
482
  }
@@ -586,6 +609,8 @@ func newTestCommandReplayService(t *testing.T) (*CommandReplayService, *fakeComm
586
609
  func beginRequest(payload json.RawMessage) CommandReplayBeginRequest {
587
610
  return CommandReplayBeginRequest{
588
611
  Namespace: commandReplayTestNamespace,
612
+ PrincipalType: commandReplayTestPrincipalType,
613
+ PrincipalRef: commandReplayTestPrincipalRef,
589
614
  CommandScope: "quote:123",
590
615
  IdempotencyKey: "key-123",
591
616
  RequestPayload: payload,
@@ -599,6 +624,8 @@ func seedCommandReplayRecord(t *testing.T, payload json.RawMessage, mutate func(
599
624
 
600
625
  record := &CommandReplayRecord{
601
626
  Namespace: commandReplayTestNamespace,
627
+ PrincipalType: commandReplayTestPrincipalType,
628
+ PrincipalRef: commandReplayTestPrincipalRef,
602
629
  CommandScope: "quote:123",
603
630
  IdempotencyKey: "key-123",
604
631
  RequestHash: mustCanonicalHash(t, payload),
@@ -669,18 +696,18 @@ func newFakeCommandReplayRepository() *fakeCommandReplayRepository {
669
696
  }
670
697
  }
671
698
 
672
- func (r *fakeCommandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, commandScope, idempotencyKey string) (bool, error) {
699
+ func (r *fakeCommandReplayRepository) TryAcquireScopeLock(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (bool, error) {
673
700
  r.mu.Lock()
674
701
  defer r.mu.Unlock()
675
702
 
676
703
  return r.lockAcquired, nil
677
704
  }
678
705
 
679
- func (r *fakeCommandReplayRepository) FindByScope(ctx context.Context, namespace, commandScope, idempotencyKey string) (*CommandReplayRecord, error) {
706
+ func (r *fakeCommandReplayRepository) FindByScope(ctx context.Context, namespace, principalType, principalRef, commandScope, idempotencyKey string) (*CommandReplayRecord, error) {
680
707
  r.mu.Lock()
681
708
  defer r.mu.Unlock()
682
709
 
683
- record := r.records[commandReplayRecordKey(namespace, commandScope, idempotencyKey)]
710
+ record := r.records[commandReplayRecordKey(namespace, principalType, principalRef, commandScope, idempotencyKey)]
684
711
  return cloneCommandReplayRecord(record), nil
685
712
  }
686
713
 
@@ -692,7 +719,7 @@ func (r *fakeCommandReplayRepository) Create(ctx context.Context, record *Comman
692
719
  r.mu.Lock()
693
720
  defer r.mu.Unlock()
694
721
 
695
- key := commandReplayRecordKey(record.Namespace, record.CommandScope, record.IdempotencyKey)
722
+ key := commandReplayRecordKey(record.Namespace, record.PrincipalType, record.PrincipalRef, record.CommandScope, record.IdempotencyKey)
696
723
  if _, exists := r.records[key]; exists {
697
724
  return ErrCommandReplayRecordExists
698
725
  }
@@ -835,17 +862,17 @@ func (r *fakeCommandReplayRepository) seed(record *CommandReplayRecord) {
835
862
  r.nextID++
836
863
  }
837
864
  record.ID = stored.ID
838
- key := commandReplayRecordKey(stored.Namespace, stored.CommandScope, stored.IdempotencyKey)
865
+ key := commandReplayRecordKey(stored.Namespace, stored.PrincipalType, stored.PrincipalRef, stored.CommandScope, stored.IdempotencyKey)
839
866
  r.records[key] = stored
840
867
  }
841
868
 
842
- func (r *fakeCommandReplayRepository) mustFind(t *testing.T, namespace, commandScope, idempotencyKey string) *CommandReplayRecord {
869
+ func (r *fakeCommandReplayRepository) mustFind(t *testing.T, namespace, principalType, principalRef, commandScope, idempotencyKey string) *CommandReplayRecord {
843
870
  t.Helper()
844
871
 
845
872
  r.mu.Lock()
846
873
  defer r.mu.Unlock()
847
874
 
848
- record := r.records[commandReplayRecordKey(namespace, commandScope, idempotencyKey)]
875
+ record := r.records[commandReplayRecordKey(namespace, principalType, principalRef, commandScope, idempotencyKey)]
849
876
  if record == nil {
850
877
  t.Fatalf("record not found")
851
878
  }
@@ -861,8 +888,8 @@ func (r *fakeCommandReplayRepository) findByIDLocked(id int64) *CommandReplayRec
861
888
  return nil
862
889
  }
863
890
 
864
- func commandReplayRecordKey(namespace, commandScope, idempotencyKey string) string {
865
- return namespace + "\x00" + commandScope + "\x00" + idempotencyKey
891
+ func commandReplayRecordKey(namespace, principalType, principalRef, commandScope, idempotencyKey string) string {
892
+ return namespace + "\x00" + principalType + "\x00" + principalRef + "\x00" + commandScope + "\x00" + idempotencyKey
866
893
  }
867
894
 
868
895
  func cloneCommandReplayRecord(record *CommandReplayRecord) *CommandReplayRecord {
@@ -870,8 +897,6 @@ func cloneCommandReplayRecord(record *CommandReplayRecord) *CommandReplayRecord
870
897
  return nil
871
898
  }
872
899
  cloned := *record
873
- cloned.PrincipalType = cloneStringPtr(record.PrincipalType)
874
- cloned.PrincipalRef = cloneStringPtr(record.PrincipalRef)
875
900
  cloned.ErrorCode = cloneStringPtr(record.ErrorCode)
876
901
  cloned.ErrorStatus = cloneIntPtr(record.ErrorStatus)
877
902
  cloned.ResponsePayload = append(json.RawMessage(nil), record.ResponsePayload...)
@@ -18,10 +18,10 @@ type CommandReplayBeginRequest struct {
18
18
  IdempotencyKey string
19
19
  // RequestPayload 命令请求的规范 JSON 对象载荷。
20
20
  RequestPayload json.RawMessage
21
- // PrincipalType 请求主体类型。
22
- PrincipalType *string
23
- // PrincipalRef 请求主体公开引用。
24
- PrincipalRef *string
21
+ // PrincipalType 是参与幂等作用域的可信调用主体类型。
22
+ PrincipalType string
23
+ // PrincipalRef 是参与幂等作用域的可信调用主体公开引用。
24
+ PrincipalRef string
25
25
  // Metadata 扩展元数据 JSON 对象。
26
26
  Metadata json.RawMessage
27
27
  // TTL 幂等记录保留时长。
@@ -106,8 +106,8 @@ type normalizedCommandReplayBeginRequest struct {
106
106
  commandScope string
107
107
  idempotencyKey string
108
108
  requestHash string
109
- principalType *string
110
- principalRef *string
109
+ principalType string
110
+ principalRef string
111
111
  metadata json.RawMessage
112
112
  ttl time.Duration
113
113
  }
@@ -0,0 +1,24 @@
1
+ package integrationevent
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+ )
7
+
8
+ const maxSubscriberCodeLength = 64
9
+
10
+ // ReceiverConfig 定义单个入站事件接收方的稳定订阅者身份。
11
+ type ReceiverConfig struct {
12
+ SubscriberCode string
13
+ }
14
+
15
+ func (c ReceiverConfig) normalized() (ReceiverConfig, error) {
16
+ c.SubscriberCode = strings.TrimSpace(c.SubscriberCode)
17
+ if c.SubscriberCode == "" {
18
+ return ReceiverConfig{}, fmt.Errorf("integration event subscriber code is required")
19
+ }
20
+ if len(c.SubscriberCode) > maxSubscriberCodeLength {
21
+ return ReceiverConfig{}, fmt.Errorf("integration event subscriber code exceeds %d bytes", maxSubscriberCodeLength)
22
+ }
23
+ return c, nil
24
+ }
@@ -0,0 +1,53 @@
1
+ package integrationevent
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ )
7
+
8
+ var (
9
+ // ErrEnvelopeInvalid 表示接收方提交的已验证信封不满足核心持久化合同。
10
+ ErrEnvelopeInvalid = errors.New("integration event envelope is invalid")
11
+ // ErrHandlerNotRegistered 表示没有消费者为精确 source/type/schema 注册处理器。
12
+ ErrHandlerNotRegistered = errors.New("integration event handler is not registered")
13
+ // ErrIdentityConflict 表示同一全局事件身份绑定了不同规范信封。
14
+ ErrIdentityConflict = errors.New("integration event identity conflicts with the persisted envelope")
15
+ // ErrVersionConflict 表示事件版本不高于水位且无法证明是已处理事件。
16
+ ErrVersionConflict = errors.New("integration event aggregate version conflicts with the checkpoint")
17
+ // ErrReceiptStateConflict 表示持久回执处于无法安全重放的状态。
18
+ ErrReceiptStateConflict = errors.New("integration event receipt state conflicts with replay")
19
+ )
20
+
21
+ // VersionGapError 表示事件版本领先于当前连续水位,调用方应稍后重试同一事件。
22
+ type VersionGapError struct {
23
+ Expected int64
24
+ Actual int64
25
+ }
26
+
27
+ func (e *VersionGapError) Error() string {
28
+ return fmt.Sprintf("integration event aggregate version gap: expected %d, got %d", e.Expected, e.Actual)
29
+ }
30
+
31
+ // Retryable 标识版本缺口可以在前序事件到达后重试。
32
+ func (e *VersionGapError) Retryable() bool { return true }
33
+
34
+ // HandlerError 表示消费者处理失败,整个入站事务已回滚并可重试。
35
+ type HandlerError struct {
36
+ Cause error
37
+ }
38
+
39
+ func (e *HandlerError) Error() string {
40
+ return fmt.Sprintf("integration event handler failed: %v", e.Cause)
41
+ }
42
+
43
+ // Unwrap 返回消费者处理器的原始错误。
44
+ func (e *HandlerError) Unwrap() error { return e.Cause }
45
+
46
+ // Retryable 保留消费者对失败是否可重试的显式分类。
47
+ func (e *HandlerError) Retryable() bool { return IsRetryable(e.Cause) }
48
+
49
+ // IsRetryable 判断错误是否允许发送方稍后重试同一事件身份。
50
+ func IsRetryable(err error) bool {
51
+ var retryable interface{ Retryable() bool }
52
+ return errors.As(err, &retryable) && retryable.Retryable()
53
+ }
@@ -0,0 +1,71 @@
1
+ package integrationevent
2
+
3
+ import (
4
+ "encoding/json"
5
+ "time"
6
+ )
7
+
8
+ // ReceiptProcessingStatus 表示订阅者回执的持久处理状态。
9
+ type ReceiptProcessingStatus string
10
+
11
+ const (
12
+ ReceiptProcessingStatusProcessing ReceiptProcessingStatus = "processing"
13
+ ReceiptProcessingStatusProcessed ReceiptProcessingStatus = "processed"
14
+ ReceiptProcessingStatusFailed ReceiptProcessingStatus = "failed"
15
+ )
16
+
17
+ // InboxRecord 保存全局唯一事件身份及其规范信封。
18
+ type InboxRecord struct {
19
+ ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
20
+ SourceSystem string `gorm:"column:source_system;type:varchar(191);not null;uniqueIndex:uk_sys_integration_event_inbox_identity,priority:1"`
21
+ EventID string `gorm:"column:event_id;type:varchar(191);not null;uniqueIndex:uk_sys_integration_event_inbox_identity,priority:2"`
22
+ EventType string `gorm:"column:event_type;type:varchar(191);not null"`
23
+ SchemaVersion string `gorm:"column:schema_version;type:varchar(32);not null"`
24
+ ProductCode string `gorm:"column:product_code;type:varchar(50);not null"`
25
+ AggregateType string `gorm:"column:aggregate_type;type:varchar(128);not null"`
26
+ AggregateRef string `gorm:"column:aggregate_ref;type:varchar(191);not null"`
27
+ AggregateVersion int64 `gorm:"column:aggregate_version;not null"`
28
+ PayloadHash string `gorm:"column:payload_hash;type:char(64);not null"`
29
+ EventPayload json.RawMessage `gorm:"column:event_payload;type:jsonb;not null"`
30
+ ReceivedAt time.Time `gorm:"column:received_at;type:timestamptz(3);not null"`
31
+ CreatedAt time.Time `gorm:"column:created_at;type:timestamptz(3);not null"`
32
+ }
33
+
34
+ // TableName 指定全局入站事件身份表。
35
+ func (InboxRecord) TableName() string { return "sys_integration_event_inbox" }
36
+
37
+ // ReceiptRecord 保存单个订阅者对一个全局事件的处理回执。
38
+ type ReceiptRecord struct {
39
+ ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
40
+ SourceSystem string `gorm:"column:source_system;type:varchar(191);not null;uniqueIndex:uk_sys_integration_event_receipts_subscriber_event,priority:1"`
41
+ SubscriberCode string `gorm:"column:subscriber_code;type:varchar(64);not null;uniqueIndex:uk_sys_integration_event_receipts_subscriber_event,priority:2"`
42
+ EventID string `gorm:"column:event_id;type:varchar(191);not null;uniqueIndex:uk_sys_integration_event_receipts_subscriber_event,priority:3"`
43
+ Status ReceiptProcessingStatus `gorm:"column:status;type:varchar(20);not null"`
44
+ ReceiptStatus *ReceiptOutcome `gorm:"column:receipt_status;type:varchar(20)"`
45
+ ErrorCode *string `gorm:"column:error_code;type:varchar(120)"`
46
+ ProcessingStartedAt time.Time `gorm:"column:processing_started_at;type:timestamptz(3);not null"`
47
+ ProcessedAt *time.Time `gorm:"column:processed_at;type:timestamptz(3)"`
48
+ CreatedAt time.Time `gorm:"column:created_at;type:timestamptz(3);not null"`
49
+ UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz(3);not null"`
50
+ }
51
+
52
+ // TableName 指定订阅者事件回执表。
53
+ func (ReceiptRecord) TableName() string { return "sys_integration_event_receipts" }
54
+
55
+ // AggregateCheckpoint 保存单个订阅者对一个上游聚合的最后连续版本。
56
+ type AggregateCheckpoint struct {
57
+ ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
58
+ SourceSystem string `gorm:"column:source_system;type:varchar(191);not null;uniqueIndex:uk_sys_integration_aggregate_checkpoints_scope,priority:1"`
59
+ SubscriberCode string `gorm:"column:subscriber_code;type:varchar(64);not null;uniqueIndex:uk_sys_integration_aggregate_checkpoints_scope,priority:2"`
60
+ AggregateType string `gorm:"column:aggregate_type;type:varchar(128);not null;uniqueIndex:uk_sys_integration_aggregate_checkpoints_scope,priority:3"`
61
+ AggregateRef string `gorm:"column:aggregate_ref;type:varchar(191);not null;uniqueIndex:uk_sys_integration_aggregate_checkpoints_scope,priority:4"`
62
+ LastAggregateVersion int64 `gorm:"column:last_aggregate_version;not null"`
63
+ LastEventID string `gorm:"column:last_event_id;type:varchar(191);not null"`
64
+ LastPayloadHash string `gorm:"column:last_payload_hash;type:char(64);not null"`
65
+ RevisionNo int64 `gorm:"column:revision_no;not null"`
66
+ CreatedAt time.Time `gorm:"column:created_at;type:timestamptz(3);not null"`
67
+ UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz(3);not null"`
68
+ }
69
+
70
+ // TableName 指定订阅者聚合连续水位表。
71
+ func (AggregateCheckpoint) TableName() string { return "sys_integration_aggregate_checkpoints" }