@marianmeres/ecsuite 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +47 -12
- package/API.md +108 -25
- package/README.md +70 -0
- package/dist/adapters/mock/cart.d.ts +4 -2
- package/dist/adapters/mock/cart.js +3 -7
- package/dist/adapters/mock/customer.d.ts +3 -1
- package/dist/adapters/mock/customer.js +3 -2
- package/dist/adapters/mock/order.d.ts +3 -1
- package/dist/adapters/mock/order.js +7 -5
- package/dist/adapters/mock/payment.d.ts +3 -1
- package/dist/adapters/mock/payment.js +34 -20
- package/dist/adapters/mock/product.d.ts +3 -1
- package/dist/adapters/mock/product.js +3 -1
- package/dist/adapters/mock/wishlist.d.ts +4 -2
- package/dist/adapters/mock/wishlist.js +3 -7
- package/dist/domains/base.d.ts +13 -6
- package/dist/domains/base.js +31 -12
- package/dist/domains/cart.js +17 -0
- package/dist/domains/customer.js +18 -4
- package/dist/domains/order.d.ts +33 -15
- package/dist/domains/order.js +34 -20
- package/dist/domains/payment.js +16 -2
- package/dist/domains/product.d.ts +29 -40
- package/dist/domains/product.js +99 -81
- package/dist/domains/wishlist.js +4 -2
- package/dist/suite.d.ts +49 -1
- package/dist/suite.js +90 -8
- package/dist/types/adapter.d.ts +10 -7
- package/dist/types/events.d.ts +6 -2
- package/docs/future-improvements.md +116 -0
- package/package.json +15 -6
package/AGENTS.md
CHANGED
|
@@ -6,7 +6,7 @@ Machine-readable documentation for AI coding assistants.
|
|
|
6
6
|
|
|
7
7
|
```yaml
|
|
8
8
|
name: "@marianmeres/ecsuite"
|
|
9
|
-
version: "1.
|
|
9
|
+
version: "1.3.2"
|
|
10
10
|
type: "library"
|
|
11
11
|
language: "typescript"
|
|
12
12
|
runtime: "deno"
|
|
@@ -28,12 +28,13 @@ E-commerce frontend UI state management library providing:
|
|
|
28
28
|
|
|
29
29
|
```
|
|
30
30
|
ECSuite (orchestrator)
|
|
31
|
-
├── CartManager [localStorage, optimistic updates]
|
|
32
|
-
├── WishlistManager [localStorage, optimistic updates]
|
|
33
|
-
├── OrderManager [server-only, read + create
|
|
31
|
+
├── CartManager [localStorage, optimistic updates, per-domain mutation queue]
|
|
32
|
+
├── WishlistManager [localStorage, optimistic updates, per-domain mutation queue]
|
|
33
|
+
├── OrderManager [server-only, read + create — list of {model_id, data}]
|
|
34
34
|
├── CustomerManager [server-only, read + update + fetchBySession]
|
|
35
|
-
├── PaymentManager [server-only, read + initiate + capture]
|
|
36
|
-
└── ProductManager [in-memory cache
|
|
35
|
+
├── PaymentManager [server-only, read + initiate + capture (throw NOT_IMPL)]
|
|
36
|
+
└── ProductManager [in-memory TTL cache; extends BaseDomainManager;
|
|
37
|
+
in-flight dedup; emits domain:error]
|
|
37
38
|
```
|
|
38
39
|
|
|
39
40
|
## Directory Structure
|
|
@@ -166,7 +167,29 @@ const suite = createECSuite({
|
|
|
166
167
|
storage: { type: "local" },
|
|
167
168
|
productCacheTtl: 300000,
|
|
168
169
|
autoInitialize: true,
|
|
170
|
+
autoResetOnIdentityChange: true, // default: reset on customerId transition
|
|
169
171
|
});
|
|
172
|
+
|
|
173
|
+
// Avoid the auto-init race: callers should await the suite's readiness
|
|
174
|
+
// before issuing mutations.
|
|
175
|
+
await suite.ready;
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Identity Switch
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
// Atomic: merge context, reset all domains, re-initialize.
|
|
182
|
+
await suite.switchIdentity({ customerId: "another" });
|
|
183
|
+
|
|
184
|
+
// Or via setContext when autoResetOnIdentityChange is enabled.
|
|
185
|
+
suite.setContext({ customerId: "another" });
|
|
186
|
+
await suite.ready;
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Teardown
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
suite.destroy(); // unsubscribes all internal pubsub listeners
|
|
170
193
|
```
|
|
171
194
|
|
|
172
195
|
### Selective Initialization
|
|
@@ -280,20 +303,32 @@ deno publish # Publish to JSR
|
|
|
280
303
|
|
|
281
304
|
## Important Implementation Details
|
|
282
305
|
|
|
283
|
-
1. **Optimistic Updates**: `
|
|
306
|
+
1. **Optimistic Updates**: `withOptimisticUpdate()` in BaseDomainManager captures previous state before mutation, rolls back to it (including `null`) on server error. Mutations are **serialized per domain** through an internal mutation queue so concurrent callers can't race their rollback snapshots.
|
|
284
307
|
|
|
285
|
-
2. **Persistence**: Cart and Wishlist use `@marianmeres/store` with `createStoragePersistor()` for localStorage/sessionStorage.
|
|
308
|
+
2. **Persistence**: Cart and Wishlist use `@marianmeres/store` with `createStoragePersistor()` for localStorage/sessionStorage. (Cross-tab `storage` event sync is NOT yet implemented.)
|
|
286
309
|
|
|
287
|
-
3. **ProductManager**:
|
|
310
|
+
3. **ProductManager**: Extends BaseDomainManager with `data: null` (cache lives in a private Map). Exposes `subscribe`, emits `domain:error` (without changing state — a single failed product fetch shouldn't blanket the whole domain in error). Uses an in-flight Map to dedup concurrent `getById` callers (prevents stampede on TTL expiry).
|
|
288
311
|
|
|
289
312
|
4. **Event System**: Shared PubSub instance passed through ECSuite constructor. Events typed with discriminated union.
|
|
290
313
|
|
|
291
314
|
5. **Context**: DomainContext (customerId, sessionId, + arbitrary properties via index signature) passed to all adapter methods for server-side identification.
|
|
292
315
|
|
|
293
|
-
6. **
|
|
316
|
+
6. **OrderListData**: Stores `OrderCreateResult[]` (`{ model_id, data }`). `fetchAll`/`fetchOne`/`create` all return this envelope so orders are uniquely identifiable. Use `getOrderById(modelId)` / `getOrderDataById(modelId)`.
|
|
294
317
|
|
|
295
|
-
7. **Payment Write Ops**: `PaymentAdapter.initiate?()` and `capture?()` are optional methods. `PaymentManager`
|
|
318
|
+
7. **Payment Write Ops**: `PaymentAdapter.initiate?()` and `capture?()` are optional adapter methods. `PaymentManager` **throws** `NOT_IMPLEMENTED` (and emits `domain:error`) when called without an implementation — callers must catch or feature-detect (`adapter.initiate !== undefined`).
|
|
296
319
|
|
|
297
|
-
8. **Guest Checkout**: `CustomerAdapter.fetchBySession?()` is optional.
|
|
320
|
+
8. **Guest Checkout**: `CustomerAdapter.fetchBySession?()` is optional. When `customerId` is absent in context AND `fetchBySession` isn't implemented, `CustomerManager` warns and stays in `ready` with `data: null` — it does NOT silently call `fetch()` anymore (real adapters typically need a `customerId`).
|
|
298
321
|
|
|
299
322
|
9. **Operation Hooks**: `ECSuite.onBeforeSync()` and `onAfterSync()` are convenience wrappers over the existing event system (no changes to BaseDomainManager).
|
|
323
|
+
|
|
324
|
+
10. **Identity Switches**: `setContext({ customerId })` auto-resets all domains and re-initializes when the id transitions (default; opt out via `autoResetOnIdentityChange: false`). Awaitable via `suite.ready` or use `suite.switchIdentity()` directly.
|
|
325
|
+
|
|
326
|
+
11. **Auto-init race**: Constructor with `autoInitialize: true` (default) starts initialize() but cannot await it; consumers should `await suite.ready` before issuing mutations.
|
|
327
|
+
|
|
328
|
+
12. **Cart Quantity Validation**: `addItem` and `updateItemQuantity` throw `TypeError`/`RangeError` for `NaN`, `Infinity`, fractional, or negative values at the call site (never persisted optimistically).
|
|
329
|
+
|
|
330
|
+
## Known limitations (not yet fixed)
|
|
331
|
+
|
|
332
|
+
- **Payment grouping by orderId**: `PaymentManager.getPayments()` returns a flat list. There is no `getPaymentsForOrder(orderId)` helper because `PaymentData` does not carry the order id; consumers fetching for multiple orders must keep their own index.
|
|
333
|
+
- **Pagination**: `OrderAdapter.fetchAll` and `PaymentAdapter.fetchForOrder` have no `limit/offset/cursor` params.
|
|
334
|
+
- **Cross-tab sync**: localStorage edits in one tab don't propagate to other tabs' stores.
|
package/API.md
CHANGED
|
@@ -59,6 +59,14 @@ interface ECSuiteConfig {
|
|
|
59
59
|
productCacheTtl?: number;
|
|
60
60
|
/** Auto-initialize on creation (default: true) */
|
|
61
61
|
autoInitialize?: boolean;
|
|
62
|
+
/** Domains to initialize (default: all) */
|
|
63
|
+
initializeDomains?: InitializableDomainName[];
|
|
64
|
+
/**
|
|
65
|
+
* Reset and re-initialize all domains automatically when `setContext()`
|
|
66
|
+
* changes `customerId`. Default: true. Set false to manage identity
|
|
67
|
+
* transitions yourself via `switchIdentity()`.
|
|
68
|
+
*/
|
|
69
|
+
autoResetOnIdentityChange?: boolean;
|
|
62
70
|
}
|
|
63
71
|
```
|
|
64
72
|
|
|
@@ -66,33 +74,48 @@ interface ECSuiteConfig {
|
|
|
66
74
|
|
|
67
75
|
#### Properties
|
|
68
76
|
|
|
69
|
-
| Property | Type | Description
|
|
70
|
-
| ---------- | ----------------- |
|
|
71
|
-
| `cart` | `CartManager` | Cart domain manager
|
|
72
|
-
| `wishlist` | `WishlistManager` | Wishlist domain manager
|
|
73
|
-
| `order` | `OrderManager` | Order domain manager
|
|
74
|
-
| `customer` | `CustomerManager` | Customer domain manager
|
|
75
|
-
| `payment` | `PaymentManager` | Payment domain manager
|
|
76
|
-
| `product` | `ProductManager` | Product domain manager
|
|
77
|
+
| Property | Type | Description |
|
|
78
|
+
| ---------- | ----------------- | -------------------------------------------------------------------- |
|
|
79
|
+
| `cart` | `CartManager` | Cart domain manager |
|
|
80
|
+
| `wishlist` | `WishlistManager` | Wishlist domain manager |
|
|
81
|
+
| `order` | `OrderManager` | Order domain manager |
|
|
82
|
+
| `customer` | `CustomerManager` | Customer domain manager |
|
|
83
|
+
| `payment` | `PaymentManager` | Payment domain manager |
|
|
84
|
+
| `product` | `ProductManager` | Product domain manager |
|
|
85
|
+
| `ready` | `Promise<void>` | Resolves when the most recent (auto or manual) `initialize()` settles |
|
|
77
86
|
|
|
78
87
|
#### Methods
|
|
79
88
|
|
|
80
|
-
##### initialize()
|
|
89
|
+
##### initialize(domains?)
|
|
81
90
|
|
|
82
|
-
Initialize all
|
|
91
|
+
Initialize the chosen domains (or all by default). Called automatically if
|
|
92
|
+
`autoInitialize` is true. The most recently initialized set is remembered
|
|
93
|
+
and re-used for identity-switch re-initialization.
|
|
83
94
|
|
|
84
95
|
```typescript
|
|
85
|
-
async initialize(): Promise<void>
|
|
96
|
+
async initialize(domains?: InitializableDomainName[]): Promise<void>
|
|
86
97
|
```
|
|
87
98
|
|
|
88
99
|
##### setContext(context)
|
|
89
100
|
|
|
90
|
-
Update context across all domains.
|
|
101
|
+
Update context across all domains. If `customerId` transitions and
|
|
102
|
+
`autoResetOnIdentityChange` is enabled (default), this also resets all
|
|
103
|
+
domains and re-initializes them — `suite.ready` is updated to the new
|
|
104
|
+
in-flight promise.
|
|
91
105
|
|
|
92
106
|
```typescript
|
|
93
107
|
setContext(context: DomainContext): void
|
|
94
108
|
```
|
|
95
109
|
|
|
110
|
+
##### switchIdentity(context)
|
|
111
|
+
|
|
112
|
+
Atomic identity switch: merge context, reset all domains, re-initialize.
|
|
113
|
+
Returns a promise that settles when the re-init completes.
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
async switchIdentity(context: DomainContext): Promise<void>
|
|
117
|
+
```
|
|
118
|
+
|
|
96
119
|
##### getContext()
|
|
97
120
|
|
|
98
121
|
Get the current context.
|
|
@@ -133,6 +156,16 @@ Reset all domains to initial state.
|
|
|
133
156
|
reset(): void
|
|
134
157
|
```
|
|
135
158
|
|
|
159
|
+
##### destroy()
|
|
160
|
+
|
|
161
|
+
Tear down the suite: unsubscribe all listeners on the internal pubsub and
|
|
162
|
+
clear the product cache. Persisted storage is intentionally NOT cleared;
|
|
163
|
+
call `reset()` first if you also want to wipe in-memory state.
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
destroy(): void
|
|
167
|
+
```
|
|
168
|
+
|
|
136
169
|
---
|
|
137
170
|
|
|
138
171
|
## Domain Managers
|
|
@@ -381,10 +414,11 @@ async fetchAll(): Promise<void>
|
|
|
381
414
|
|
|
382
415
|
##### fetchOne(orderId)
|
|
383
416
|
|
|
384
|
-
Fetch single order by ID.
|
|
417
|
+
Fetch single order by ID. Updates an existing entry in the local list (matched
|
|
418
|
+
by `model_id`) or appends a new one.
|
|
385
419
|
|
|
386
420
|
```typescript
|
|
387
|
-
async fetchOne(orderId: UUID): Promise<
|
|
421
|
+
async fetchOne(orderId: UUID): Promise<OrderCreateResult | null>
|
|
388
422
|
```
|
|
389
423
|
|
|
390
424
|
##### create(orderData)
|
|
@@ -392,7 +426,7 @@ async fetchOne(orderId: UUID): Promise<OrderData | null>
|
|
|
392
426
|
Create a new order.
|
|
393
427
|
|
|
394
428
|
```typescript
|
|
395
|
-
async create(orderData: OrderCreatePayload): Promise<
|
|
429
|
+
async create(orderData: OrderCreatePayload): Promise<OrderCreateResult | null>
|
|
396
430
|
```
|
|
397
431
|
|
|
398
432
|
**Emits:** `order:created`
|
|
@@ -407,18 +441,34 @@ getOrderCount(): number
|
|
|
407
441
|
|
|
408
442
|
##### getOrders()
|
|
409
443
|
|
|
410
|
-
Get all
|
|
444
|
+
Get all order envelopes.
|
|
445
|
+
|
|
446
|
+
```typescript
|
|
447
|
+
getOrders(): OrderCreateResult[]
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
##### getOrderById(modelId)
|
|
451
|
+
|
|
452
|
+
Look up an order envelope by its server-assigned `model_id`.
|
|
411
453
|
|
|
412
454
|
```typescript
|
|
413
|
-
|
|
455
|
+
getOrderById(modelId: UUID): OrderCreateResult | undefined
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
##### getOrderDataById(modelId)
|
|
459
|
+
|
|
460
|
+
Same as `getOrderById` but returns the bare `OrderData` payload.
|
|
461
|
+
|
|
462
|
+
```typescript
|
|
463
|
+
getOrderDataById(modelId: UUID): OrderData | undefined
|
|
414
464
|
```
|
|
415
465
|
|
|
416
466
|
##### getOrderByIndex(index)
|
|
417
467
|
|
|
418
|
-
Get order by index.
|
|
468
|
+
Get order envelope by index.
|
|
419
469
|
|
|
420
470
|
```typescript
|
|
421
|
-
getOrderByIndex(index: number):
|
|
471
|
+
getOrderByIndex(index: number): OrderCreateResult | undefined
|
|
422
472
|
```
|
|
423
473
|
|
|
424
474
|
---
|
|
@@ -521,12 +571,38 @@ async fetchForOrder(orderId: UUID): Promise<PaymentData[]>
|
|
|
521
571
|
|
|
522
572
|
##### fetchOne(paymentId)
|
|
523
573
|
|
|
524
|
-
Fetch single payment by ID.
|
|
574
|
+
Fetch single payment by ID. Updates the existing entry (matched by
|
|
575
|
+
`provider_reference`) or appends a new one.
|
|
525
576
|
|
|
526
577
|
```typescript
|
|
527
578
|
async fetchOne(paymentId: UUID): Promise<PaymentData | null>
|
|
528
579
|
```
|
|
529
580
|
|
|
581
|
+
##### initiate(orderId, config)
|
|
582
|
+
|
|
583
|
+
Initiate a payment for an order. **Throws** if `adapter.initiate` is not
|
|
584
|
+
implemented (also emits `domain:error` with `code: "NOT_IMPLEMENTED"`).
|
|
585
|
+
|
|
586
|
+
```typescript
|
|
587
|
+
async initiate(
|
|
588
|
+
orderId: UUID,
|
|
589
|
+
config: PaymentInitConfig,
|
|
590
|
+
): Promise<PaymentIntent | null>
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
**Emits:** `payment:initiated`
|
|
594
|
+
|
|
595
|
+
##### capture(paymentId)
|
|
596
|
+
|
|
597
|
+
Capture a previously initiated payment. **Throws** if `adapter.capture` is
|
|
598
|
+
not implemented (also emits `domain:error` with `code: "NOT_IMPLEMENTED"`).
|
|
599
|
+
|
|
600
|
+
```typescript
|
|
601
|
+
async capture(paymentId: UUID): Promise<PaymentData | null>
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
**Emits:** `payment:captured`
|
|
605
|
+
|
|
530
606
|
##### getPaymentCount()
|
|
531
607
|
|
|
532
608
|
Get number of fetched payments.
|
|
@@ -563,7 +639,12 @@ clearCache(): void
|
|
|
563
639
|
|
|
564
640
|
### ProductManager
|
|
565
641
|
|
|
566
|
-
Manages product data with in-memory caching.
|
|
642
|
+
Manages product data with in-memory TTL caching. Extends `BaseDomainManager`
|
|
643
|
+
for unified observability (`subscribe`, `domain:error`, `domain:state:changed`)
|
|
644
|
+
but skips per-product state-machine transitions — fetching a single product
|
|
645
|
+
never blankets the whole domain in `"syncing"` or `"error"`. Concurrent
|
|
646
|
+
`getById()` callers for the same id share a single in-flight request
|
|
647
|
+
(stampede dedup).
|
|
567
648
|
|
|
568
649
|
#### Constructor Options
|
|
569
650
|
|
|
@@ -645,7 +726,6 @@ interface CartAdapter {
|
|
|
645
726
|
updateItem(productId: UUID, quantity: number, ctx: DomainContext): Promise<CartData>;
|
|
646
727
|
removeItem(productId: UUID, ctx: DomainContext): Promise<CartData>;
|
|
647
728
|
clear(ctx: DomainContext): Promise<CartData>;
|
|
648
|
-
sync(cart: CartData, ctx: DomainContext): Promise<CartData>;
|
|
649
729
|
}
|
|
650
730
|
```
|
|
651
731
|
|
|
@@ -657,16 +737,19 @@ interface WishlistAdapter {
|
|
|
657
737
|
addItem(productId: UUID, ctx: DomainContext): Promise<WishlistData>;
|
|
658
738
|
removeItem(productId: UUID, ctx: DomainContext): Promise<WishlistData>;
|
|
659
739
|
clear(ctx: DomainContext): Promise<WishlistData>;
|
|
660
|
-
sync(wishlist: WishlistData, ctx: DomainContext): Promise<WishlistData>;
|
|
661
740
|
}
|
|
662
741
|
```
|
|
663
742
|
|
|
664
743
|
### OrderAdapter
|
|
665
744
|
|
|
745
|
+
> Returns `OrderCreateResult` envelopes (`{ model_id, data }`) so the
|
|
746
|
+
> manager can identify orders by `model_id` (bare `OrderData` has only an
|
|
747
|
+
> open index signature).
|
|
748
|
+
|
|
666
749
|
```typescript
|
|
667
750
|
interface OrderAdapter {
|
|
668
|
-
fetchAll(ctx: DomainContext): Promise<
|
|
669
|
-
fetchOne(orderId: UUID, ctx: DomainContext): Promise<
|
|
751
|
+
fetchAll(ctx: DomainContext): Promise<OrderCreateResult[]>;
|
|
752
|
+
fetchOne(orderId: UUID, ctx: DomainContext): Promise<OrderCreateResult>;
|
|
670
753
|
create(order: OrderCreatePayload, ctx: DomainContext): Promise<OrderData>;
|
|
671
754
|
}
|
|
672
755
|
```
|
package/README.md
CHANGED
|
@@ -41,6 +41,10 @@ const suite = createECSuite({
|
|
|
41
41
|
},
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
// `autoInitialize` is true by default; await `suite.ready` so consumer
|
|
45
|
+
// mutations don't race the in-flight initial fetches.
|
|
46
|
+
await suite.ready;
|
|
47
|
+
|
|
44
48
|
// Subscribe to cart state (Svelte-compatible)
|
|
45
49
|
suite.cart.subscribe((state) => {
|
|
46
50
|
console.log(state.state, state.data);
|
|
@@ -57,6 +61,23 @@ suite.on("domain:error", (event) => {
|
|
|
57
61
|
await suite.cart.addItem({ product_id: "prod-1", quantity: 2 });
|
|
58
62
|
```
|
|
59
63
|
|
|
64
|
+
### Identity switches (login / logout)
|
|
65
|
+
|
|
66
|
+
When the user signs in or out, use `switchIdentity()` (or just call
|
|
67
|
+
`setContext()` with a different `customerId` — auto-reset is on by default):
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
await suite.switchIdentity({ customerId: "another-user" });
|
|
71
|
+
// All domains reset, re-initialized for the new identity, and `suite.ready`
|
|
72
|
+
// resolves once the new fetches settle.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Teardown
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
suite.destroy(); // unsubscribes every internal event listener
|
|
79
|
+
```
|
|
80
|
+
|
|
60
81
|
## Domains
|
|
61
82
|
|
|
62
83
|
| Domain | Persistence | Operations |
|
|
@@ -145,6 +166,55 @@ suite.once("order:created", (event) => {
|
|
|
145
166
|
|
|
146
167
|
For complete API documentation, see [API.md](API.md).
|
|
147
168
|
|
|
169
|
+
## Migration to next major
|
|
170
|
+
|
|
171
|
+
This release tightens correctness in several places. Breaking changes:
|
|
172
|
+
|
|
173
|
+
- **`OrderAdapter` returns `OrderCreateResult`** for both `fetchAll` and
|
|
174
|
+
`fetchOne` (`{ model_id, data }`) so orders are uniquely identifiable.
|
|
175
|
+
`OrderListData.orders` is now `OrderCreateResult[]`. Use the new
|
|
176
|
+
`orders.getOrderById(modelId)` / `getOrderDataById(modelId)` helpers, or
|
|
177
|
+
read `result.data.<field>` on returned envelopes.
|
|
178
|
+
- **`CartAdapter.sync()` and `WishlistAdapter.sync()` removed** — they were
|
|
179
|
+
never called by the manager.
|
|
180
|
+
- **`PaymentManager.initiate()` / `capture()` throw `NOT_IMPLEMENTED`** when
|
|
181
|
+
the adapter doesn't implement the optional method (previously returned
|
|
182
|
+
`null` silently). `domain:error` is also emitted.
|
|
183
|
+
- **`CustomerManager.update()` throws `NOT_IMPLEMENTED`** when no adapter is
|
|
184
|
+
configured (previously silent no-op).
|
|
185
|
+
- **`CustomerManager` no longer falls through to `fetch()`** when both
|
|
186
|
+
`customerId` is missing AND `adapter.fetchBySession` is undefined; it
|
|
187
|
+
now warns and stays in `ready` with `data: null`. Pass `customerId` in
|
|
188
|
+
context, or implement `fetchBySession`.
|
|
189
|
+
- **`CartManager.addItem` / `updateItemQuantity`** validate the quantity
|
|
190
|
+
(must be a finite, non-negative integer); invalid values throw at the
|
|
191
|
+
call site instead of being persisted optimistically.
|
|
192
|
+
- **`ProductManager` now extends `BaseDomainManager`** — exposes `subscribe`,
|
|
193
|
+
emits `domain:error`, and gains an `initialize()` no-op. `setAdapter` /
|
|
194
|
+
`getAdapter` / `setContext` / `getContext` keep the same signatures.
|
|
195
|
+
- **`InitializableDomainName`** now includes `"product"` for parity with
|
|
196
|
+
the other domains.
|
|
197
|
+
|
|
198
|
+
New additions:
|
|
199
|
+
|
|
200
|
+
- `suite.ready: Promise<void>` — resolves when the most recent (auto or
|
|
201
|
+
manual) `initialize()` settles.
|
|
202
|
+
- `suite.switchIdentity(context)` — atomic identity switch (merge context,
|
|
203
|
+
reset domains, re-initialize). Returns a promise.
|
|
204
|
+
- `suite.destroy()` — unsubscribes all internal pubsub listeners.
|
|
205
|
+
- `ECSuiteConfig.autoResetOnIdentityChange` (default `true`) — opt out of
|
|
206
|
+
the auto-reset path on `setContext()` if you manage identity transitions
|
|
207
|
+
yourself.
|
|
208
|
+
- `OrderManager.getOrderById(modelId)` / `getOrderDataById(modelId)` lookup
|
|
209
|
+
helpers.
|
|
210
|
+
- Per-domain mutation queue (`withOptimisticUpdate` is serialized per
|
|
211
|
+
manager) — concurrent `cart.addItem(...)` calls no longer race their
|
|
212
|
+
rollback snapshots.
|
|
213
|
+
- Cache stampede dedup in `ProductManager.getById` — concurrent callers
|
|
214
|
+
for the same id share a single in-flight request.
|
|
215
|
+
- Mock adapters now dispatch `forceError.code` (any name from `HTTP_ERROR`)
|
|
216
|
+
so tests can simulate `NotFound`, `Conflict`, etc., not just `BadRequest`.
|
|
217
|
+
|
|
148
218
|
## License
|
|
149
219
|
|
|
150
220
|
MIT
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Mock cart adapter for testing.
|
|
3
3
|
*/
|
|
4
4
|
import type { CartData } from "@marianmeres/collection-types";
|
|
5
|
+
import { HTTP_ERROR } from "@marianmeres/http-utils";
|
|
5
6
|
import type { CartAdapter } from "../../types/adapter.js";
|
|
6
7
|
/** Mock cart adapter options */
|
|
7
8
|
export interface MockCartAdapterOptions {
|
|
@@ -11,8 +12,9 @@ export interface MockCartAdapterOptions {
|
|
|
11
12
|
delay?: number;
|
|
12
13
|
/** Force errors for testing */
|
|
13
14
|
forceError?: {
|
|
14
|
-
operation?: "fetch" | "addItem" | "updateItem" | "removeItem" | "clear"
|
|
15
|
-
|
|
15
|
+
operation?: "fetch" | "addItem" | "updateItem" | "removeItem" | "clear";
|
|
16
|
+
/** HTTP error class name from `HTTP_ERROR` (default: "BadRequest") */
|
|
17
|
+
code?: keyof typeof HTTP_ERROR;
|
|
16
18
|
message?: string;
|
|
17
19
|
};
|
|
18
20
|
}
|
|
@@ -11,7 +11,9 @@ export function createMockCartAdapter(options = {}) {
|
|
|
11
11
|
const wait = () => new Promise((r) => setTimeout(r, delay));
|
|
12
12
|
const maybeThrow = (operation) => {
|
|
13
13
|
if (options.forceError?.operation === operation) {
|
|
14
|
-
|
|
14
|
+
const code = options.forceError.code ?? "BadRequest";
|
|
15
|
+
const Ctor = HTTP_ERROR[code] ?? HTTP_ERROR.BadRequest;
|
|
16
|
+
throw new Ctor(options.forceError.message ?? `Mock error for ${operation}`);
|
|
15
17
|
}
|
|
16
18
|
};
|
|
17
19
|
return {
|
|
@@ -58,11 +60,5 @@ export function createMockCartAdapter(options = {}) {
|
|
|
58
60
|
cart = { items: [] };
|
|
59
61
|
return structuredClone(cart);
|
|
60
62
|
},
|
|
61
|
-
async sync(newCart, _ctx) {
|
|
62
|
-
await wait();
|
|
63
|
-
maybeThrow("sync");
|
|
64
|
-
cart = structuredClone(newCart);
|
|
65
|
-
return structuredClone(cart);
|
|
66
|
-
},
|
|
67
63
|
};
|
|
68
64
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Mock customer adapter for testing.
|
|
3
3
|
*/
|
|
4
4
|
import type { CustomerData } from "@marianmeres/collection-types";
|
|
5
|
+
import { HTTP_ERROR } from "@marianmeres/http-utils";
|
|
5
6
|
import type { CustomerAdapter } from "../../types/adapter.js";
|
|
6
7
|
/** Mock customer adapter options */
|
|
7
8
|
export interface MockCustomerAdapterOptions {
|
|
@@ -14,7 +15,8 @@ export interface MockCustomerAdapterOptions {
|
|
|
14
15
|
/** Force errors for testing */
|
|
15
16
|
forceError?: {
|
|
16
17
|
operation?: "fetch" | "fetchBySession" | "update";
|
|
17
|
-
|
|
18
|
+
/** HTTP error class name from `HTTP_ERROR` (default: "BadRequest") */
|
|
19
|
+
code?: keyof typeof HTTP_ERROR;
|
|
18
20
|
message?: string;
|
|
19
21
|
};
|
|
20
22
|
}
|
|
@@ -14,8 +14,9 @@ export function createMockCustomerAdapter(options = {}) {
|
|
|
14
14
|
const wait = () => new Promise((r) => setTimeout(r, delay));
|
|
15
15
|
const maybeThrow = (operation) => {
|
|
16
16
|
if (options.forceError?.operation === operation) {
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const code = options.forceError.code ?? "BadRequest";
|
|
18
|
+
const Ctor = HTTP_ERROR[code] ?? HTTP_ERROR.BadRequest;
|
|
19
|
+
throw new Ctor(options.forceError.message ?? `Mock error for ${operation}`);
|
|
19
20
|
}
|
|
20
21
|
};
|
|
21
22
|
const hasFetchBySession = "guestData" in options ||
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Mock order adapter for testing.
|
|
3
3
|
*/
|
|
4
4
|
import type { OrderData } from "@marianmeres/collection-types";
|
|
5
|
+
import { HTTP_ERROR } from "@marianmeres/http-utils";
|
|
5
6
|
import type { OrderAdapter } from "../../types/adapter.js";
|
|
6
7
|
/** Mock order adapter options */
|
|
7
8
|
export interface MockOrderAdapterOptions {
|
|
@@ -12,7 +13,8 @@ export interface MockOrderAdapterOptions {
|
|
|
12
13
|
/** Force errors for testing */
|
|
13
14
|
forceError?: {
|
|
14
15
|
operation?: "fetchAll" | "fetchOne" | "create";
|
|
15
|
-
|
|
16
|
+
/** HTTP error class name from `HTTP_ERROR` (default: "BadRequest") */
|
|
17
|
+
code?: keyof typeof HTTP_ERROR;
|
|
16
18
|
message?: string;
|
|
17
19
|
};
|
|
18
20
|
}
|
|
@@ -5,17 +5,19 @@ import { HTTP_ERROR } from "@marianmeres/http-utils";
|
|
|
5
5
|
/** Create a mock order adapter for testing */
|
|
6
6
|
export function createMockOrderAdapter(options = {}) {
|
|
7
7
|
const delay = options.delay ?? 50;
|
|
8
|
-
|
|
8
|
+
const orders = options.initialData
|
|
9
9
|
? options.initialData.map((o, i) => ({
|
|
10
|
-
...structuredClone(o),
|
|
11
10
|
model_id: `order-${i + 1}`,
|
|
11
|
+
data: structuredClone(o),
|
|
12
12
|
}))
|
|
13
13
|
: [];
|
|
14
14
|
let orderIdCounter = orders.length;
|
|
15
15
|
const wait = () => new Promise((r) => setTimeout(r, delay));
|
|
16
16
|
const maybeThrow = (operation) => {
|
|
17
17
|
if (options.forceError?.operation === operation) {
|
|
18
|
-
|
|
18
|
+
const code = options.forceError.code ?? "BadRequest";
|
|
19
|
+
const Ctor = HTTP_ERROR[code] ?? HTTP_ERROR.BadRequest;
|
|
20
|
+
throw new Ctor(options.forceError.message ?? `Mock error for ${operation}`);
|
|
19
21
|
}
|
|
20
22
|
};
|
|
21
23
|
return {
|
|
@@ -41,8 +43,8 @@ export function createMockOrderAdapter(options = {}) {
|
|
|
41
43
|
...structuredClone(orderData),
|
|
42
44
|
status: "pending",
|
|
43
45
|
};
|
|
44
|
-
orders.push({
|
|
45
|
-
return { model_id, data
|
|
46
|
+
orders.push({ model_id, data: structuredClone(data) });
|
|
47
|
+
return { model_id, data };
|
|
46
48
|
},
|
|
47
49
|
};
|
|
48
50
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Mock payment adapter for testing.
|
|
3
3
|
*/
|
|
4
4
|
import type { PaymentData, UUID } from "@marianmeres/collection-types";
|
|
5
|
+
import { HTTP_ERROR } from "@marianmeres/http-utils";
|
|
5
6
|
import type { PaymentAdapter } from "../../types/adapter.js";
|
|
6
7
|
/** Mock payment adapter options */
|
|
7
8
|
export interface MockPaymentAdapterOptions {
|
|
@@ -12,7 +13,8 @@ export interface MockPaymentAdapterOptions {
|
|
|
12
13
|
/** Force errors for testing */
|
|
13
14
|
forceError?: {
|
|
14
15
|
operation?: "fetchForOrder" | "fetchOne" | "initiate" | "capture";
|
|
15
|
-
|
|
16
|
+
/** HTTP error class name from `HTTP_ERROR` (default: "BadRequest") */
|
|
17
|
+
code?: keyof typeof HTTP_ERROR;
|
|
16
18
|
message?: string;
|
|
17
19
|
};
|
|
18
20
|
}
|
|
@@ -11,11 +11,19 @@ export function createMockPaymentAdapter(options = {}) {
|
|
|
11
11
|
const wait = () => new Promise((r) => setTimeout(r, delay));
|
|
12
12
|
const maybeThrow = (operation) => {
|
|
13
13
|
if (options.forceError?.operation === operation) {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
const code = options.forceError.code ?? "BadRequest";
|
|
15
|
+
const Ctor = HTTP_ERROR[code] ?? HTTP_ERROR.BadRequest;
|
|
16
|
+
throw new Ctor(options.forceError.message ?? `Mock error for ${operation}`);
|
|
16
17
|
}
|
|
17
18
|
};
|
|
18
|
-
const
|
|
19
|
+
const findPaymentByRef = (ref) => {
|
|
20
|
+
for (const [orderId, list] of Object.entries(payments)) {
|
|
21
|
+
const payment = list.find((p) => p.provider_reference === ref);
|
|
22
|
+
if (payment)
|
|
23
|
+
return { payment, orderId: orderId };
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
19
27
|
return {
|
|
20
28
|
async fetchForOrder(orderId, _ctx) {
|
|
21
29
|
await wait();
|
|
@@ -29,17 +37,26 @@ export function createMockPaymentAdapter(options = {}) {
|
|
|
29
37
|
async fetchOne(paymentId, _ctx) {
|
|
30
38
|
await wait();
|
|
31
39
|
maybeThrow("fetchOne");
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
if (!payment) {
|
|
40
|
+
const found = findPaymentByRef(paymentId);
|
|
41
|
+
if (!found) {
|
|
35
42
|
throw new HTTP_ERROR.NotFound(`Payment ${paymentId} not found`);
|
|
36
43
|
}
|
|
37
|
-
return structuredClone(payment);
|
|
44
|
+
return structuredClone(found.payment);
|
|
38
45
|
},
|
|
39
46
|
async initiate(orderId, config, _ctx) {
|
|
40
47
|
await wait();
|
|
41
48
|
maybeThrow("initiate");
|
|
42
49
|
const id = `pi_${Math.random().toString(36).slice(2)}`;
|
|
50
|
+
// Persist the initiated (pending) payment so capture() can find it.
|
|
51
|
+
if (!payments[orderId])
|
|
52
|
+
payments[orderId] = [];
|
|
53
|
+
payments[orderId].push({
|
|
54
|
+
provider: config.provider,
|
|
55
|
+
status: "pending",
|
|
56
|
+
amount: config.amount,
|
|
57
|
+
currency: config.currency,
|
|
58
|
+
provider_reference: id,
|
|
59
|
+
});
|
|
43
60
|
return {
|
|
44
61
|
id,
|
|
45
62
|
redirect_url: `https://mock-payment.test/pay/${id}`,
|
|
@@ -52,19 +69,16 @@ export function createMockPaymentAdapter(options = {}) {
|
|
|
52
69
|
async capture(paymentId, _ctx) {
|
|
53
70
|
await wait();
|
|
54
71
|
maybeThrow("capture");
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
payments[key] = [];
|
|
66
|
-
payments[key].push(payment);
|
|
67
|
-
return structuredClone(payment);
|
|
72
|
+
// Look up the (pending) payment by reference and complete it.
|
|
73
|
+
// Preserves the original amount/currency/provider that initiate()
|
|
74
|
+
// recorded — bypassing this would force the test to assert against
|
|
75
|
+
// hardcoded zeros.
|
|
76
|
+
const found = findPaymentByRef(paymentId);
|
|
77
|
+
if (!found) {
|
|
78
|
+
throw new HTTP_ERROR.NotFound(`Payment ${paymentId} not found`);
|
|
79
|
+
}
|
|
80
|
+
found.payment.status = "completed";
|
|
81
|
+
return structuredClone(found.payment);
|
|
68
82
|
},
|
|
69
83
|
};
|
|
70
84
|
}
|