@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.
- package/AGENTS.md +134 -20
- package/API.md +108 -25
- package/README.md +127 -0
- package/dist/adapters/http/_http.d.ts +34 -0
- package/dist/adapters/http/_http.js +75 -0
- package/dist/adapters/http/cart.d.ts +21 -0
- package/dist/adapters/http/cart.js +52 -0
- package/dist/adapters/http/customer.d.ts +22 -0
- package/dist/adapters/http/customer.js +35 -0
- package/dist/adapters/http/mod.d.ts +21 -0
- package/dist/adapters/http/mod.js +20 -0
- package/dist/adapters/http/order.d.ts +24 -0
- package/dist/adapters/http/order.js +43 -0
- package/dist/adapters/http/payment.d.ts +32 -0
- package/dist/adapters/http/payment.js +77 -0
- package/dist/adapters/http/product.d.ts +18 -0
- package/dist/adapters/http/product.js +30 -0
- package/dist/adapters/http/wishlist.d.ts +19 -0
- package/dist/adapters/http/wishlist.js +42 -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/adapters/mod.d.ts +4 -1
- package/dist/adapters/mod.js +4 -1
- 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/dist/types/state.d.ts +2 -0
- 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
|
|
@@ -57,15 +58,27 @@ src/
|
|
|
57
58
|
│ ├── payment.ts # PaymentManager
|
|
58
59
|
│ └── product.ts # ProductManager
|
|
59
60
|
└── adapters/
|
|
60
|
-
├── mod.ts # Adapter exports
|
|
61
|
-
|
|
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
|
-
├──
|
|
64
|
-
├──
|
|
65
|
-
├──
|
|
66
|
-
├──
|
|
67
|
-
├──
|
|
68
|
-
|
|
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**: `
|
|
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**:
|
|
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. **
|
|
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`
|
|
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.
|
|
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
|
|
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 |
|
|
@@ -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;
|