@marianmeres/ecsuite 1.3.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENTS.md +134 -20
  2. package/API.md +108 -25
  3. package/README.md +127 -0
  4. package/dist/adapters/http/_http.d.ts +34 -0
  5. package/dist/adapters/http/_http.js +75 -0
  6. package/dist/adapters/http/cart.d.ts +21 -0
  7. package/dist/adapters/http/cart.js +52 -0
  8. package/dist/adapters/http/customer.d.ts +22 -0
  9. package/dist/adapters/http/customer.js +35 -0
  10. package/dist/adapters/http/mod.d.ts +21 -0
  11. package/dist/adapters/http/mod.js +20 -0
  12. package/dist/adapters/http/order.d.ts +24 -0
  13. package/dist/adapters/http/order.js +43 -0
  14. package/dist/adapters/http/payment.d.ts +32 -0
  15. package/dist/adapters/http/payment.js +77 -0
  16. package/dist/adapters/http/product.d.ts +18 -0
  17. package/dist/adapters/http/product.js +30 -0
  18. package/dist/adapters/http/wishlist.d.ts +19 -0
  19. package/dist/adapters/http/wishlist.js +42 -0
  20. package/dist/adapters/mock/cart.d.ts +4 -2
  21. package/dist/adapters/mock/cart.js +3 -7
  22. package/dist/adapters/mock/customer.d.ts +3 -1
  23. package/dist/adapters/mock/customer.js +3 -2
  24. package/dist/adapters/mock/order.d.ts +3 -1
  25. package/dist/adapters/mock/order.js +7 -5
  26. package/dist/adapters/mock/payment.d.ts +3 -1
  27. package/dist/adapters/mock/payment.js +34 -20
  28. package/dist/adapters/mock/product.d.ts +3 -1
  29. package/dist/adapters/mock/product.js +3 -1
  30. package/dist/adapters/mock/wishlist.d.ts +4 -2
  31. package/dist/adapters/mock/wishlist.js +3 -7
  32. package/dist/adapters/mod.d.ts +4 -1
  33. package/dist/adapters/mod.js +4 -1
  34. package/dist/domains/base.d.ts +13 -6
  35. package/dist/domains/base.js +31 -12
  36. package/dist/domains/cart.js +17 -0
  37. package/dist/domains/customer.js +18 -4
  38. package/dist/domains/order.d.ts +33 -15
  39. package/dist/domains/order.js +34 -20
  40. package/dist/domains/payment.js +16 -2
  41. package/dist/domains/product.d.ts +29 -40
  42. package/dist/domains/product.js +99 -81
  43. package/dist/domains/wishlist.js +4 -2
  44. package/dist/suite.d.ts +49 -1
  45. package/dist/suite.js +90 -8
  46. package/dist/types/adapter.d.ts +10 -7
  47. package/dist/types/events.d.ts +6 -2
  48. package/dist/types/state.d.ts +2 -0
  49. package/docs/future-improvements.md +116 -0
  50. 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.1.4"
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 (returns model_id)]
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 with TTL]
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
@@ -57,15 +58,27 @@ src/
57
58
  │ ├── payment.ts # PaymentManager
58
59
  │ └── product.ts # ProductManager
59
60
  └── adapters/
60
- ├── mod.ts # Adapter exports
61
- └── mock/ # Mock adapters for testing
61
+ ├── mod.ts # Adapter exports (re-exports mock/* and http/*)
62
+ ├── mock/ # Mock adapters for testing
63
+ │ ├── mod.ts
64
+ │ ├── cart.ts
65
+ │ ├── wishlist.ts
66
+ │ ├── order.ts
67
+ │ ├── customer.ts
68
+ │ ├── payment.ts
69
+ │ └── product.ts
70
+ └── http/ # Built-in HTTP adapters
62
71
  ├── mod.ts
63
- ├── cart.ts
64
- ├── wishlist.ts
65
- ├── order.ts
66
- ├── customer.ts
67
- ├── payment.ts
68
- └── product.ts
72
+ ├── _http.ts # Shared fetch primitives (authHeaders, sessionHeader, requestJson)
73
+ ├── cart.ts # createHttpCartAdapter
74
+ ├── wishlist.ts # createHttpWishlistAdapter
75
+ ├── order.ts # createHttpOrderAdapter
76
+ ├── customer.ts # createHttpCustomerAdapter
77
+ ├── payment.ts # createHttpPaymentAdapter (capture omitted)
78
+ └── product.ts # createHttpProductAdapter
79
+ example/
80
+ ├── index.html # Vanilla-JS reference harness — every public verb
81
+ └── src/app.ts # imports ../../src/mod.ts
69
82
  tests/
70
83
  ├── cart.test.ts
71
84
  ├── wishlist.test.ts
@@ -73,6 +86,7 @@ tests/
73
86
  ├── customer.test.ts
74
87
  ├── payment.test.ts
75
88
  ├── product.test.ts
89
+ ├── http-adapters.test.ts # Unit tests for the built-in HTTP adapters
76
90
  └── ecsuite.test.ts
77
91
  ```
78
92
 
@@ -142,8 +156,74 @@ export {
142
156
  MockProductAdapterOptions,
143
157
  MockWishlistAdapterOptions,
144
158
  } from "./adapters/mod.ts";
159
+
160
+ // Built-in HTTP Adapters
161
+ export {
162
+ createHttpCartAdapter,
163
+ createHttpCustomerAdapter,
164
+ createHttpOrderAdapter,
165
+ createHttpPaymentAdapter,
166
+ createHttpProductAdapter,
167
+ createHttpWishlistAdapter,
168
+ HttpAdapterOptions,
169
+ HttpCartAdapterOptions,
170
+ HttpCustomerAdapterOptions,
171
+ HttpOrderAdapterOptions,
172
+ HttpPaymentAdapterOptions,
173
+ HttpProductAdapterOptions,
174
+ HttpWishlistAdapterOptions,
175
+ } from "./adapters/mod.ts";
145
176
  ```
146
177
 
178
+ ## Built-in HTTP Adapters
179
+
180
+ Each factory returns an object conforming to the matching `*Adapter`
181
+ interface in `src/types/adapter.ts`. They target a conventional commerce
182
+ REST surface; see the `README.md` "Built-in HTTP Adapters" section for the
183
+ endpoint table.
184
+
185
+ Key rules:
186
+
187
+ - Adapters are **thin** — they throw raw HTTP errors (`Error` with
188
+ `.status` and `.body` attached); the domain manager normalizes them to
189
+ `DomainError`. Don't normalize inside the adapter.
190
+ - Authentication comes off `DomainContext`:
191
+ - `ctx.jwt` → `Authorization: Bearer <jwt>`
192
+ - `ctx.sessionId` → `X-Session-ID`
193
+ - `ctx.customerId` is required for the customer adapter's
194
+ owner-scoped route.
195
+ - `_http.ts` holds the shared primitives (`authHeaders`, `sessionHeader`,
196
+ `requestJson`, `join`, `require*Id`); every adapter is ~50–70 lines.
197
+ - `createHttpOrderAdapter.create()` is a **single-call** adapter
198
+ targeting `POST /checkout/start`. The addresses → delivery → payment →
199
+ complete checkout flow is not wrapped by the adapter in v2.x; callers
200
+ drive the remaining steps directly.
201
+ - `createHttpPaymentAdapter.initiate()` posts `{ order_id, provider,
202
+ return_url, cancel_url }` to `POST {baseUrl}/initiate`. **No
203
+ `amount`/`currency` in the body** — the server derives them from the
204
+ order row (security: prevents client-side tampering), and the target
205
+ route strictly requires all four listed fields. The adapter throws a
206
+ client-side error if `return_url` or `cancel_url` is missing from the
207
+ `PaymentInitConfig`. The server additionally requires the order to have
208
+ passed checkout validation (addresses + delivery set) before initiating
209
+ payment — callers are responsible for the upstream steps.
210
+ - `createHttpPaymentAdapter` intentionally omits `capture` — consumers
211
+ calling `suite.payment.capture()` will get `NOT_IMPLEMENTED` from the
212
+ manager. Capture is typically server-driven (webhooks + checkout
213
+ complete).
214
+ - `createHttpCustomerAdapter` omits `fetchBySession` — there is no clean
215
+ session-scoped read endpoint on the target surface. Consumers should
216
+ hydrate customer state from the JWT subject claim or pass an explicit
217
+ `customerId` on context.
218
+
219
+ ## Reference Harness
220
+
221
+ `example/` ships a vanilla-JS harness that exercises every public verb of
222
+ every domain manager. It can swap between mock adapters (zero-setup
223
+ default) and the built-in HTTP adapters (real server round-trip). Runs
224
+ through `deno task example:build` / `example:watch` + any static file
225
+ server.
226
+
147
227
  ## State Machine
148
228
 
149
229
  ```
@@ -166,7 +246,29 @@ const suite = createECSuite({
166
246
  storage: { type: "local" },
167
247
  productCacheTtl: 300000,
168
248
  autoInitialize: true,
249
+ autoResetOnIdentityChange: true, // default: reset on customerId transition
169
250
  });
251
+
252
+ // Avoid the auto-init race: callers should await the suite's readiness
253
+ // before issuing mutations.
254
+ await suite.ready;
255
+ ```
256
+
257
+ ### Identity Switch
258
+
259
+ ```typescript
260
+ // Atomic: merge context, reset all domains, re-initialize.
261
+ await suite.switchIdentity({ customerId: "another" });
262
+
263
+ // Or via setContext when autoResetOnIdentityChange is enabled.
264
+ suite.setContext({ customerId: "another" });
265
+ await suite.ready;
266
+ ```
267
+
268
+ ### Teardown
269
+
270
+ ```typescript
271
+ suite.destroy(); // unsubscribes all internal pubsub listeners
170
272
  ```
171
273
 
172
274
  ### Selective Initialization
@@ -280,20 +382,32 @@ deno publish # Publish to JSR
280
382
 
281
383
  ## Important Implementation Details
282
384
 
283
- 1. **Optimistic Updates**: `_withOptimisticUpdate()` in BaseDomainManager captures previous state before mutation, rolls back on server error.
385
+ 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
386
 
285
- 2. **Persistence**: Cart and Wishlist use `@marianmeres/store` with `createStoragePersistor()` for localStorage/sessionStorage.
387
+ 2. **Persistence**: Cart and Wishlist use `@marianmeres/store` with `createStoragePersistor()` for localStorage/sessionStorage. (Cross-tab `storage` event sync is NOT yet implemented.)
286
388
 
287
- 3. **ProductManager**: Does NOT extend BaseDomainManager. Uses simple Map cache with TTL instead of state machine.
389
+ 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
390
 
289
391
  4. **Event System**: Shared PubSub instance passed through ECSuite constructor. Events typed with discriminated union.
290
392
 
291
393
  5. **Context**: DomainContext (customerId, sessionId, + arbitrary properties via index signature) passed to all adapter methods for server-side identification.
292
394
 
293
- 6. **OrderCreateResult**: `OrderAdapter.create()` returns `{ model_id, data }` so consumers always get the server-assigned model ID.
395
+ 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
396
 
295
- 7. **Payment Write Ops**: `PaymentAdapter.initiate?()` and `capture?()` are optional methods. `PaymentManager` null-checks before calling, returns null when unavailable.
397
+ 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
398
 
297
- 8. **Guest Checkout**: `CustomerAdapter.fetchBySession?()` is optional. `CustomerManager` uses it when `customerId` is absent in context, falls back to `fetch()` when unavailable.
399
+ 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
400
 
299
401
  9. **Operation Hooks**: `ECSuite.onBeforeSync()` and `onAfterSync()` are convenience wrappers over the existing event system (no changes to BaseDomainManager).
402
+
403
+ 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.
404
+
405
+ 11. **Auto-init race**: Constructor with `autoInitialize: true` (default) starts initialize() but cannot await it; consumers should `await suite.ready` before issuing mutations.
406
+
407
+ 12. **Cart Quantity Validation**: `addItem` and `updateItemQuantity` throw `TypeError`/`RangeError` for `NaN`, `Infinity`, fractional, or negative values at the call site (never persisted optimistically).
408
+
409
+ ## Known limitations (not yet fixed)
410
+
411
+ - **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.
412
+ - **Pagination**: `OrderAdapter.fetchAll` and `PaymentAdapter.fetchForOrder` have no `limit/offset/cursor` params.
413
+ - **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 domains. Called automatically if `autoInitialize` is true.
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<OrderData | null>
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<OrderData | null>
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 orders.
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
- getOrders(): OrderData[]
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): OrderData | undefined
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. Unlike other managers, uses a simple cache layer instead of state machine.
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<OrderData[]>;
669
- fetchOne(orderId: UUID, ctx: DomainContext): Promise<OrderData>;
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 |
@@ -123,6 +144,63 @@ const suite = createECSuite({
123
144
  });
124
145
  ```
125
146
 
147
+ ## Built-in HTTP Adapters
148
+
149
+ For consumers whose backend exposes the conventional commerce REST surface,
150
+ ecsuite ships ready-to-use HTTP adapters for every domain. Each factory
151
+ takes `{ baseUrl?, fetch? }`; authentication is carried on the context
152
+ passed into each call (`ctx.sessionId` → `X-Session-ID`; `ctx.jwt` →
153
+ `Authorization: Bearer <jwt>`).
154
+
155
+ ```typescript
156
+ import {
157
+ createECSuite,
158
+ createHttpCartAdapter,
159
+ createHttpCustomerAdapter,
160
+ createHttpOrderAdapter,
161
+ createHttpPaymentAdapter,
162
+ createHttpProductAdapter,
163
+ createHttpWishlistAdapter,
164
+ } from "@marianmeres/ecsuite";
165
+
166
+ const suite = createECSuite({
167
+ context: { sessionId: mySessionId, jwt: myJwt, customerId: myCustomerId },
168
+ adapters: {
169
+ cart: createHttpCartAdapter({ baseUrl: "/api/session" }),
170
+ wishlist: createHttpWishlistAdapter({ baseUrl: "/api/session" }),
171
+ order: createHttpOrderAdapter({ baseUrl: "/api/order" }),
172
+ customer: createHttpCustomerAdapter({ baseUrl: "/api/customer" }),
173
+ payment: createHttpPaymentAdapter({ baseUrl: "/api/payment" }),
174
+ product: createHttpProductAdapter({ baseUrl: "/api/product" }),
175
+ },
176
+ });
177
+ ```
178
+
179
+ Expected endpoints per adapter (all mutations require `X-Session-ID`, all
180
+ owner-scoped reads require a JWT):
181
+
182
+ | Adapter | Endpoints |
183
+ | -------- | --------------------------------------------------------------------------------------------------- |
184
+ | cart | `GET/POST/PUT/DELETE {baseUrl}/cart` (DELETE with optional `?product_id=` for single-item remove) |
185
+ | wishlist | `GET/POST/DELETE {baseUrl}/wishlist` (DELETE with optional `?product_id=` for single-item remove) |
186
+ | order | `GET {baseUrl}/col/order`, `GET {baseUrl}/col/order/:id`, `POST {baseUrl}/checkout/start` |
187
+ | customer | `GET/PUT {baseUrl}/me/col/customer/:customerId` |
188
+ | payment | `GET {baseUrl}/by-order/:orderId`, `GET {baseUrl}/col/payment/:id`, `POST {baseUrl}/initiate` (body: `{ order_id, provider, return_url, cancel_url }` — server derives amount/currency from the order record) |
189
+ | product | `GET {baseUrl}/col/product/:id` (`fetchMany` = parallel single fetches — no batch endpoint assumed) |
190
+
191
+ Adapters throw raw HTTP errors (`Error` with `.status` and `.body`
192
+ attached); the domain manager normalizes them to `DomainError`. Responses
193
+ may use `{ model_id, data }` model envelopes — adapters unwrap them
194
+ transparently.
195
+
196
+ `PaymentAdapter.capture` is intentionally omitted from
197
+ `createHttpPaymentAdapter`; capture is typically driven server-side by
198
+ provider webhooks + checkout completion. Calls to `suite.payment.capture()`
199
+ will surface as `NOT_IMPLEMENTED`.
200
+
201
+ See [`example/`](./example/) for a vanilla-JS reference harness exercising
202
+ every public verb against either the HTTP adapters or the mock adapters.
203
+
126
204
  ## Events
127
205
 
128
206
  Subscribe to domain events:
@@ -145,6 +223,55 @@ suite.once("order:created", (event) => {
145
223
 
146
224
  For complete API documentation, see [API.md](API.md).
147
225
 
226
+ ## Migration to next major
227
+
228
+ This release tightens correctness in several places. Breaking changes:
229
+
230
+ - **`OrderAdapter` returns `OrderCreateResult`** for both `fetchAll` and
231
+ `fetchOne` (`{ model_id, data }`) so orders are uniquely identifiable.
232
+ `OrderListData.orders` is now `OrderCreateResult[]`. Use the new
233
+ `orders.getOrderById(modelId)` / `getOrderDataById(modelId)` helpers, or
234
+ read `result.data.<field>` on returned envelopes.
235
+ - **`CartAdapter.sync()` and `WishlistAdapter.sync()` removed** — they were
236
+ never called by the manager.
237
+ - **`PaymentManager.initiate()` / `capture()` throw `NOT_IMPLEMENTED`** when
238
+ the adapter doesn't implement the optional method (previously returned
239
+ `null` silently). `domain:error` is also emitted.
240
+ - **`CustomerManager.update()` throws `NOT_IMPLEMENTED`** when no adapter is
241
+ configured (previously silent no-op).
242
+ - **`CustomerManager` no longer falls through to `fetch()`** when both
243
+ `customerId` is missing AND `adapter.fetchBySession` is undefined; it
244
+ now warns and stays in `ready` with `data: null`. Pass `customerId` in
245
+ context, or implement `fetchBySession`.
246
+ - **`CartManager.addItem` / `updateItemQuantity`** validate the quantity
247
+ (must be a finite, non-negative integer); invalid values throw at the
248
+ call site instead of being persisted optimistically.
249
+ - **`ProductManager` now extends `BaseDomainManager`** — exposes `subscribe`,
250
+ emits `domain:error`, and gains an `initialize()` no-op. `setAdapter` /
251
+ `getAdapter` / `setContext` / `getContext` keep the same signatures.
252
+ - **`InitializableDomainName`** now includes `"product"` for parity with
253
+ the other domains.
254
+
255
+ New additions:
256
+
257
+ - `suite.ready: Promise<void>` — resolves when the most recent (auto or
258
+ manual) `initialize()` settles.
259
+ - `suite.switchIdentity(context)` — atomic identity switch (merge context,
260
+ reset domains, re-initialize). Returns a promise.
261
+ - `suite.destroy()` — unsubscribes all internal pubsub listeners.
262
+ - `ECSuiteConfig.autoResetOnIdentityChange` (default `true`) — opt out of
263
+ the auto-reset path on `setContext()` if you manage identity transitions
264
+ yourself.
265
+ - `OrderManager.getOrderById(modelId)` / `getOrderDataById(modelId)` lookup
266
+ helpers.
267
+ - Per-domain mutation queue (`withOptimisticUpdate` is serialized per
268
+ manager) — concurrent `cart.addItem(...)` calls no longer race their
269
+ rollback snapshots.
270
+ - Cache stampede dedup in `ProductManager.getById` — concurrent callers
271
+ for the same id share a single in-flight request.
272
+ - Mock adapters now dispatch `forceError.code` (any name from `HTTP_ERROR`)
273
+ so tests can simulate `NotFound`, `Conflict`, etc., not just `BadRequest`.
274
+
148
275
  ## License
149
276
 
150
277
  MIT
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @module adapters/http/_http
3
+ *
4
+ * Shared primitives for the built-in HTTP adapters.
5
+ *
6
+ * Adapters throw raw HTTP errors (`Error` with `.status` and `.body`
7
+ * attached). `BaseDomainManager` catches and normalizes them to
8
+ * `DomainError` at the call site — adapters don't normalize themselves.
9
+ */
10
+ import type { DomainContext } from "../../types/state.js";
11
+ /** Options shared by every built-in HTTP adapter factory. */
12
+ export interface HttpAdapterOptions {
13
+ /** Base URL of the mounted REST app. Each adapter has its own default. */
14
+ baseUrl?: string;
15
+ /** Override the `fetch` implementation (useful for tests / SSR). */
16
+ fetch?: typeof fetch;
17
+ }
18
+ export declare function resolveFetch(opts?: HttpAdapterOptions): typeof fetch;
19
+ /** Trailing-slash-safe path joining. */
20
+ export declare function join(base: string, path: string): string;
21
+ export declare function authHeaders(ctx: DomainContext): HeadersInit;
22
+ export declare function sessionHeader(ctx: DomainContext): HeadersInit;
23
+ /**
24
+ * Wrap `fetch`, merge auth + session + content-type headers, and throw a raw
25
+ * HTTP error (with `.status` and `.body`) on non-OK. Returns `undefined` on
26
+ * 204 No Content, otherwise the parsed JSON body.
27
+ */
28
+ export declare function requestJson<T>(doFetch: typeof fetch, url: string, init: RequestInit, ctx: DomainContext): Promise<T>;
29
+ /** Require `ctx.sessionId`; throw a client-side Error if missing. */
30
+ export declare function requireSessionId(ctx: DomainContext, operation: string): string;
31
+ /** Require `ctx.jwt`; throw a client-side Error if missing. */
32
+ export declare function requireJwt(ctx: DomainContext, operation: string): string;
33
+ /** Require `ctx.customerId`; throw a client-side Error if missing. */
34
+ export declare function requireCustomerId(ctx: DomainContext, operation: string): string;