cabloy 5.1.115 → 5.1.116

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 (31) hide show
  1. package/.cabloy-version +1 -1
  2. package/.github/workflows/vona-test-mysql.yml +6 -10
  3. package/.github/workflows/vona-test-pg.yml +6 -10
  4. package/CHANGELOG.md +14 -0
  5. package/cabloy-docs/backend/orm-mutation-guide.md +36 -10
  6. package/cabloy-docs/backend/orm-select-guide.md +15 -10
  7. package/package.json +1 -1
  8. package/vona/packages-vona/vona/package.json +1 -1
  9. package/vona/pnpm-lock.yaml +8 -124
  10. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/sku.test.ts +1 -1
  11. package/vona/src/suite/a-commerce/modules/commerce-member/test/addressOwnership.test.ts +4 -1
  12. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/dto/couponIssue.tsx +1 -1
  13. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/service/coupon.ts +4 -1
  14. package/vona/src/suite/a-commerce/modules/commerce-promotion/test/couponReservation.test.ts +85 -2
  15. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/cart.ts +14 -0
  16. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/order.ts +42 -2
  17. package/vona/src/suite/a-commerce/modules/commerce-trade/src/service/stockBalance.ts +4 -3
  18. package/vona/src/suite/a-commerce/modules/commerce-trade/test/cartOwnership.test.ts +160 -80
  19. package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutReservation.test.ts +255 -1
  20. package/vona/src/suite/a-commerce/modules/commerce-trade/test/checkoutTransaction.test.ts +208 -0
  21. package/vona/src/suite/a-commerce/modules/commerce-trade/test/orderSnapshot.test.ts +28 -4
  22. package/vona/src/suite/a-commerce/modules/commerce-trade/test/reservationExpiry.test.ts +90 -8
  23. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockBalance.test.ts +15 -1
  24. package/vona/src/suite/a-commerce/modules/commerce-trade/test/stockReservation.test.ts +20 -2
  25. package/vona/src/suite/a-commerce/modules/commerce-trade/test/testLock.ts +11 -0
  26. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  27. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_crud_inner.ts +3 -0
  28. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/lib/bean.model/bean.model_utils.ts +1 -1
  29. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  30. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/schemas.ts +40 -32
  31. package/zova/src/suite/a-commerce/modules/commerce-promotion/src/api/openapi/types.ts +638 -633
package/.cabloy-version CHANGED
@@ -1 +1 @@
1
- 5.1.115
1
+ 5.1.116
@@ -35,16 +35,12 @@ jobs:
35
35
  - name: init
36
36
  run: npm run init
37
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
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 -- commerce-trade/test/stockBalance.test.ts commerce-trade/test/stockReservation.test.ts commerce-trade/test/stockAudit.test.ts --flavor=normal
40
+ DATABASE_DEFAULT_CLIENT=mysql DATABASE_CLIENT_MYSQL_HOST=127.0.0.1 DATABASE_CLIENT_MYSQL_PASSWORD=root npm run vona :bin:test -- commerce-promotion/test/couponReservation.test.ts commerce-payment/test/paymentAttempt.test.ts --flavor=normal
41
+ DATABASE_DEFAULT_CLIENT=mysql DATABASE_CLIENT_MYSQL_HOST=127.0.0.1 DATABASE_CLIENT_MYSQL_PASSWORD=root npm run vona :bin:test -- commerce-trade/test/checkoutReservation.test.ts commerce-trade/test/checkoutTransaction.test.ts --flavor=normal
42
+ DATABASE_DEFAULT_CLIENT=mysql DATABASE_CLIENT_MYSQL_HOST=127.0.0.1 DATABASE_CLIENT_MYSQL_PASSWORD=root npm run vona :bin:test -- commerce-trade/test/orderSnapshot.test.ts --flavor=normal
43
+ DATABASE_DEFAULT_CLIENT=mysql DATABASE_CLIENT_MYSQL_HOST=127.0.0.1 DATABASE_CLIENT_MYSQL_PASSWORD=root npm run vona :bin:test -- commerce-trade/test/reservationExpiry.test.ts --flavor=normal
48
44
  working-directory: vona
49
45
  - run: DATABASE_DEFAULT_CLIENT=mysql DATABASE_CLIENT_MYSQL_HOST=127.0.0.1 DATABASE_CLIENT_MYSQL_PASSWORD=root npm run test
50
46
  working-directory: vona
@@ -35,16 +35,12 @@ jobs:
35
35
  - name: init
36
36
  run: npm run init
37
37
  - name: run Commerce stock contention gate
38
- run: >-
39
- DATABASE_DEFAULT_CLIENT=pg 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
38
+ run: |
39
+ DATABASE_DEFAULT_CLIENT=pg npm run vona :bin:test -- commerce-trade/test/stockBalance.test.ts commerce-trade/test/stockReservation.test.ts commerce-trade/test/stockAudit.test.ts --flavor=normal
40
+ DATABASE_DEFAULT_CLIENT=pg npm run vona :bin:test -- commerce-promotion/test/couponReservation.test.ts commerce-payment/test/paymentAttempt.test.ts --flavor=normal
41
+ DATABASE_DEFAULT_CLIENT=pg npm run vona :bin:test -- commerce-trade/test/checkoutReservation.test.ts commerce-trade/test/checkoutTransaction.test.ts --flavor=normal
42
+ DATABASE_DEFAULT_CLIENT=pg npm run vona :bin:test -- commerce-trade/test/orderSnapshot.test.ts --flavor=normal
43
+ DATABASE_DEFAULT_CLIENT=pg npm run vona :bin:test -- commerce-trade/test/reservationExpiry.test.ts --flavor=normal
48
44
  working-directory: vona
49
45
  - run: DATABASE_DEFAULT_CLIENT=pg npm run test
50
46
  working-directory: vona
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.1.116
4
+
5
+ ### Features
6
+
7
+ - Update platform capabilities and integrations.
8
+
9
+ ### Bug Fixes
10
+
11
+ - Stabilize Commerce trade transactions and associated tests.
12
+
13
+ ### Improvements
14
+
15
+ - Update address ownership test coverage.
16
+
3
17
  ## 5.1.115
4
18
 
5
19
  ### Improvements
@@ -29,6 +29,32 @@ await this.scope.model.post.delete({ id: 1 });
29
29
 
30
30
  These operations are the clearest fit when the caller already knows the exact write intent.
31
31
 
32
+ ## Field presence in insert and update
33
+
34
+ For normal writable fields, insert and update intentionally interpret field presence differently:
35
+
36
+ | Field state | `insert` | `update` |
37
+ | -------------------------- | -------------------------------------------------- | -------------------------------- |
38
+ | Field is absent | Leave the column to normal insert/default behavior | Leave the stored value unchanged |
39
+ | Own field with `undefined` | Leave the column to normal insert/default behavior | Write SQL `NULL` |
40
+ | `null` | Write SQL `NULL` | Write SQL `NULL` |
41
+
42
+ An own `undefined` means the property exists on the JavaScript object but its value is `undefined`, such as `{ title: undefined }`. This lets an update distinguish an omitted field from an intentional clear:
43
+
44
+ ```typescript
45
+ await this.scope.model.post.update({
46
+ id,
47
+ title: undefined, // write SQL NULL
48
+ // stars is absent, so its stored value is unchanged
49
+ });
50
+ ```
51
+
52
+ ### JSON columns
53
+
54
+ JSON columns follow the same field-presence rules. Non-null values are persisted as JSON. An explicit `null` writes SQL `NULL`, not a JSON literal `null`. On update, an own `undefined` also clears the column to SQL `NULL`.
55
+
56
+ A JSON request body normally omits object properties whose value is `undefined`. This distinction therefore most often applies to in-process TypeScript model calls or server-side payload construction.
57
+
32
58
  ## Conditional update and delete paths
33
59
 
34
60
  Write operations do not have to target one row only by primary key.
@@ -56,7 +82,7 @@ await this.scope.model.post.delete({
56
82
  });
57
83
  ```
58
84
 
59
- That matters because the mutation layer still participates in the same structured query language used by select operations.
85
+ That matters because the mutation layer still participates in the same structured query language used by select operations. In `options.where`, an absent field, an own `undefined`, and `Op.omit` all omit that condition; `null` requests SQL `IS NULL`. See [ORM Select Guide](/backend/orm-select-guide#absent-undefined-and-null-in-where) for the detailed query contract.
60
86
 
61
87
  ## Bulk mutation operations
62
88
 
@@ -87,9 +113,12 @@ Instead of forcing callers to choose insert/update/delete up front, Vona can inf
87
113
 
88
114
  Representative logic:
89
115
 
90
- - no `id` → insert
91
- - `id` present → update
92
- - `id` present and `deleted: true` → delete
116
+ - `id` absent, `undefined`, or `null` → insert
117
+ - non-nullish `id` → update
118
+ - non-nullish `id` and `deleted: true` → delete
119
+ - `deleted: true` without a non-nullish `id` → ignore the item because no deletion target exists
120
+
121
+ A non-nullish identity is neither `null` nor `undefined`. `mutate` chooses by that usable identity value, not merely by whether the `id` key exists.
93
122
 
94
123
  Representative pattern:
95
124
 
@@ -182,13 +211,10 @@ The important rule is:
182
211
 
183
212
  There is also a useful argument-handling rule to remember:
184
213
 
185
- - default `eq` magic methods such as `getByName()` or `selectByName()` treat an omitted argument as `null`
186
- - non-`eq` magic methods such as `getByNameEqI()` require an explicit value and throw if the argument is omitted
187
-
188
- This aligns magic-method behavior with the current ORM `where` semantics:
214
+ - default `eq` magic methods such as `getByName()` or `selectByName()` treat an omitted or `undefined` argument as `null`
215
+ - non-`eq` magic methods such as `getByNameEqI()` require a concrete value and throw when the argument is omitted or `undefined`
189
216
 
190
- - `Op.omit` means omit a condition explicitly
191
- - `null` means SQL `IS NULL`
217
+ This is argument adaptation performed by the convenience method. It differs from a direct structured `where` object, where `{ name: undefined }` omits the condition. Use `Op.omit` to make direct condition omission explicit, and use `null` to request SQL `IS NULL`.
192
218
 
193
219
  That means mutation should stay conceptually grounded in the standard model methods even when convenience wrappers are present.
194
220
 
@@ -122,29 +122,34 @@ A practical operator-family reading is:
122
122
  - composition/subquery: `_and_`, `_or_`, `_not_`, `_exists_`, `_notExists_`
123
123
  - identifier helper: `_ref_`
124
124
 
125
- ### Omitting a condition with `Op.omit`
125
+ ### Absent, `undefined`, and `null` in `where`
126
126
 
127
- Use `Op.omit` when you want to express omission explicitly at the call site. `Op.omit` is defined as `undefined`, but using the named constant makes the business meaning of the query object clearer.
127
+ A direct `where` object distinguishes omitted conditions from SQL `NULL` checks:
128
+
129
+ | Field value | Meaning |
130
+ | -------------------------- | --------------------------------- |
131
+ | Field is absent | Do not add a condition |
132
+ | Own field with `undefined` | Do not add a condition |
133
+ | `Op.omit` | Explicitly do not add a condition |
134
+ | `null` | Match SQL `NULL` with `IS NULL` |
135
+
136
+ An own `undefined` means the property exists on the JavaScript object but its value is `undefined`, such as `{ title: undefined }`. It has the same query result as an absent field, while `Op.omit` is the readable way to make that omission intentional in a dynamically composed query.
128
137
 
129
138
  Representative pattern:
130
139
 
131
140
  ```typescript
132
141
  import { Op } from 'vona-module-a-orm';
133
142
 
134
- const where = {
135
- title: { _includes_: 'ai' },
136
- stars: { _gt_: 20 },
137
- };
138
-
139
143
  await this.scope.model.post.select({
140
144
  where: {
141
- ...where,
142
- stars: Op.omit,
145
+ title: undefined, // omitted
146
+ stars: Op.omit, // explicitly omitted
147
+ publishedAt: null, // SQL IS NULL
143
148
  },
144
149
  });
145
150
  ```
146
151
 
147
- This lets a query builder remove one condition cleanly without rebuilding the whole `where` object by hand, while `null` remains available for SQL `IS NULL` semantics.
152
+ The same direct-`where` rule applies to filters used by mutation, aggregate, and group operations.
148
153
 
149
154
  That distinction also matters at the request-contract layer: omitting a nullable filter and passing a real `null` are different operations. When a query DTO needs `IS NULL` behavior from frontend query params, the parsing contract must explicitly preserve `null` instead of collapsing it to omission.
150
155
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cabloy",
3
- "version": "5.1.115",
3
+ "version": "5.1.116",
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",
3
- "version": "5.1.79",
3
+ "version": "5.1.80",
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": [
@@ -678,11 +678,11 @@ importers:
678
678
  specifier: npm:@cabloy/zod@4.3.8
679
679
  version: '@cabloy/zod@4.3.8'
680
680
  zova:
681
- specifier: ^5.1.139
682
- version: 5.1.139(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3))
681
+ specifier: ^5.1.140
682
+ version: 5.1.140(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3))
683
683
  zova-jsx:
684
- specifier: ^1.1.78
685
- version: 1.1.78(typescript@5.9.3)
684
+ specifier: ^1.1.79
685
+ version: 1.1.79(typescript@5.9.3)
686
686
  zova-module-a-api:
687
687
  specifier: ^5.1.21
688
688
  version: 5.1.21
@@ -750,11 +750,11 @@ importers:
750
750
  specifier: npm:@cabloy/zod@4.3.8
751
751
  version: '@cabloy/zod@4.3.8'
752
752
  zova:
753
- specifier: ^5.1.139
754
- version: 5.1.139(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3))
753
+ specifier: ^5.1.140
754
+ version: 5.1.140(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3))
755
755
  zova-jsx:
756
- specifier: ^1.1.78
757
- version: 1.1.78(typescript@5.9.3)
756
+ specifier: ^1.1.79
757
+ version: 1.1.79(typescript@5.9.3)
758
758
  zova-module-a-api:
759
759
  specifier: ^5.1.21
760
760
  version: 5.1.21
@@ -8236,15 +8236,9 @@ packages:
8236
8236
  peerDependencies:
8237
8237
  zod: ^3.25.28 || ^4
8238
8238
 
8239
- zova-core@5.1.78:
8240
- resolution: {integrity: sha512-PWvgOujRI1k7TpsJy1G8/Wf7W+/Rm6QbGHPNwcRjgDz4PhTg9z8UN1hfIO1Gg37IAI2hIrK8kqHhFRayROQHgQ==}
8241
-
8242
8239
  zova-core@5.1.79:
8243
8240
  resolution: {integrity: sha512-q04JWAjwa5x0py9/FCFPX9I8cNA4zzZ/Z+8mm2mVsRBMqDFcafwVqNumOAkZ+DuA10b4YIoncyQKYz5PhhmBUw==}
8244
8241
 
8245
- zova-jsx@1.1.78:
8246
- resolution: {integrity: sha512-KToLi7h2uElT4ULd5QSXfLNqSA3PaeRKJ4IHNMEJU1xiH02j2u0KCd7iVYK54mQC1QYphan3Z/YXFuW0odPx8w==}
8247
-
8248
8242
  zova-jsx@1.1.79:
8249
8243
  resolution: {integrity: sha512-iFp9UpW/3Cbaq0H25xyR04LYyaqB2wlAGOL1dEL7OyuIzHI0IHcBwfZRbRGy7ewgDVHit2ULG03kO1sSjjPuWQ==}
8250
8244
 
@@ -8320,24 +8314,15 @@ packages:
8320
8314
  zova-module-a-zod@5.1.36:
8321
8315
  resolution: {integrity: sha512-4gEjxJBelVOA84aP2XWSNSmzQr+AYh14Ha5QCj9yZIpSnEajSgYLoyLw4dwiVcscuEw7xAAOOnWbZS9uXSpJzg==}
8322
8316
 
8323
- zova-module-a-zova@5.1.93:
8324
- resolution: {integrity: sha512-pZ7JGNvDoSLdgbQZLj/LtppDWinpd6W+u0WYQvXLuJH3Hnf0Satcpz5fQS+zseJLxm5jhXjmG3ngoYoOfv72RQ==}
8325
-
8326
8317
  zova-module-a-zova@5.1.94:
8327
8318
  resolution: {integrity: sha512-c2qbu3YiNYRSMpprPkBbZ9RaQ9KKF4Y/cEumDJaCpPoZEbJClI/WBZMrY/ZpG9Rsp/87DJ63Vkbu+iSTOh7csg==}
8328
8319
 
8329
8320
  zova-module-rest-resource@5.1.42:
8330
8321
  resolution: {integrity: sha512-eMWhc4428U5Hs4BHqFJoF7yaH+bK8t71scIrCnpLbQS7M80lNkoHxY9s/Kl4i8Uo3+lVWbSbuaXWtR+zVLNqpA==}
8331
8322
 
8332
- zova-suite-a-zova@5.1.138:
8333
- resolution: {integrity: sha512-KiX37VxKcZxEi+inDva3iTpeA90vNlEGxBvLqDMPgBYIpkiX8iAoGZmEHhV5iKtbvFoq3gcbGAki+iO6R8mtCw==}
8334
-
8335
8323
  zova-suite-a-zova@5.1.139:
8336
8324
  resolution: {integrity: sha512-LZzCJOXj6njLL91cBgyzThRo+1aOvkBag1XIq4QR6R28t2mrFEdhnvPXBIqcvPpalyZJYwwD+8H/eyE2V/biNg==}
8337
8325
 
8338
- zova@5.1.139:
8339
- resolution: {integrity: sha512-6gA4pfdrSv6ZLp/aeE2nZx8Tu6KwKpuHrIbmZ3zrdHazYRkz51l4y//fBLk45lIfiR7uCIlKb9ziFdvOQQ3F7A==}
8340
-
8341
8326
  zova@5.1.140:
8342
8327
  resolution: {integrity: sha512-T8x4YEvkX32lGeeP5rgw7/Y4au10gxqaIHl2nvZlD5Cn9EoLvHfEENpPov8a4cWeQtActoxSTNb3D3WYHE/zpg==}
8343
8328
 
@@ -14158,35 +14143,6 @@ snapshots:
14158
14143
  dependencies:
14159
14144
  zod: '@cabloy/zod@4.3.8'
14160
14145
 
14161
- zova-core@5.1.78(typescript@5.9.3):
14162
- dependencies:
14163
- '@cabloy/compose': link:packages-utils/compose
14164
- '@cabloy/extend': link:packages-utils/extend
14165
- '@cabloy/json5': link:packages-utils/json5
14166
- '@cabloy/localeutil': link:packages-utils/localeutil
14167
- '@cabloy/logger': 1.1.24
14168
- '@cabloy/module-info': 2.0.3
14169
- '@cabloy/utils': link:packages-utils/utils
14170
- '@cabloy/vue-compiler-sfc': 3.5.14
14171
- '@cabloy/vue-reactivity': 3.5.16
14172
- '@cabloy/vue-router': 4.4.16(vue@3.5.39(typescript@5.9.3))
14173
- '@cabloy/vue-runtime-core': 3.5.57
14174
- '@cabloy/vue-runtime-dom': 3.5.13
14175
- '@cabloy/vue-server-renderer': 3.5.18(vue@3.5.39(typescript@5.9.3))
14176
- '@cabloy/word-utils': 2.1.14
14177
- '@cabloy/zod-errors-custom': link:packages-utils/zod-errors-custom
14178
- '@cabloy/zod-openapi': link:packages-utils/zod-openapi
14179
- '@cabloy/zod-query': link:packages-utils/zod-query
14180
- '@types/js-cookie': 3.0.6
14181
- '@vue/shared': 3.5.36
14182
- deep-equal: 2.2.3
14183
- reflect-metadata: 0.2.2
14184
- vue: 3.5.39(typescript@5.9.3)
14185
- vue-plugin-render-freeze: 2.0.4(vue@3.5.39(typescript@5.9.3))
14186
- zod: '@cabloy/zod@4.3.8'
14187
- transitivePeerDependencies:
14188
- - typescript
14189
-
14190
14146
  zova-core@5.1.79(patch_hash=ff659aa5f659be32501f56841b72f148c24acf1546f79cce551586e27fcde2ce)(typescript@5.9.3):
14191
14147
  dependencies:
14192
14148
  '@cabloy/compose': link:packages-utils/compose
@@ -14216,17 +14172,6 @@ snapshots:
14216
14172
  transitivePeerDependencies:
14217
14173
  - typescript
14218
14174
 
14219
- zova-jsx@1.1.78(typescript@5.9.3):
14220
- dependencies:
14221
- '@cabloy/compose': link:packages-utils/compose
14222
- '@cabloy/utils': link:packages-utils/utils
14223
- '@cabloy/word-utils': 2.1.14
14224
- typestyle: 2.4.0
14225
- vue: 3.5.39(typescript@5.9.3)
14226
- zova-core: 5.1.78(typescript@5.9.3)
14227
- transitivePeerDependencies:
14228
- - typescript
14229
-
14230
14175
  zova-jsx@1.1.79(typescript@5.9.3):
14231
14176
  dependencies:
14232
14177
  '@cabloy/compose': link:packages-utils/compose
@@ -14336,22 +14281,6 @@ snapshots:
14336
14281
  '@cabloy/zod-query': link:packages-utils/zod-query
14337
14282
  zod: '@cabloy/zod@4.3.8'
14338
14283
 
14339
- zova-module-a-zova@5.1.93(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3)):
14340
- dependencies:
14341
- '@cabloy/compose': link:packages-utils/compose
14342
- '@cabloy/deps': link:packages-utils/deps
14343
- '@cabloy/json5': link:packages-utils/json5
14344
- '@cabloy/module-info': 2.0.3
14345
- '@cabloy/utils': link:packages-utils/utils
14346
- '@cabloy/vue-router': 4.4.16(vue@3.5.39(typescript@5.9.3))
14347
- '@cabloy/word-utils': 2.1.14
14348
- defu: 6.1.7
14349
- luxon: 3.7.2
14350
- zova-jsx: 1.1.78(typescript@5.9.3)
14351
- transitivePeerDependencies:
14352
- - typescript
14353
- - vue
14354
-
14355
14284
  zova-module-a-zova@5.1.94(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3)):
14356
14285
  dependencies:
14357
14286
  '@cabloy/compose': link:packages-utils/compose
@@ -14370,40 +14299,6 @@ snapshots:
14370
14299
 
14371
14300
  zova-module-rest-resource@5.1.42: {}
14372
14301
 
14373
- zova-suite-a-zova@5.1.138(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3)):
14374
- dependencies:
14375
- zova-module-a-api: 5.1.21
14376
- zova-module-a-app: 5.1.25
14377
- zova-module-a-bean: 5.1.32
14378
- zova-module-a-behavior: 5.1.25
14379
- zova-module-a-behaviors: 5.1.21
14380
- zova-module-a-boundary: 5.1.21
14381
- zova-module-a-command: 5.1.33
14382
- zova-module-a-fetch: 5.1.24
14383
- zova-module-a-form: 5.1.45(vue@3.5.39(typescript@5.9.3))
14384
- zova-module-a-icon: 5.1.26
14385
- zova-module-a-interceptor: 5.1.29
14386
- zova-module-a-logger: 5.1.26
14387
- zova-module-a-meta: 5.1.21
14388
- zova-module-a-model: 5.1.33(vue@3.5.39(typescript@5.9.3))
14389
- zova-module-a-openapi: 5.1.46
14390
- zova-module-a-router: 5.1.31
14391
- zova-module-a-routerstack: 5.1.26
14392
- zova-module-a-routertabs: 5.1.33
14393
- zova-module-a-ssr: 5.1.31
14394
- zova-module-a-ssrhmr: 5.1.22
14395
- zova-module-a-ssrserver: 5.1.23
14396
- zova-module-a-style: 5.1.32
14397
- zova-module-a-table: 5.1.37(vue@3.5.39(typescript@5.9.3))
14398
- zova-module-a-zod: 5.1.36
14399
- zova-module-a-zova: 5.1.93(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3))
14400
- transitivePeerDependencies:
14401
- - '@vue/composition-api'
14402
- - debug
14403
- - supports-color
14404
- - typescript
14405
- - vue
14406
-
14407
14302
  zova-suite-a-zova@5.1.139(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3)):
14408
14303
  dependencies:
14409
14304
  zova-module-a-api: 5.1.21
@@ -14438,17 +14333,6 @@ snapshots:
14438
14333
  - typescript
14439
14334
  - vue
14440
14335
 
14441
- zova@5.1.139(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3)):
14442
- dependencies:
14443
- zova-core: 5.1.78(typescript@5.9.3)
14444
- zova-suite-a-zova: 5.1.138(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3))
14445
- transitivePeerDependencies:
14446
- - '@vue/composition-api'
14447
- - debug
14448
- - supports-color
14449
- - typescript
14450
- - vue
14451
-
14452
14336
  zova@5.1.140(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3)):
14453
14337
  dependencies:
14454
14338
  zova-core: 5.1.79(patch_hash=ff659aa5f659be32501f56841b72f148c24acf1546f79cce551586e27fcde2ce)(typescript@5.9.3)
@@ -26,7 +26,7 @@ async function viewSku(id: TableIdentity): Promise<EntitySku> {
26
26
  });
27
27
  }
28
28
 
29
- describe('sku.test.ts', () => {
29
+ describe('sku.test.ts', { concurrency: false }, () => {
30
30
  it('action:sku', async () => {
31
31
  await app.bean.executor.mockCtx(async () => {
32
32
  await app.bean.passport.signinMock();
@@ -91,7 +91,10 @@ describe('address.test.ts', () => {
91
91
  true,
92
92
  );
93
93
 
94
- const update: DtoAddressUpdate = { ...addressData, city: 'Oakland', addressLine2: undefined };
94
+ const { addressLine2: _, ...update } = {
95
+ ...addressData,
96
+ city: 'Oakland',
97
+ } satisfies DtoAddressUpdate;
95
98
  assert.equal(
96
99
  await performAs(customerA.token, 'patch', `${actionPath}/:id`, {
97
100
  params: { id: addressId },
@@ -14,7 +14,7 @@ export class DtoCouponIssue {
14
14
  @Api.field(v.required(), v.tableIdentity())
15
15
  userId: TableIdentity;
16
16
 
17
- @Api.field(v.required(), v.min(1), v.max(100))
17
+ @Api.field(v.required(), v.min(1), v.max(80))
18
18
  correlationId: string;
19
19
 
20
20
  @Api.field(v.required(), v.min(1), v.max(255))
@@ -42,7 +42,7 @@ const serializationRetryOptions = {
42
42
  minTimeout: 0,
43
43
  maxTimeout: 0,
44
44
  randomize: false,
45
- errorCodes: ['40001'],
45
+ errorCodes: ['40001', 'ER_LOCK_DEADLOCK', 'ER_LOCK_WAIT_TIMEOUT'],
46
46
  ownerOnly: true,
47
47
  };
48
48
 
@@ -52,6 +52,9 @@ export class ServiceCoupon extends BeanBase {
52
52
  @Core.retryable(serializationRetryOptions)
53
53
  async issue(command: ICouponIssueCommand): Promise<EntityCouponGrant> {
54
54
  this._assertCommand(command);
55
+ if (command.correlationId.length > 80) {
56
+ this.app.throw(400, 'coupon issuance correlationId is too long');
57
+ }
55
58
  const template = await this.scope.model.couponTemplate.getByIdForUpdate(command.templateId);
56
59
  if (!template) this.app.throw(404, 'coupon template not found');
57
60
  this._assertTemplateIssuable(template);
@@ -39,14 +39,29 @@ describe('couponReservation.test.ts', { concurrency: false }, () => {
39
39
  minSpendCents: 1000,
40
40
  validFrom: new Date(Date.now() - 1_000),
41
41
  validUntil: new Date(Date.now() + 60_000),
42
- totalIssueLimit: 1,
42
+ totalIssueLimit: 2,
43
43
  totalUsageLimit: 1,
44
- perCustomerIssueLimit: 1,
44
+ perCustomerIssueLimit: 3,
45
45
  issuedCount: 0,
46
46
  redeemedCount: 0,
47
47
  })
48
48
  ).id as number;
49
49
  const coupon = app.scope('commerce-promotion').service.coupon;
50
+ const [, issuanceLengthError] = await catchError(() =>
51
+ coupon.issue({
52
+ templateId: fixture.templateId!,
53
+ userId: customer.id,
54
+ correlationId: 'x'.repeat(81),
55
+ reason: 'test overlong issue correlation',
56
+ }),
57
+ );
58
+ assert.equal(issuanceLengthError?.code, 400);
59
+ assert.equal(
60
+ await app
61
+ .scope('commerce-promotion')
62
+ .model.couponGrant.get({ couponCode: 'x'.repeat(81) }),
63
+ undefined,
64
+ );
50
65
  const grant = await coupon.issue({
51
66
  templateId: fixture.templateId,
52
67
  userId: customer.id,
@@ -56,6 +71,20 @@ describe('couponReservation.test.ts', { concurrency: false }, () => {
56
71
  fixture.grantId = grant.id as number;
57
72
  assert.equal(grant.state, 'available');
58
73
  assert.equal(grant.discountCentsSnapshot, 500);
74
+ const exactLimitGrant = await coupon.issue({
75
+ templateId: fixture.templateId,
76
+ userId: customer.id,
77
+ correlationId: 'a'.repeat(80),
78
+ reason: 'test exact issue correlation limit',
79
+ });
80
+ assert.equal(exactLimitGrant.couponCode.length, 80);
81
+ await app.scope('commerce-promotion').model.couponAudit.delete({
82
+ couponGrantId: exactLimitGrant.id,
83
+ });
84
+ await app.scope('commerce-promotion').model.couponGrant.delete({ id: exactLimitGrant.id });
85
+ await app.scope('commerce-promotion').model.couponTemplate.updateById(fixture.templateId, {
86
+ issuedCount: 1,
87
+ });
59
88
 
60
89
  const [_, belowMinimumError] = await catchError(() =>
61
90
  coupon.reserve({
@@ -95,6 +124,25 @@ describe('couponReservation.test.ts', { concurrency: false }, () => {
95
124
  reason: 'test reserve',
96
125
  });
97
126
  assert.equal(replay.couponGrant.id, grant.id);
127
+ const [, wrongCustomerError] = await catchError(() =>
128
+ coupon.reserve({
129
+ couponGrantId: grant.id,
130
+ userId: customer.id + 10_000,
131
+ orderId: 100_001,
132
+ eligibleSubtotalCents: 1_200,
133
+ currency: 'USD',
134
+ correlationId: `reserve-wrong-customer-${suffix}`,
135
+ reason: 'test wrong customer',
136
+ }),
137
+ );
138
+ assert.equal(wrongCustomerError?.code, 404);
139
+ const reserveAuditsBeforeRelease = await app
140
+ .scope('commerce-promotion')
141
+ .model.couponAudit.select({ where: { couponGrantId: grant.id } });
142
+ assert.deepEqual(
143
+ reserveAuditsBeforeRelease.map(item => item.operation),
144
+ ['issue', 'reserve'],
145
+ );
98
146
 
99
147
  const released = await coupon.release({
100
148
  couponGrantId: grant.id,
@@ -103,6 +151,12 @@ describe('couponReservation.test.ts', { concurrency: false }, () => {
103
151
  reason: 'test release',
104
152
  });
105
153
  assert.equal(released.state, 'available');
154
+ const releasedPersisted = await app
155
+ .scope('commerce-promotion')
156
+ .model.couponGrant.getById(grant.id);
157
+ assert.equal(releasedPersisted?.reservationOrderId, null);
158
+ assert.equal(releasedPersisted?.reservationCorrelationId, null);
159
+ assert.equal(releasedPersisted?.reservedAt, null);
106
160
  const releaseReplay = await coupon.release({
107
161
  couponGrantId: grant.id,
108
162
  orderId: 100_001,
@@ -124,6 +178,35 @@ describe('couponReservation.test.ts', { concurrency: false }, () => {
124
178
  reason: 'test reserve again',
125
179
  });
126
180
  assert.equal(reservedAgain.discountCents, 500);
181
+ const anotherGrant = await coupon.issue({
182
+ templateId: fixture.templateId,
183
+ userId: customer.id,
184
+ correlationId: `issue-another-${suffix}`,
185
+ reason: 'test second grant',
186
+ });
187
+ const [, secondCouponError] = await catchError(() =>
188
+ coupon.reserve({
189
+ couponGrantId: anotherGrant.id,
190
+ userId: customer.id,
191
+ orderId: 100_002,
192
+ eligibleSubtotalCents: 1_200,
193
+ currency: 'USD',
194
+ correlationId: `reserve-another-${suffix}`,
195
+ reason: 'test second coupon',
196
+ }),
197
+ );
198
+ assert.equal(secondCouponError?.code, 409);
199
+ assert.equal(
200
+ (await app.scope('commerce-promotion').model.couponGrant.getById(anotherGrant.id))?.state,
201
+ 'available',
202
+ );
203
+ await app.scope('commerce-promotion').model.couponAudit.delete({
204
+ couponGrantId: anotherGrant.id,
205
+ });
206
+ await app.scope('commerce-promotion').model.couponGrant.delete({ id: anotherGrant.id });
207
+ await app.scope('commerce-promotion').model.couponTemplate.updateById(fixture.templateId, {
208
+ issuedCount: 1,
209
+ });
127
210
  const redeemed = await coupon.redeem({
128
211
  couponGrantId: grant.id,
129
212
  orderId: 100_002,
@@ -10,6 +10,16 @@ import type { DtoCartUpdateItem } from '../dto/cartUpdateItem.tsx';
10
10
  import type { DtoCartView } from '../dto/cartView.tsx';
11
11
  import type { EntityCart } from '../entity/cart.tsx';
12
12
 
13
+ const serializationRetryOptions = {
14
+ retries: 1,
15
+ factor: 1,
16
+ minTimeout: 0,
17
+ maxTimeout: 0,
18
+ randomize: false,
19
+ errorCodes: ['40001', 'ER_LOCK_DEADLOCK', 'ER_LOCK_WAIT_TIMEOUT'],
20
+ ownerOnly: true,
21
+ };
22
+
13
23
  @Service()
14
24
  export class ServiceCart extends BeanBase {
15
25
  async current(): Promise<DtoCartView> {
@@ -18,6 +28,7 @@ export class ServiceCart extends BeanBase {
18
28
  }
19
29
 
20
30
  @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
31
+ @Core.retryable(serializationRetryOptions)
21
32
  async addItem(command: DtoCartAddItem): Promise<DtoCartView> {
22
33
  await this._assertSellableSku(command.skuId, command.quantity);
23
34
  const cart = await this._getOrCreateCurrentCart();
@@ -40,6 +51,7 @@ export class ServiceCart extends BeanBase {
40
51
  }
41
52
 
42
53
  @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
54
+ @Core.retryable(serializationRetryOptions)
43
55
  async updateItem(id: TableIdentity, command: DtoCartUpdateItem): Promise<DtoCartView> {
44
56
  const cart = await this._getCurrentCartForUpdate();
45
57
  if (!cart) return { items: [] };
@@ -51,6 +63,7 @@ export class ServiceCart extends BeanBase {
51
63
  }
52
64
 
53
65
  @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
66
+ @Core.retryable(serializationRetryOptions)
54
67
  async deleteItem(id: TableIdentity): Promise<DtoCartView> {
55
68
  const cart = await this._getCurrentCartForUpdate();
56
69
  if (!cart) return { items: [] };
@@ -60,6 +73,7 @@ export class ServiceCart extends BeanBase {
60
73
  }
61
74
 
62
75
  @Core.transaction({ isolationLevel: 'SERIALIZABLE' })
76
+ @Core.retryable(serializationRetryOptions)
63
77
  async clear(): Promise<DtoCartView> {
64
78
  const cart = await this._getCurrentCartForUpdate();
65
79
  if (!cart) return { items: [] };