cabloy 5.1.113 → 5.1.114

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 (138) hide show
  1. package/.cabloy-version +1 -1
  2. package/.claude/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +4 -0
  3. package/.github/workflows/vona-test-mysql.yml +12 -0
  4. package/.github/workflows/vona-test-pg.yml +5 -1
  5. package/CHANGELOG.md +14 -0
  6. package/CLAUDE.md +4 -0
  7. package/cabloy-docs/backend/internal-aop-guide.md +35 -0
  8. package/cabloy-docs/backend/unit-testing.md +72 -1
  9. package/package.json +1 -1
  10. package/vona/packages-cli/cli/package.json +1 -1
  11. package/vona/packages-cli/cli-set-api/package.json +1 -1
  12. package/vona/packages-cli/cli-set-api/toolsIsolate/test.ts +26 -13
  13. package/vona/packages-utils/compose/package.json +1 -1
  14. package/vona/packages-utils/compose/src/index.ts +19 -11
  15. package/vona/packages-vona/vona/package.json +1 -1
  16. package/vona/packages-vona/vona-core/package.json +1 -1
  17. package/vona/packages-vona/vona-core/src/lib/bean/beanContainer.ts +15 -3
  18. package/vona/packages-vona/vona-mock/package.json +1 -1
  19. package/vona/pnpm-lock.yaml +15 -0
  20. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/.metadata/index.ts +8 -0
  21. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/bean/meta.version.ts +1 -0
  22. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/en-us.ts +3 -0
  23. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/config/locale/zh-cn.ts +3 -0
  24. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/skuAttribute.tsx +17 -0
  25. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/entity/sku.tsx +10 -1
  26. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/catalog.test.ts +1 -1
  27. package/vona/src/suite/a-commerce/modules/commerce-member/test/addressOwnership.test.ts +6 -1
  28. package/vona/src/suite/a-commerce/modules/commerce-payment/package.json +2 -1
  29. package/vona/src/suite/a-commerce/modules/commerce-payment/src/.metadata/index.ts +228 -0
  30. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.index.ts +15 -0
  31. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.version.ts +35 -0
  32. package/vona/src/suite/a-commerce/modules/commerce-payment/src/dto/paymentAttemptView.tsx +26 -0
  33. package/vona/src/suite/a-commerce/modules/commerce-payment/src/entity/paymentAttempt.tsx +34 -0
  34. package/vona/src/suite/a-commerce/modules/commerce-payment/src/model/paymentAttempt.ts +10 -0
  35. package/vona/src/suite/a-commerce/modules/commerce-payment/src/service/paymentAttempt.ts +41 -0
  36. package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentAttempt.test.ts +33 -0
  37. package/vona/src/suite/a-commerce/modules/commerce-promotion/package.json +4 -2
  38. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/index.ts +533 -2
  39. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/locales.ts +18 -0
  40. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.index.ts +24 -0
  41. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.version.ts +137 -0
  42. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/ssrMenu.couponTemplate.ts +30 -0
  43. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/en-us.ts +37 -0
  44. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/config/locale/zh-cn.ts +37 -0
  45. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/controller/coupon.ts +37 -0
  46. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/controller/couponTemplate.ts +65 -0
  47. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponIssue.tsx +22 -0
  48. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponMineItem.tsx +32 -0
  49. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateCreate.tsx +43 -0
  50. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectReq.tsx +28 -0
  51. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectRes.tsx +11 -0
  52. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateSelectResItem.tsx +39 -0
  53. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateUpdate.tsx +19 -0
  54. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponTemplateView.tsx +25 -0
  55. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponAudit.tsx +73 -0
  56. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponGrant.tsx +83 -0
  57. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/entity/couponTemplate.tsx +82 -0
  58. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/index.ts +1 -0
  59. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponAudit.ts +10 -0
  60. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponGrant.ts +10 -0
  61. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/model/couponTemplate.ts +10 -0
  62. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/coupon.ts +397 -0
  63. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/couponTemplate.ts +52 -0
  64. package/vona/src/suite/a-commerce/modules/commerce-promotion/test/couponReservation.test.ts +162 -0
  65. package/vona/src/suite/a-commerce/modules/commerce-seed/src/bean/meta.version.ts +8 -10
  66. package/vona/src/suite/a-commerce/modules/commerce-trade/package.json +4 -1
  67. package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +277 -0
  68. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/bean.scheduleOrderExpiry.ts +26 -0
  69. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.index.ts +9 -0
  70. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.version.ts +75 -0
  71. package/vona/src/suite/a-commerce/modules/commerce-trade/src/config/locale/en-us.ts +11 -2
  72. package/vona/src/suite/a-commerce/modules/commerce-trade/src/config/locale/zh-cn.ts +11 -2
  73. package/vona/src/suite/a-commerce/modules/commerce-trade/src/controller/checkout.ts +19 -0
  74. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutCreate.tsx +19 -0
  75. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/checkoutResult.tsx +32 -0
  76. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderAddressSnapshot.tsx +33 -0
  77. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderCouponSnapshot.tsx +35 -0
  78. package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/orderLineSkuAttributeSnapshot.tsx +15 -0
  79. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/order.tsx +90 -0
  80. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/orderAudit.tsx +36 -0
  81. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/orderLine.tsx +52 -0
  82. package/vona/src/suite/a-commerce/modules/commerce-trade/src/entity/stockReservation.tsx +4 -1
  83. package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/order.ts +15 -0
  84. package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/orderAudit.ts +10 -0
  85. package/vona/src/suite/a-commerce/modules/commerce-trade/src/model/orderLine.ts +10 -0
  86. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +556 -0
  87. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/stockBalance.ts +15 -20
  88. package/vona/src/suite/a-commerce/modules/commerce-trade/test/cartOwnership.test.ts +8 -3
  89. package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutReservation.test.ts +158 -0
  90. package/vona/src/suite/a-commerce/modules/commerce-trade/test/orderSnapshot.test.ts +203 -0
  91. package/vona/src/suite/a-commerce/modules/commerce-trade/test/reservationExpiry.test.ts +163 -0
  92. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockBalance.test.ts +11 -6
  93. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockReservation.test.ts +11 -6
  94. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/package.json +1 -1
  95. package/vona/src/suite-vendor/a-image/modules/image-cloudflare/src/service/imageCloudflare.ts +10 -7
  96. package/vona/src/suite-vendor/a-image/package.json +1 -1
  97. package/vona/src/suite-vendor/a-vona/modules/a-aspect/package.json +1 -1
  98. package/vona/src/suite-vendor/a-vona/modules/a-aspect/src/types/aopMethod.ts +16 -3
  99. package/vona/src/suite-vendor/a-vona/modules/a-core/package.json +4 -2
  100. package/vona/src/suite-vendor/a-vona/modules/a-core/src/bean/bean.core.ts +6 -0
  101. package/vona/src/suite-vendor/a-vona/modules/a-core/src/index.ts +1 -0
  102. package/vona/src/suite-vendor/a-vona/modules/a-core/src/lib/core.ts +6 -0
  103. package/vona/src/suite-vendor/a-vona/modules/a-core/src/types/fetch.ts +7 -0
  104. package/vona/src/suite-vendor/a-vona/modules/a-core/src/types/index.ts +1 -0
  105. package/vona/src/suite-vendor/a-vona/modules/a-executor/package.json +1 -1
  106. package/vona/src/suite-vendor/a-vona/modules/a-executor/src/lib/utils.ts +5 -3
  107. package/vona/src/suite-vendor/a-vona/modules/a-retryable/package.json +50 -0
  108. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/.metadata/index.ts +55 -0
  109. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/.metadata/this.ts +2 -0
  110. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/bean/aopMethod.retryable.ts +45 -0
  111. package/vona/src/suite-vendor/a-vona/modules/a-retryable/src/index.ts +1 -0
  112. package/vona/src/suite-vendor/a-vona/modules/a-retryable/tsconfig.build.json +11 -0
  113. package/vona/src/suite-vendor/a-vona/modules/a-retryable/tsconfig.json +7 -0
  114. package/vona/src/suite-vendor/a-vona/modules/a-vona/package.json +2 -1
  115. package/vona/src/suite-vendor/a-vona/package.json +2 -1
  116. package/vona/src/suite-vendor/a-vona/tsconfig.json +3 -0
  117. package/zova/packages-utils/zova-jsx/package.json +3 -3
  118. package/zova/packages-zova/zova/package.json +3 -3
  119. package/zova/packages-zova/zova-core/package.json +2 -2
  120. package/zova/src/suite/a-commerce/modules/commerce-promotion/cli/openapi.config.ts +9 -0
  121. package/zova/src/suite/a-commerce/modules/commerce-promotion/package.json +2 -1
  122. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/.metadata/index.ts +115 -1
  123. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/commercePromotionCoupon.ts +24 -0
  124. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/baseURL.ts +5 -0
  125. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/index.ts +3 -0
  126. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/schemas.ts +478 -0
  127. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/types.ts +8187 -0
  128. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/apiSchema/commercePromotionCoupon.ts +13 -0
  129. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/model/coupon.ts +19 -0
  130. package/zova/src/suite/a-commerce/modules/commerce-trade/cli/openapi.config.ts +1 -0
  131. package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +30 -0
  132. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradeCheckout.ts +27 -0
  133. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/schemas.ts +92 -52
  134. package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/types.ts +1389 -848
  135. package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradeCheckout.ts +13 -0
  136. package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/cart.ts +13 -0
  137. package/zova/src/suite-vendor/a-zova/modules/a-zova/package.json +3 -3
  138. package/zova/src/suite-vendor/a-zova/package.json +2 -2
package/.cabloy-version CHANGED
@@ -1 +1 @@
1
- 5.1.113
1
+ 5.1.114
@@ -43,6 +43,10 @@ After generating or extending a backend thread, check which follow-up layers app
43
43
  - retain exact IDs or entities for every test-local persisted resource and delete them from `finally`
44
44
  - delete joins, children, derived records, and other dependents before their owners, in the same active tenant/instance scope
45
45
  - do not use unscoped, timestamp-, prefix-, or broad business-condition deletion when an exact test-owned identity is available
46
+ - treat the application lifecycle as runner-owned; do not create or close the shared `app` in an individual test
47
+ - give each scoped test or operation its own `app.bean.executor.mockCtx(...)` boundary
48
+ - for a business race, give every contender a separate `mockCtx(...)`, launch the competing operations explicitly, wait for all branches to settle, and assert the combined durable state
49
+ - gate lock- or isolation-sensitive contention tests on supporting database capabilities; runner scheduling is never race-condition evidence
46
50
 
47
51
  ## Module composition and dependency intent
48
52
 
@@ -34,6 +34,18 @@ jobs:
34
34
  version: 11.5.2
35
35
  - name: init
36
36
  run: npm run init
37
+ - name: run Commerce lifecycle gate
38
+ run: >-
39
+ DATABASE_DEFAULT_CLIENT=mysql DATABASE_CLIENT_MYSQL_HOST=127.0.0.1 DATABASE_CLIENT_MYSQL_PASSWORD=root npm run vona :bin:test --
40
+ commerce-trade/test/stockBalance.test.ts
41
+ commerce-trade/test/stockReservation.test.ts
42
+ commerce-trade/test/stockAudit.test.ts
43
+ commerce-promotion/test/couponReservation.test.ts
44
+ commerce-payment/test/paymentAttempt.test.ts
45
+ commerce-trade/test/checkoutReservation.test.ts
46
+ commerce-trade/test/reservationExpiry.test.ts
47
+ --flavor=normal
48
+ working-directory: vona
37
49
  - run: DATABASE_DEFAULT_CLIENT=mysql DATABASE_CLIENT_MYSQL_HOST=127.0.0.1 DATABASE_CLIENT_MYSQL_PASSWORD=root npm run test
38
50
  working-directory: vona
39
51
  - run: DATABASE_DEFAULT_CLIENT=mysql DATABASE_CLIENT_MYSQL_HOST=127.0.0.1 DATABASE_CLIENT_MYSQL_PASSWORD=root npm run build -- --flavor=ci
@@ -36,10 +36,14 @@ jobs:
36
36
  run: npm run init
37
37
  - name: run Commerce stock contention gate
38
38
  run: >-
39
- TEST_CONCURRENCY=false DATABASE_DEFAULT_CLIENT=pg npm run vona :bin:test --
39
+ DATABASE_DEFAULT_CLIENT=pg npm run vona :bin:test --
40
40
  commerce-trade/test/stockBalance.test.ts
41
41
  commerce-trade/test/stockReservation.test.ts
42
42
  commerce-trade/test/stockAudit.test.ts
43
+ commerce-promotion/test/couponReservation.test.ts
44
+ commerce-payment/test/paymentAttempt.test.ts
45
+ commerce-trade/test/checkoutReservation.test.ts
46
+ commerce-trade/test/reservationExpiry.test.ts
43
47
  --flavor=normal
44
48
  working-directory: vona
45
49
  - run: DATABASE_DEFAULT_CLIENT=pg npm run test
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.1.114
4
+
5
+ ### Features
6
+
7
+ - Add retryable AOP infrastructure for Commerce transactions.
8
+ - Add a foundational Commerce coupon lifecycle.
9
+ - Add a foundational Commerce order snapshot capability.
10
+ - Add `ctx.state.fetch` support.
11
+ - Add checkout functionality.
12
+
13
+ ### Improvements
14
+
15
+ - Decorate Commerce transaction retries.
16
+
3
17
  ## 5.1.113
4
18
 
5
19
  ### Features
package/CLAUDE.md CHANGED
@@ -72,6 +72,10 @@ Before inventing a custom implementation path:
72
72
  - In Vona, a tenant corresponds to an instance. Ordinary resource-model CRUD is automatically scoped to the active instance; treat records absent from that scope as absent, and do not use raw cross-instance probes merely to choose between `403` and not-found behavior. Model future multi-merchant boundaries explicitly inside an instance.
73
73
  - Model cross-Model query-cache dependencies as one directed, acyclic `modelsClear` / `modelsClearedBy` graph, and verify source mutations refresh warmed dependent queries; read `.docs-internal/architecture/vona-cross-model-query-cache-dependencies.md` before designing a nontrivial graph.
74
74
  - For `@Api.field(...)` and related schemaLike composition, framework guards now preserve previously attached OpenAPI metadata across schema rebuilds, but structure-shaping schemaLike is still order-sensitive. Treat `v.object(...)`, `v.array(...)`, `v.optional()`, `v.nullable()`, `v.default(...)`, and preprocess/transform wrappers as structure-shaping; keep the final structure-defining schemaLike last and verify emitted schema/OpenAPI output after such edits.
75
+ - `@Core.transaction(...)` defaults to `REQUIRED`: it starts a transaction only when none exists and otherwise joins the current datasource transaction without upgrading its isolation. Prefer it over manual `inTransaction` wrappers for atomic service methods; use `REQUIRES_NEW` only when an independently committed boundary is explicitly required.
76
+ - For replay-safe transient failures, use `@Core.retryable(...)` with an explicit `errorCodes` allowlist. It retries the downstream AOP suffix, so place it closest to the method when it must wrap and retry a `@Core.transaction(...)` boundary; use `ownerOnly: true` for dual-role leaves that must not retry inside a caller-owned transaction; do not retry external side effects or infer retryability from isolation level.
77
+ - Backend tests that use scoped Vona state must own an `app.bean.executor.mockCtx(...)` boundary; every intentionally competing operation must run in a separate `mockCtx(...)`.
78
+ - Do not set `TEST_CONCURRENCY=false` for normal `npm run test` or CI runs: an unset value defaults to concurrent execution. For stateful `node:test` suites, declare `{ concurrency: false }` explicitly. Prove business contention by explicitly launching competing operations and asserting their combined durable outcome, never through runner parallelism or scheduling.
75
79
  - Unit tests must delete every test-owned persisted resource in `finally`, using precise owned identities and reverse dependency order.
76
80
  - Shared durable test or local-development fixtures must be created through the owning module's `meta.version.ts` `seed()` hook and treated as read-only by tests; the managed seed path starts from a newly recreated database rather than repeating against one database.
77
81
 
@@ -82,6 +82,7 @@ Representative examples include:
82
82
 
83
83
  - `a-logger:log`
84
84
  - `a-orm:transaction`
85
+ - `a-retryable:retryable`
85
86
  - `a-caching:cachingGet`
86
87
  - `a-caching:cachingSet`
87
88
  - `a-caching:cachingDel`
@@ -92,9 +93,43 @@ These built-ins also expose shorthand decorators such as:
92
93
  ```typescript
93
94
  @Core.log({ level: 'info' })
94
95
  @Core.transaction({ isolationLevel: 'READ_COMMITTED', propagation: 'REQUIRED' })
96
+ @Core.retryable({
97
+ retries: 1,
98
+ minTimeout: 0,
99
+ maxTimeout: 0,
100
+ errorCodes: ['40001'],
101
+ })
95
102
  @Caching.get({ cacheName: 'module-name:xxx' })
96
103
  ```
97
104
 
105
+ `@Core.retryable(...)` retries only errors whose string `code` is explicitly listed in `errorCodes`. It replays the downstream AOP suffix; to retry a complete transaction, place `@Core.retryable(...)` closest to the method so it runs outside the transaction decorator:
106
+
107
+ ```typescript
108
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
109
+ @Core.retryable({ retries: 1, minTimeout: 0, maxTimeout: 0, errorCodes: ['40001'] })
110
+ async reserve() {
111
+ // transactional writes
112
+ }
113
+ ```
114
+
115
+ Use `ownerOnly: true` for a dual-role leaf method that may either own a transaction when called directly or join an aggregate transaction owned by its caller. It retries only when no current-datasource transaction existed when the method began; when nested, it executes once and lets the aggregate owner decide whether to replay the complete unit of work:
116
+
117
+ ```typescript
118
+ @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
119
+ @Core.retryable({
120
+ retries: 1,
121
+ minTimeout: 0,
122
+ maxTimeout: 0,
123
+ errorCodes: ['40001'],
124
+ ownerOnly: true,
125
+ })
126
+ async reserveLeaf() {
127
+ // transactional writes
128
+ }
129
+ ```
130
+
131
+ Retried methods must be replay-safe: defer external side effects until commit, and do not use this helper to imply cross-datasource or independent `REQUIRES_NEW` retry semantics.
132
+
98
133
  For the broader logger-client, rotation, and level model behind `@Core.log(...)`, see [Logger Guide](/backend/logger-guide).
99
134
 
100
135
  ## Magic Method
@@ -46,11 +46,18 @@ npm run test
46
46
 
47
47
  A typical Vona test flow includes:
48
48
 
49
- 1. create a global `app` object
49
+ 1. create one shared global `app` object for the test run
50
50
  2. clean Redis data
51
51
  3. recreate the database
52
52
  4. execute migration code
53
53
  5. run the test files
54
+ 6. close the shared application after the test run
55
+
56
+ The runner owns this application lifecycle. Test files import the shared app from `vona-mock` and must not create or close it themselves:
57
+
58
+ ```typescript
59
+ import { app } from 'vona-mock';
60
+ ```
54
61
 
55
62
  This is one of the most important distinctions from ordinary app flow: test execution rebuilds and verifies the framework lifecycle, not only the target function.
56
63
 
@@ -72,6 +79,33 @@ Representative command:
72
79
  cd vona && npm run cov
73
80
  ```
74
81
 
82
+ ## Runner concurrency
83
+
84
+ The Vona runner uses Node's built-in test runner with one shared application and process isolation disabled. `TEST_CONCURRENCY` controls runner scheduling:
85
+
86
+ - `true` uses the available CPU count;
87
+ - `false` forces serial execution;
88
+ - a positive integer sets an explicit concurrency limit.
89
+
90
+ ```bash
91
+ TEST_CONCURRENCY=false npm run test
92
+ TEST_CONCURRENCY=4 npm run test
93
+ ```
94
+
95
+ Cabloy Basic requests concurrency by default with `TEST_CONCURRENCY=true`. When the active database dialect does not advertise the required concurrency capability, the runner falls back to serial execution. The default SQLite path therefore runs serially.
96
+
97
+ Runner concurrency schedules ordinary tests; it does not prove a business-level race condition. Tests must not rely on scheduling, timing, or execution order for correctness.
98
+
99
+ A suite can also make sibling-test scheduling explicit:
100
+
101
+ ```typescript
102
+ describe('resource.test.ts', { concurrency: false }, () => {
103
+ // sibling tests in this suite are serialized
104
+ });
105
+ ```
106
+
107
+ This setting controls test scheduling only. It is not a substitute for explicitly creating competing business operations.
108
+
75
109
  ## Mock request context
76
110
 
77
111
  One of the most important Vona testing patterns is simulating a request context.
@@ -84,6 +118,10 @@ await app.bean.executor.mockCtx(async () => {
84
118
  });
85
119
  ```
86
120
 
121
+ Treat one `mockCtx(...)` callback as one simulated request boundary. Keep `app.ctx`, authentication, current database selection, and context-dependent service, model, or action work inside that callback, and always await it. Do not retain request-context state after the callback returns.
122
+
123
+ `mockCtx(...)` isolates request-local context. It does not isolate committed persisted records, app-global state, external caches, or shared durable fixtures. For independent concurrent requests, create one `mockCtx(...)` for each branch; parallel work started inside a single `mockCtx(...)` shares that request context.
124
+
87
125
  Locale-sensitive variants and additional request-context helpers are also available.
88
126
 
89
127
  ## Persisted fixture lifecycle
@@ -111,6 +149,37 @@ try {
111
149
 
112
150
  Treat shared `seed()` records as read-only. If a scenario needs to change a record, create a separate test-local fixture instead and clean it up. See [Migration and Changes](/backend/migration-and-changes#seed-test-environment-baseline-data) for the durable seed lifecycle.
113
151
 
152
+ Application shutdown is runner-owned; authentication and fixture cleanup are test-owned. Do not call `app.close()` from a test. Keep `signout()` and exact-identity deletion in `finally` inside an appropriate request context.
153
+
154
+ ## Testing concurrent behavior explicitly
155
+
156
+ A concurrency test creates competing business operations inside one test; it does not depend on the runner to happen to schedule tests at the same time.
157
+
158
+ ```typescript
159
+ const attempt = async () => {
160
+ return await app.bean.executor.mockCtx(async () => {
161
+ await app.bean.passport.signinMock();
162
+ try {
163
+ return await reserve(resourceId);
164
+ } finally {
165
+ await app.bean.passport.signout();
166
+ }
167
+ });
168
+ };
169
+
170
+ const results = await Promise.allSettled([attempt(), attempt()]);
171
+ ```
172
+
173
+ Use this sequence:
174
+
175
+ 1. create a dedicated test-local fixture in an appropriate `mockCtx(...)`;
176
+ 2. give every contender its own `mockCtx(...)` and authentication lifecycle;
177
+ 3. launch the contenders explicitly and wait for all of them to settle;
178
+ 4. use a fresh `mockCtx(...)` to assert both individual outcomes and the combined durable invariant;
179
+ 5. clean up exact owned fixtures only after every branch has settled, in the appropriate tenant/instance context.
180
+
181
+ For example, a one-winner reservation test should verify not only the fulfilled and rejected operations but also the final balance, surviving reservation, and audit records. If the invariant depends on row locks, transaction isolation, or another database-specific capability, skip the test on unsupported dialects rather than weakening its assertions. See [stockReservation.test.ts](../../vona/src/suite/a-commerce/modules/commerce-trade/test/stockReservation.test.ts) for this pattern.
182
+
114
183
  ## Working with module scope in tests
115
184
 
116
185
  Representative pattern:
@@ -218,6 +287,8 @@ Read this guide together with:
218
287
  - [CRUD Workflow](/backend/crud-workflow)
219
288
  - [Migration and Changes](/backend/migration-and-changes)
220
289
  - [Controller Guide](/backend/controller-guide)
290
+ - [Transaction Guide](/backend/transaction-guide)
291
+ - [Redlock Guide](/backend/redlock-guide)
221
292
 
222
293
  A practical split is:
223
294
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cabloy",
3
- "version": "5.1.113",
3
+ "version": "5.1.114",
4
4
  "gitHead": "2c5c19284bab738e492856189acb6fad74b8a7b7",
5
5
  "description": "A Node.js fullstack framework",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-cli",
3
- "version": "1.1.131",
3
+ "version": "1.1.132",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona cli",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-cli-set-api",
3
- "version": "1.1.129",
3
+ "version": "1.1.130",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona cli-set-api",
6
6
  "keywords": [
@@ -7,6 +7,7 @@ import { globby } from 'globby';
7
7
  import { createWriteStream } from 'node:fs';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
+ import { pipeline } from 'node:stream/promises';
10
11
  import { run } from 'node:test';
11
12
  import { lcov, spec } from 'node:test/reporters';
12
13
  import { fileURLToPath } from 'node:url';
@@ -104,19 +105,25 @@ async function testRun(projectPath: string, coverage: boolean, patterns: string[
104
105
  }
105
106
  });
106
107
  const summaryPromise = waitForTestSummary(testStream);
107
- if (coverage) {
108
- const reporterDir = path.join(projectPath, 'coverage');
109
- fse.ensureDirSync(reporterDir);
110
- const reporterLcov = createWriteStream(path.join(reporterDir, 'lcov.info'));
111
- testStream.compose(lcov).pipe(reporterLcov);
112
- } else {
113
- testStream.compose(spec).pipe(process.stdout);
114
- }
108
+ const reporterPromise = coverage
109
+ ? (() => {
110
+ const reporterDir = path.join(projectPath, 'coverage');
111
+ fse.ensureDirSync(reporterDir);
112
+ const reporterLcov = createWriteStream(path.join(reporterDir, 'lcov.info'));
113
+ return pipeline(testStream.compose(lcov), reporterLcov);
114
+ })()
115
+ : pipeline(testStream.compose(spec), process.stdout, { end: false });
115
116
 
116
- const summarySuccess = await summaryPromise;
117
+ const [summarySuccess, [, reporterError]] = await Promise.all([
118
+ summaryPromise,
119
+ catchError(() => reporterPromise),
120
+ ]);
117
121
  if (!summarySuccess) {
118
122
  throw new Error('node:test reported failed tests');
119
123
  }
124
+ if (reporterError) {
125
+ throw reporterError;
126
+ }
120
127
  } catch (error) {
121
128
  testError = error;
122
129
  } finally {
@@ -143,13 +150,19 @@ function waitForTestSummary(testStream: ReturnType<typeof run>) {
143
150
 
144
151
  async function prepareConcurrency(app: VonaApplication) {
145
152
  // check
153
+ const configured = process.env.TEST_CONCURRENCY;
146
154
  let concurrency = 1;
147
- if (process.env.TEST_CONCURRENCY === 'true') {
148
- concurrency = os.cpus().length;
149
- } else if (process.env.TEST_CONCURRENCY === 'false') {
155
+ if (configured === 'false') {
150
156
  concurrency = 1;
157
+ } else if (configured === undefined || configured === 'true') {
158
+ concurrency = Math.max(1, os.cpus().length);
151
159
  } else {
152
- concurrency = Number.parseInt(process.env.TEST_CONCURRENCY!);
160
+ concurrency = Number.parseInt(configured, 10);
161
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
162
+ throw new Error(
163
+ `TEST_CONCURRENCY must be true, false, or a positive integer; received ${configured}`,
164
+ );
165
+ }
153
166
  }
154
167
  if (concurrency === 1) return concurrency;
155
168
  // check again
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cabloy/compose",
3
- "version": "2.1.8",
3
+ "version": "2.1.9",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "cabloy compose",
6
6
  "keywords": [
@@ -10,32 +10,40 @@ function __adapterDefault(_context, chain) {
10
10
  };
11
11
  }
12
12
 
13
+ export type TypeComposeNext = ((context?: any) => any) & {
14
+ replay: (context?: any) => any;
15
+ };
16
+
13
17
  export function compose(chains?: any[], adapter?: TypeComposeAdapter) {
14
18
  if (!adapter) adapter = __adapterDefault;
15
19
  if (!chains) chains = [];
16
20
  return function (context, next?) {
17
- // last called middleware #
18
- let index = -1;
19
- return dispatch(0, context);
20
- function dispatch(i, context) {
21
- if (i <= index) throw new Error('next() called multiple times');
22
- index = i;
21
+ return dispatch(0, context, { index: -1 });
22
+
23
+ function dispatch(i, context, state: { index: number }) {
24
+ if (i <= state.index) throw new Error('next() called multiple times');
25
+ state.index = i;
23
26
  let receiver;
24
27
  let fn;
25
28
  const chain = chains![i];
26
29
  if (chain) {
27
30
  const obj = adapter!(context, chain);
28
- if (!obj) return dispatch(i + 1, context);
31
+ if (!obj) return dispatch(i + 1, context, state);
29
32
  receiver = obj.receiver;
30
33
  fn = obj.fn;
31
34
  if (!fn) throw new Error('fn is not defined');
32
35
  }
33
36
  if (i === chains!.length) fn = next;
34
37
  if (!fn) return context;
35
- return fn.call(receiver, context, (...args) => {
36
- context = args.length === 0 ? context : args[0];
37
- return dispatch(i + 1, context);
38
- });
38
+ const nextPatched = ((...args) => {
39
+ const nextContext = args.length === 0 ? context : args[0];
40
+ return dispatch(i + 1, nextContext, state);
41
+ }) as TypeComposeNext;
42
+ nextPatched.replay = (...args) => {
43
+ const nextContext = args.length === 0 ? context : args[0];
44
+ return dispatch(i + 1, nextContext, { index: i });
45
+ };
46
+ return fn.call(receiver, context, nextPatched);
39
47
  }
40
48
  };
41
49
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona",
3
- "version": "5.1.78",
3
+ "version": "5.1.79",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "Vona is an intuitive, elegant and powerful Node.js framework for rapidly developing enterprise applications of any size",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-core",
3
- "version": "5.1.32",
3
+ "version": "5.1.33",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona",
6
6
  "keywords": [
@@ -614,7 +614,7 @@ export class BeanContainer {
614
614
  throw new Error(`get property accessor not exists: ${aopMethod.onionName}`);
615
615
  return beanInstance.get(
616
616
  aopMethod.options,
617
- _patchAopNext([receiver, _], next),
617
+ _patchAopMethodNext([receiver, _], next),
618
618
  receiver,
619
619
  prop,
620
620
  );
@@ -627,7 +627,7 @@ export class BeanContainer {
627
627
  return beanInstance.set(
628
628
  aopMethod.options,
629
629
  value,
630
- _patchAopNext([receiver, value], next),
630
+ _patchAopMethodNext([receiver, value], next),
631
631
  receiver,
632
632
  prop,
633
633
  );
@@ -640,7 +640,7 @@ export class BeanContainer {
640
640
  return beanInstance.execute(
641
641
  aopMethod.options,
642
642
  args,
643
- _patchAopNext([receiver, args], next),
643
+ _patchAopMethodNext([receiver, args], next),
644
644
  receiver,
645
645
  prop,
646
646
  );
@@ -673,6 +673,18 @@ function _patchAopNext([receiver, context], next) {
673
673
  };
674
674
  }
675
675
 
676
+ function _patchAopMethodNext([receiver, context], next) {
677
+ const nextPatched = (...args) => {
678
+ context = args.length === 0 ? context : args[0];
679
+ return next([receiver, context]);
680
+ };
681
+ nextPatched.replay = (...args) => {
682
+ const nextContext = args.length === 0 ? context : args[0];
683
+ return next.replay([receiver, nextContext]);
684
+ };
685
+ return nextPatched;
686
+ }
687
+
676
688
  function __checkAopOfDescriptorInfo(descriptorInfo) {
677
689
  if (!descriptorInfo) return true;
678
690
  return !descriptorInfo.dynamic && !descriptorInfo.ofBeanBase;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vona-mock",
3
- "version": "6.1.32",
3
+ "version": "6.1.33",
4
4
  "gitHead": "a79189b882c17af5911573896a781bbb0046d37d",
5
5
  "description": "vona mock",
6
6
  "keywords": [
@@ -253,6 +253,9 @@ importers:
253
253
  vona-module-a-redlock:
254
254
  specifier: workspace:^
255
255
  version: link:src/suite-vendor/a-vona/modules/a-redlock
256
+ vona-module-a-retryable:
257
+ specifier: workspace:^
258
+ version: link:src/suite-vendor/a-vona/modules/a-retryable
256
259
  vona-module-a-runtime:
257
260
  specifier: workspace:^
258
261
  version: link:src/suite-vendor/a-vona/modules/a-runtime
@@ -1991,6 +1994,9 @@ importers:
1991
1994
  vona-module-a-redlock:
1992
1995
  specifier: workspace:^
1993
1996
  version: link:modules/a-redlock
1997
+ vona-module-a-retryable:
1998
+ specifier: workspace:^
1999
+ version: link:modules/a-retryable
1994
2000
  vona-module-a-runtime:
1995
2001
  specifier: workspace:^
1996
2002
  version: link:modules/a-runtime
@@ -2618,6 +2624,15 @@ importers:
2618
2624
  specifier: ^6.1.3
2619
2625
  version: 6.1.3
2620
2626
 
2627
+ src/suite-vendor/a-vona/modules/a-retryable:
2628
+ devDependencies:
2629
+ clean-package:
2630
+ specifier: ^2.2.0
2631
+ version: 2.2.0
2632
+ rimraf:
2633
+ specifier: ^6.1.3
2634
+ version: 6.1.3
2635
+
2621
2636
  src/suite-vendor/a-vona/modules/a-runtime:
2622
2637
  devDependencies:
2623
2638
  clean-package:
@@ -377,6 +377,7 @@ export * from '../dto/productSelectRes.tsx';
377
377
  export * from '../dto/productSelectResItem.tsx';
378
378
  export * from '../dto/productUpdate.tsx';
379
379
  export * from '../dto/productView.tsx';
380
+ export * from '../dto/skuAttribute.tsx';
380
381
  export * from '../dto/skuCreate.tsx';
381
382
  export * from '../dto/skuSelectReq.tsx';
382
383
  export * from '../dto/skuSelectRes.tsx';
@@ -399,6 +400,7 @@ import type { IDtoOptionsProductSelectRes } from '../dto/productSelectRes.tsx';
399
400
  import type { IDtoOptionsProductSelectResItem } from '../dto/productSelectResItem.tsx';
400
401
  import type { IDtoOptionsProductUpdate } from '../dto/productUpdate.tsx';
401
402
  import type { IDtoOptionsProductView } from '../dto/productView.tsx';
403
+ import type { IDtoOptionsSkuAttribute } from '../dto/skuAttribute.tsx';
402
404
  import type { IDtoOptionsSkuCreate } from '../dto/skuCreate.tsx';
403
405
  import type { IDtoOptionsSkuSelectReq } from '../dto/skuSelectReq.tsx';
404
406
  import type { IDtoOptionsSkuSelectRes } from '../dto/skuSelectRes.tsx';
@@ -425,6 +427,7 @@ declare module 'vona-module-a-web' {
425
427
  'commerce-catalog:productSelectResItem': IDtoOptionsProductSelectResItem;
426
428
  'commerce-catalog:productUpdate': IDtoOptionsProductUpdate;
427
429
  'commerce-catalog:productView': IDtoOptionsProductView;
430
+ 'commerce-catalog:skuAttribute': IDtoOptionsSkuAttribute;
428
431
  'commerce-catalog:skuCreate': IDtoOptionsSkuCreate;
429
432
  'commerce-catalog:skuSelectReq': IDtoOptionsSkuSelectReq;
430
433
  'commerce-catalog:skuSelectRes': IDtoOptionsSkuSelectRes;
@@ -456,6 +459,7 @@ import type { DtoProductSelectRes } from '../dto/productSelectRes.tsx';
456
459
  import type { DtoProductSelectResItem } from '../dto/productSelectResItem.tsx';
457
460
  import type { DtoProductUpdate } from '../dto/productUpdate.tsx';
458
461
  import type { DtoProductView } from '../dto/productView.tsx';
462
+ import type { DtoSkuAttribute } from '../dto/skuAttribute.tsx';
459
463
  import type { DtoSkuCreate } from '../dto/skuCreate.tsx';
460
464
  import type { DtoSkuSelectReq } from '../dto/skuSelectReq.tsx';
461
465
  import type { DtoSkuSelectRes } from '../dto/skuSelectRes.tsx';
@@ -528,6 +532,10 @@ declare module 'vona-module-commerce-catalog' {
528
532
  fields?: TypeEntityOptionsFields<DtoProductView, IDtoOptionsProductView[TypeSymbolKeyFieldsMore]>;
529
533
  }
530
534
 
535
+ export interface IDtoOptionsSkuAttribute {
536
+ fields?: TypeEntityOptionsFields<DtoSkuAttribute, IDtoOptionsSkuAttribute[TypeSymbolKeyFieldsMore]>;
537
+ }
538
+
531
539
  export interface IDtoOptionsSkuCreate {
532
540
  fields?: TypeEntityOptionsFields<DtoSkuCreate, IDtoOptionsSkuCreate[TypeSymbolKeyFieldsMore]>;
533
541
  }
@@ -42,6 +42,7 @@ export class MetaVersion extends BeanBase implements IMetaVersionUpdate {
42
42
  table.tableIdentity(entitySku.productId).comment(entitySku.$comment.productId);
43
43
  table.string(entitySku.code, 100).comment(entitySku.$comment.code);
44
44
  table.integer(entitySku.priceCents).comment(entitySku.$comment.priceCents);
45
+ table.json(entitySku.attributes).comment(entitySku.$comment.attributes);
45
46
  table.string(entitySku.lifecycle, 20).comment(entitySku.$comment.lifecycle);
46
47
  table.index([entitySku.productId], `idx_${entitySku.$table}_productId`);
47
48
  table.index([entitySku.lifecycle], `idx_${entitySku.$table}_lifecycle`);
@@ -10,6 +10,9 @@ export default {
10
10
  ProductTitle: 'Product title',
11
11
  Published: 'Published',
12
12
  Sku: 'SKU',
13
+ SkuAttributeName: 'SKU attribute name',
14
+ SkuAttributeValue: 'SKU attribute value',
15
+ SkuAttributes: 'SKU attributes',
13
16
  SkuCode: 'SKU code',
14
17
  SkuLifecycle: 'SKU lifecycle',
15
18
  SkuLifecycleActive: 'Active',
@@ -10,6 +10,9 @@ export default {
10
10
  ProductTitle: '商品标题',
11
11
  Published: '已发布',
12
12
  Sku: 'SKU',
13
+ SkuAttributeName: 'SKU 属性名',
14
+ SkuAttributeValue: 'SKU 属性值',
15
+ SkuAttributes: 'SKU 属性',
13
16
  SkuCode: 'SKU 编码',
14
17
  SkuLifecycle: 'SKU 生命周期',
15
18
  SkuLifecycleActive: '已启用',
@@ -0,0 +1,17 @@
1
+ import type { IDecoratorDtoOptions } from 'vona-module-a-web';
2
+
3
+ import { Api, v } from 'vona-module-a-openapiutils';
4
+ import { Dto } from 'vona-module-a-web';
5
+
6
+ import { $locale } from '../.metadata/locales.ts';
7
+
8
+ export interface IDtoOptionsSkuAttribute extends IDecoratorDtoOptions {}
9
+
10
+ @Dto<IDtoOptionsSkuAttribute>()
11
+ export class DtoSkuAttribute {
12
+ @Api.field(v.title($locale('SkuAttributeName')), v.required(), v.min(1), v.max(100))
13
+ name: string;
14
+
15
+ @Api.field(v.title($locale('SkuAttributeValue')), v.required(), v.min(1), v.max(255))
16
+ value: string;
17
+ }
@@ -7,6 +7,7 @@ import z from 'zod';
7
7
  import { ZovaRender } from 'zova-rest-cabloy-basic-admin';
8
8
 
9
9
  import { $locale } from '../.metadata/locales.ts';
10
+ import { DtoSkuAttribute } from '../dto/skuAttribute.tsx';
10
11
 
11
12
  export const skuLifecycleItems = [
12
13
  { value: 'draft', title: $locale('SkuLifecycleDraft') },
@@ -56,10 +57,18 @@ export class EntitySku extends EntityBase {
56
57
  )
57
58
  priceCents: number;
58
59
 
60
+ @Api.field(
61
+ v.title($locale('SkuAttributes')),
62
+ v.default([]),
63
+ v.array(v.object(DtoSkuAttribute)),
64
+ ZovaRender.order(4),
65
+ )
66
+ attributes: DtoSkuAttribute[];
67
+
59
68
  @Api.field(
60
69
  v.title($locale('SkuLifecycle')),
61
70
  v.default('draft'),
62
- ZovaRender.order(4),
71
+ ZovaRender.order(5),
63
72
  z.enum(['draft', 'active', 'inactive', 'archived']),
64
73
  )
65
74
  lifecycle: 'draft' | 'active' | 'inactive' | 'archived';
@@ -448,7 +448,7 @@ describe('catalog.test.ts', () => {
448
448
  let foreignCatalog!: Awaited<ReturnType<typeof createCatalog>>;
449
449
  await app.bean.executor.mockCtx(
450
450
  async () => {
451
- foreignCatalog = await createCatalog(`__catalog-relation-foreign-${suffix}__`, 'active');
451
+ foreignCatalog = await createCatalog(`crf-${suffix}`, 'active');
452
452
  },
453
453
  { instanceName: 'shareTest' as any },
454
454
  );