@marianmeres/ecsuite 1.3.2 → 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/dist/domains/product.js
CHANGED
|
@@ -3,23 +3,28 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Product domain manager with in-memory caching.
|
|
5
5
|
*
|
|
6
|
-
* Read-only domain for fetching product data with caching
|
|
7
|
-
*
|
|
6
|
+
* Read-only domain for fetching product data with TTL caching. Extends
|
|
7
|
+
* `BaseDomainManager` for unified observability (subscribe, domain:error,
|
|
8
|
+
* domain:state:changed) but the per-product cache itself is held in a
|
|
9
|
+
* private Map — the store's `data` is null because there is no single
|
|
10
|
+
* aggregate state worth subscribing to (use `getCacheSize()` /
|
|
11
|
+
* `isCached()` for cache introspection).
|
|
8
12
|
*/
|
|
9
|
-
import {
|
|
10
|
-
import { createPubSub } from "@marianmeres/pubsub";
|
|
13
|
+
import { BaseDomainManager } from "./base.js";
|
|
11
14
|
/**
|
|
12
|
-
* Product manager with in-memory caching.
|
|
15
|
+
* Product manager with in-memory TTL caching.
|
|
13
16
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* Extends `BaseDomainManager` for unified observability (Svelte-compatible
|
|
18
|
+
* `subscribe`, `domain:state:changed`, `domain:error` events) but skips the
|
|
19
|
+
* per-product state machine — fetching a single product never transitions
|
|
20
|
+
* the whole domain into "syncing" because that would be misleading.
|
|
17
21
|
*
|
|
18
22
|
* Features:
|
|
19
|
-
* - In-memory cache with TTL expiration
|
|
23
|
+
* - In-memory cache with per-entry TTL expiration
|
|
20
24
|
* - Single and batch product fetching
|
|
25
|
+
* - In-flight request dedup (cache stampede prevention)
|
|
21
26
|
* - Prefetching support for UI optimization
|
|
22
|
-
* -
|
|
27
|
+
* - `domain:error` event emission on adapter failures
|
|
23
28
|
*
|
|
24
29
|
* @example
|
|
25
30
|
* ```typescript
|
|
@@ -27,88 +32,79 @@ import { createPubSub } from "@marianmeres/pubsub";
|
|
|
27
32
|
* adapter: myProductAdapter,
|
|
28
33
|
* cacheTtl: 10 * 60 * 1000, // 10 minutes
|
|
29
34
|
* });
|
|
35
|
+
* await products.initialize();
|
|
30
36
|
*
|
|
31
37
|
* const product = await products.getById("prod-123");
|
|
32
38
|
* const many = await products.getByIds(["prod-1", "prod-2"]);
|
|
33
39
|
* ```
|
|
34
40
|
*/
|
|
35
|
-
export class ProductManager {
|
|
36
|
-
#clog = createClog("ecsuite:product", { color: "auto" });
|
|
37
|
-
#pubsub;
|
|
38
|
-
#adapter = null;
|
|
39
|
-
#context = {};
|
|
41
|
+
export class ProductManager extends BaseDomainManager {
|
|
40
42
|
#cache = new Map();
|
|
41
43
|
#cacheTtl;
|
|
44
|
+
/** Pending fetches by id, used to dedup concurrent callers (D8). */
|
|
45
|
+
#inflight = new Map();
|
|
42
46
|
constructor(options = {}) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
*/
|
|
54
|
-
setAdapter(adapter) {
|
|
55
|
-
this.#adapter = adapter;
|
|
56
|
-
this.#clog.debug("adapter set");
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Get the current adapter.
|
|
60
|
-
*
|
|
61
|
-
* @returns The adapter or null if not set
|
|
62
|
-
*/
|
|
63
|
-
getAdapter() {
|
|
64
|
-
return this.#adapter;
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* Update context (customerId, sessionId).
|
|
68
|
-
*
|
|
69
|
-
* @param context - Context to merge with existing context
|
|
70
|
-
*/
|
|
71
|
-
setContext(context) {
|
|
72
|
-
this.#context = { ...this.#context, ...context };
|
|
47
|
+
super("product", {
|
|
48
|
+
...options,
|
|
49
|
+
// Cache lives in a private Map; the store's `data` carries no
|
|
50
|
+
// aggregate so persistence is meaningless here.
|
|
51
|
+
storageType: null,
|
|
52
|
+
});
|
|
53
|
+
if (options.adapter) {
|
|
54
|
+
this.adapter = options.adapter;
|
|
55
|
+
}
|
|
56
|
+
this.#cacheTtl = options.cacheTtl ?? 5 * 60 * 1000;
|
|
73
57
|
}
|
|
74
58
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
59
|
+
* Initialize. Lazy domain — there's nothing to fetch eagerly. Just
|
|
60
|
+
* transitions to "ready" so consumers can rely on the same readiness
|
|
61
|
+
* contract as other domains.
|
|
78
62
|
*/
|
|
79
|
-
|
|
80
|
-
|
|
63
|
+
initialize() {
|
|
64
|
+
this.clog.debug("initialize");
|
|
65
|
+
this.setState("ready");
|
|
66
|
+
return Promise.resolve();
|
|
81
67
|
}
|
|
82
68
|
/**
|
|
83
69
|
* Get a single product by ID.
|
|
84
|
-
* Returns from cache if valid, otherwise fetches from server.
|
|
70
|
+
* Returns from cache if valid, otherwise fetches from server. Concurrent
|
|
71
|
+
* callers for the same id share a single in-flight request.
|
|
85
72
|
*
|
|
86
73
|
* @param productId - The product ID to fetch
|
|
87
|
-
* @returns The product data or null if not found/
|
|
74
|
+
* @returns The product data or null if not found / no adapter
|
|
88
75
|
* @emits product:fetched - On successful server fetch
|
|
76
|
+
* @emits domain:error - On adapter failure
|
|
89
77
|
*/
|
|
90
78
|
async getById(productId) {
|
|
91
|
-
// Check cache first
|
|
92
79
|
const cached = this.#getFromCache(productId);
|
|
93
|
-
if (cached)
|
|
80
|
+
if (cached)
|
|
94
81
|
return cached;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (!this.#adapter) {
|
|
98
|
-
this.#clog.debug("getById: no adapter", { productId });
|
|
99
|
-
return null;
|
|
100
|
-
}
|
|
101
|
-
this.#clog.debug("getById: fetching", { productId });
|
|
102
|
-
try {
|
|
103
|
-
const data = await this.#adapter.fetchOne(productId, this.#context);
|
|
104
|
-
this.#setCache(productId, data);
|
|
105
|
-
this.#emitFetched(productId);
|
|
106
|
-
return data;
|
|
107
|
-
}
|
|
108
|
-
catch (e) {
|
|
109
|
-
this.#clog.error("getById failed", { productId, error: e });
|
|
82
|
+
if (!this.adapter) {
|
|
83
|
+
this.clog.debug("getById: no adapter", { productId });
|
|
110
84
|
return null;
|
|
111
85
|
}
|
|
86
|
+
// Dedup concurrent callers (cache stampede prevention)
|
|
87
|
+
const pending = this.#inflight.get(productId);
|
|
88
|
+
if (pending)
|
|
89
|
+
return pending;
|
|
90
|
+
const promise = (async () => {
|
|
91
|
+
this.clog.debug("getById: fetching", { productId });
|
|
92
|
+
try {
|
|
93
|
+
const data = await this.adapter.fetchOne(productId, this.context);
|
|
94
|
+
this.#setCache(productId, data);
|
|
95
|
+
this.#emitFetched(productId);
|
|
96
|
+
return data;
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
this.#emitError("getById", e, { productId });
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
this.#inflight.delete(productId);
|
|
104
|
+
}
|
|
105
|
+
})();
|
|
106
|
+
this.#inflight.set(productId, promise);
|
|
107
|
+
return promise;
|
|
112
108
|
}
|
|
113
109
|
/**
|
|
114
110
|
* Get multiple products by IDs.
|
|
@@ -117,11 +113,11 @@ export class ProductManager {
|
|
|
117
113
|
* @param productIds - Array of product IDs to fetch
|
|
118
114
|
* @returns Map of productId to ProductData
|
|
119
115
|
* @emits product:fetched - For each product fetched from server
|
|
116
|
+
* @emits domain:error - On adapter failure
|
|
120
117
|
*/
|
|
121
118
|
async getByIds(productIds) {
|
|
122
119
|
const result = new Map();
|
|
123
120
|
const missingIds = [];
|
|
124
|
-
// Check cache for each product
|
|
125
121
|
for (const id of productIds) {
|
|
126
122
|
const cached = this.#getFromCache(id);
|
|
127
123
|
if (cached) {
|
|
@@ -131,17 +127,15 @@ export class ProductManager {
|
|
|
131
127
|
missingIds.push(id);
|
|
132
128
|
}
|
|
133
129
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
this.#clog.debug("getByIds: fetching missing", {
|
|
130
|
+
if (missingIds.length > 0 && this.adapter) {
|
|
131
|
+
this.clog.debug("getByIds: fetching missing", {
|
|
137
132
|
total: productIds.length,
|
|
138
133
|
cached: result.size,
|
|
139
134
|
missing: missingIds.length,
|
|
140
135
|
});
|
|
141
136
|
try {
|
|
142
|
-
const fetchedData = await this
|
|
137
|
+
const fetchedData = await this.adapter.fetchMany(missingIds, this.context);
|
|
143
138
|
for (const product of fetchedData) {
|
|
144
|
-
// Products from collection-types have model_id
|
|
145
139
|
const productId = product.model_id;
|
|
146
140
|
if (productId) {
|
|
147
141
|
this.#setCache(productId, product);
|
|
@@ -151,7 +145,7 @@ export class ProductManager {
|
|
|
151
145
|
}
|
|
152
146
|
}
|
|
153
147
|
catch (e) {
|
|
154
|
-
this.#
|
|
148
|
+
this.#emitError("getByIds", e, { missingIds });
|
|
155
149
|
}
|
|
156
150
|
}
|
|
157
151
|
return result;
|
|
@@ -166,7 +160,7 @@ export class ProductManager {
|
|
|
166
160
|
const missingIds = productIds.filter((id) => !this.isCached(id));
|
|
167
161
|
if (missingIds.length === 0)
|
|
168
162
|
return;
|
|
169
|
-
this
|
|
163
|
+
this.clog.debug("prefetch", { count: missingIds.length });
|
|
170
164
|
await this.getByIds(missingIds);
|
|
171
165
|
}
|
|
172
166
|
/**
|
|
@@ -177,11 +171,11 @@ export class ProductManager {
|
|
|
177
171
|
clearCache(productId) {
|
|
178
172
|
if (productId) {
|
|
179
173
|
this.#cache.delete(productId);
|
|
180
|
-
this
|
|
174
|
+
this.clog.debug("cache cleared for product", { productId });
|
|
181
175
|
}
|
|
182
176
|
else {
|
|
183
177
|
this.#cache.clear();
|
|
184
|
-
this
|
|
178
|
+
this.clog.debug("cache cleared entirely");
|
|
185
179
|
}
|
|
186
180
|
}
|
|
187
181
|
/**
|
|
@@ -204,13 +198,18 @@ export class ProductManager {
|
|
|
204
198
|
getCacheSize() {
|
|
205
199
|
return this.#cache.size;
|
|
206
200
|
}
|
|
201
|
+
/** Reset clears the cache too (overrides base reset). */
|
|
202
|
+
reset() {
|
|
203
|
+
this.#cache.clear();
|
|
204
|
+
this.#inflight.clear();
|
|
205
|
+
super.reset();
|
|
206
|
+
}
|
|
207
207
|
/** Get product from cache if valid */
|
|
208
208
|
#getFromCache(productId) {
|
|
209
209
|
const entry = this.#cache.get(productId);
|
|
210
210
|
if (!entry)
|
|
211
211
|
return null;
|
|
212
212
|
if (Date.now() >= entry.expiresAt) {
|
|
213
|
-
// Expired, remove from cache
|
|
214
213
|
this.#cache.delete(productId);
|
|
215
214
|
return null;
|
|
216
215
|
}
|
|
@@ -231,6 +230,25 @@ export class ProductManager {
|
|
|
231
230
|
timestamp: Date.now(),
|
|
232
231
|
productId,
|
|
233
232
|
};
|
|
234
|
-
this
|
|
233
|
+
this.pubsub.publish(event.type, event);
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Emit `domain:error` without changing state. A single failed product
|
|
237
|
+
* fetch should not blanket the whole domain in "error" — other cached
|
|
238
|
+
* lookups remain valid.
|
|
239
|
+
*/
|
|
240
|
+
#emitError(operation, e, context) {
|
|
241
|
+
this.clog.error(`${operation} failed`, { ...context, error: e });
|
|
242
|
+
this.emit({
|
|
243
|
+
type: "domain:error",
|
|
244
|
+
domain: "product",
|
|
245
|
+
timestamp: Date.now(),
|
|
246
|
+
error: {
|
|
247
|
+
code: "FETCH_FAILED",
|
|
248
|
+
message: e instanceof Error ? e.message : "Failed to fetch",
|
|
249
|
+
operation,
|
|
250
|
+
originalError: e,
|
|
251
|
+
},
|
|
252
|
+
});
|
|
235
253
|
}
|
|
236
254
|
}
|
package/dist/domains/wishlist.js
CHANGED
|
@@ -80,8 +80,7 @@ export class WishlistManager extends BaseDomainManager {
|
|
|
80
80
|
async addItem(productId) {
|
|
81
81
|
this.clog.debug("addItem", { productId });
|
|
82
82
|
// Check if already in wishlist
|
|
83
|
-
|
|
84
|
-
if (current.items.some((i) => i.product_id === productId)) {
|
|
83
|
+
if (this.hasProduct(productId)) {
|
|
85
84
|
return; // Already in wishlist, no-op
|
|
86
85
|
}
|
|
87
86
|
const newItem = {
|
|
@@ -89,6 +88,9 @@ export class WishlistManager extends BaseDomainManager {
|
|
|
89
88
|
added_at: Date.now(),
|
|
90
89
|
};
|
|
91
90
|
await this.withOptimisticUpdate("addItem", () => {
|
|
91
|
+
// Re-read inside callback so we operate on the latest state
|
|
92
|
+
// even if a prior in-flight mutation has just settled.
|
|
93
|
+
const current = this.store.get().data ?? { items: [] };
|
|
92
94
|
const items = [...current.items, newItem];
|
|
93
95
|
this.setData({ items }, false);
|
|
94
96
|
}, async () => {
|
package/dist/suite.d.ts
CHANGED
|
@@ -43,6 +43,13 @@ export interface ECSuiteConfig {
|
|
|
43
43
|
autoInitialize?: boolean;
|
|
44
44
|
/** Domains to initialize (default: all). Used by both autoInitialize and manual initialize(). */
|
|
45
45
|
initializeDomains?: InitializableDomainName[];
|
|
46
|
+
/**
|
|
47
|
+
* Reset and re-initialize all domains automatically when `setContext()`
|
|
48
|
+
* changes `customerId` (or transitions between guest and authenticated).
|
|
49
|
+
* Default: `true`. Set to `false` to manage identity transitions yourself
|
|
50
|
+
* via `switchIdentity()` or explicit `reset()` + `initialize()`.
|
|
51
|
+
*/
|
|
52
|
+
autoResetOnIdentityChange?: boolean;
|
|
46
53
|
}
|
|
47
54
|
/**
|
|
48
55
|
* Main ECSuite class - orchestrates all e-commerce domain managers.
|
|
@@ -63,6 +70,19 @@ export interface ECSuiteConfig {
|
|
|
63
70
|
*/
|
|
64
71
|
export declare class ECSuite {
|
|
65
72
|
#private;
|
|
73
|
+
/**
|
|
74
|
+
* Resolves when the most recent `initialize()` (auto or manual, including
|
|
75
|
+
* the one triggered by an identity switch) has settled. Always available;
|
|
76
|
+
* defaults to `Promise.resolve()` if `autoInitialize: false`.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```typescript
|
|
80
|
+
* const suite = createECSuite({ adapters: { cart } });
|
|
81
|
+
* await suite.ready; // wait for initial fetches
|
|
82
|
+
* await suite.cart.addItem(...);
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
ready: Promise<void>;
|
|
66
86
|
/** Cart domain manager */
|
|
67
87
|
readonly cart: CartManager;
|
|
68
88
|
/** Wishlist domain manager */
|
|
@@ -94,8 +114,25 @@ export declare class ECSuite {
|
|
|
94
114
|
* ```
|
|
95
115
|
*/
|
|
96
116
|
initialize(domains?: InitializableDomainName[]): Promise<void>;
|
|
97
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Update context across all domains.
|
|
119
|
+
*
|
|
120
|
+
* If `customerId` transitions (including to/from undefined) and
|
|
121
|
+
* `autoResetOnIdentityChange` is enabled (default), this also resets
|
|
122
|
+
* all domains and re-initializes them — assigning the new in-flight
|
|
123
|
+
* promise to `ready`. Callers that need to wait for the new identity's
|
|
124
|
+
* data should `await suite.ready` afterwards (or use `switchIdentity`).
|
|
125
|
+
*/
|
|
98
126
|
setContext(context: DomainContext): void;
|
|
127
|
+
/**
|
|
128
|
+
* Atomically switch to a new identity: merge context, reset all domains,
|
|
129
|
+
* and re-initialize. Returns a promise that settles when the re-init
|
|
130
|
+
* completes. Use this instead of `setContext` when you need to await
|
|
131
|
+
* the identity switch (also updates `suite.ready`).
|
|
132
|
+
*
|
|
133
|
+
* Works even when `autoResetOnIdentityChange: false`.
|
|
134
|
+
*/
|
|
135
|
+
switchIdentity(context: DomainContext): Promise<void>;
|
|
99
136
|
/** Get the current context */
|
|
100
137
|
getContext(): DomainContext;
|
|
101
138
|
/** Subscribe to specific event type */
|
|
@@ -132,6 +169,17 @@ export declare class ECSuite {
|
|
|
132
169
|
}) => void): Unsubscriber;
|
|
133
170
|
/** Reset all domains to initial state */
|
|
134
171
|
reset(): void;
|
|
172
|
+
/**
|
|
173
|
+
* Tear down the suite: unsubscribe all listeners on the internal pubsub
|
|
174
|
+
* and clear the product cache. Use when the consumer (e.g., a SPA
|
|
175
|
+
* lifecycle hook) is done with the suite — long-lived apps that recreate
|
|
176
|
+
* suites otherwise leak subscribers across instances.
|
|
177
|
+
*
|
|
178
|
+
* Persisted storage is intentionally NOT cleared (cart/wishlist data
|
|
179
|
+
* should survive page reloads); call `reset()` first if you also want
|
|
180
|
+
* to wipe in-memory state.
|
|
181
|
+
*/
|
|
182
|
+
destroy(): void;
|
|
135
183
|
}
|
|
136
184
|
/**
|
|
137
185
|
* Factory function to create an ECSuite instance.
|
package/dist/suite.js
CHANGED
|
@@ -12,6 +12,15 @@ import { OrderManager } from "./domains/order.js";
|
|
|
12
12
|
import { CustomerManager } from "./domains/customer.js";
|
|
13
13
|
import { PaymentManager } from "./domains/payment.js";
|
|
14
14
|
import { ProductManager } from "./domains/product.js";
|
|
15
|
+
/** Combine multiple unsubscribers into one (also `Symbol.dispose`-compatible). */
|
|
16
|
+
function combineUnsubscribers(...unsubs) {
|
|
17
|
+
const fn = () => {
|
|
18
|
+
for (const u of unsubs)
|
|
19
|
+
u();
|
|
20
|
+
};
|
|
21
|
+
fn[Symbol.dispose] = fn;
|
|
22
|
+
return fn;
|
|
23
|
+
}
|
|
15
24
|
/**
|
|
16
25
|
* Main ECSuite class - orchestrates all e-commerce domain managers.
|
|
17
26
|
*
|
|
@@ -33,6 +42,21 @@ export class ECSuite {
|
|
|
33
42
|
#clog = createClog("ecsuite", { color: "auto" });
|
|
34
43
|
#pubsub;
|
|
35
44
|
#context;
|
|
45
|
+
#initDomains;
|
|
46
|
+
#autoResetOnIdentityChange;
|
|
47
|
+
/**
|
|
48
|
+
* Resolves when the most recent `initialize()` (auto or manual, including
|
|
49
|
+
* the one triggered by an identity switch) has settled. Always available;
|
|
50
|
+
* defaults to `Promise.resolve()` if `autoInitialize: false`.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```typescript
|
|
54
|
+
* const suite = createECSuite({ adapters: { cart } });
|
|
55
|
+
* await suite.ready; // wait for initial fetches
|
|
56
|
+
* await suite.cart.addItem(...);
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
ready = Promise.resolve();
|
|
36
60
|
/** Cart domain manager */
|
|
37
61
|
cart;
|
|
38
62
|
/** Wishlist domain manager */
|
|
@@ -52,6 +76,8 @@ export class ECSuite {
|
|
|
52
76
|
});
|
|
53
77
|
this.#pubsub = createPubSub();
|
|
54
78
|
this.#context = config.context ?? {};
|
|
79
|
+
this.#initDomains = config.initializeDomains;
|
|
80
|
+
this.#autoResetOnIdentityChange = config.autoResetOnIdentityChange !== false;
|
|
55
81
|
const storageType = config.storage?.type ?? "local";
|
|
56
82
|
// Initialize domain managers with shared pubsub
|
|
57
83
|
this.cart = new CartManager({
|
|
@@ -89,9 +115,10 @@ export class ECSuite {
|
|
|
89
115
|
pubsub: this.#pubsub,
|
|
90
116
|
cacheTtl: config.productCacheTtl,
|
|
91
117
|
});
|
|
92
|
-
// Auto-initialize if configured
|
|
118
|
+
// Auto-initialize if configured. `ready` exposes the in-flight promise
|
|
119
|
+
// so callers don't race the constructor.
|
|
93
120
|
if (config.autoInitialize !== false) {
|
|
94
|
-
this.initialize(
|
|
121
|
+
this.ready = this.initialize(this.#initDomains);
|
|
95
122
|
}
|
|
96
123
|
}
|
|
97
124
|
/** Initialize domains. When called without arguments, initializes all domains.
|
|
@@ -118,15 +145,55 @@ export class ECSuite {
|
|
|
118
145
|
"order",
|
|
119
146
|
"customer",
|
|
120
147
|
"payment",
|
|
148
|
+
"product",
|
|
121
149
|
];
|
|
122
|
-
const toInit = domains ?? all;
|
|
150
|
+
const toInit = domains ?? this.#initDomains ?? all;
|
|
151
|
+
// Remember the most recently initialized set so identity switches
|
|
152
|
+
// (and other re-inits) re-fetch the same domains.
|
|
153
|
+
this.#initDomains = toInit;
|
|
123
154
|
this.#clog.debug("initializing domains", toInit);
|
|
124
155
|
await Promise.all(toInit.map((name) => this[name].initialize()));
|
|
125
156
|
this.#clog.debug("domains initialized", toInit);
|
|
126
157
|
}
|
|
127
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* Update context across all domains.
|
|
160
|
+
*
|
|
161
|
+
* If `customerId` transitions (including to/from undefined) and
|
|
162
|
+
* `autoResetOnIdentityChange` is enabled (default), this also resets
|
|
163
|
+
* all domains and re-initializes them — assigning the new in-flight
|
|
164
|
+
* promise to `ready`. Callers that need to wait for the new identity's
|
|
165
|
+
* data should `await suite.ready` afterwards (or use `switchIdentity`).
|
|
166
|
+
*/
|
|
128
167
|
setContext(context) {
|
|
129
168
|
this.#clog.debug("setContext", context);
|
|
169
|
+
const prevCustomerId = this.#context.customerId;
|
|
170
|
+
this.#context = { ...this.#context, ...context };
|
|
171
|
+
this.cart.setContext(context);
|
|
172
|
+
this.wishlist.setContext(context);
|
|
173
|
+
this.order.setContext(context);
|
|
174
|
+
this.customer.setContext(context);
|
|
175
|
+
this.payment.setContext(context);
|
|
176
|
+
this.product.setContext(context);
|
|
177
|
+
const newCustomerId = this.#context.customerId;
|
|
178
|
+
if (this.#autoResetOnIdentityChange && prevCustomerId !== newCustomerId) {
|
|
179
|
+
this.#clog.debug("identity changed; resetting domains", {
|
|
180
|
+
from: prevCustomerId,
|
|
181
|
+
to: newCustomerId,
|
|
182
|
+
});
|
|
183
|
+
this.reset();
|
|
184
|
+
this.ready = this.initialize(this.#initDomains);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Atomically switch to a new identity: merge context, reset all domains,
|
|
189
|
+
* and re-initialize. Returns a promise that settles when the re-init
|
|
190
|
+
* completes. Use this instead of `setContext` when you need to await
|
|
191
|
+
* the identity switch (also updates `suite.ready`).
|
|
192
|
+
*
|
|
193
|
+
* Works even when `autoResetOnIdentityChange: false`.
|
|
194
|
+
*/
|
|
195
|
+
async switchIdentity(context) {
|
|
196
|
+
this.#clog.debug("switchIdentity", context);
|
|
130
197
|
this.#context = { ...this.#context, ...context };
|
|
131
198
|
this.cart.setContext(context);
|
|
132
199
|
this.wishlist.setContext(context);
|
|
@@ -134,6 +201,9 @@ export class ECSuite {
|
|
|
134
201
|
this.customer.setContext(context);
|
|
135
202
|
this.payment.setContext(context);
|
|
136
203
|
this.product.setContext(context);
|
|
204
|
+
this.reset();
|
|
205
|
+
this.ready = this.initialize(this.#initDomains);
|
|
206
|
+
await this.ready;
|
|
137
207
|
}
|
|
138
208
|
/** Get the current context */
|
|
139
209
|
getContext() {
|
|
@@ -186,10 +256,7 @@ export class ECSuite {
|
|
|
186
256
|
error: event.error,
|
|
187
257
|
});
|
|
188
258
|
});
|
|
189
|
-
return ()
|
|
190
|
-
unsub1();
|
|
191
|
-
unsub2();
|
|
192
|
-
};
|
|
259
|
+
return combineUnsubscribers(unsub1, unsub2);
|
|
193
260
|
}
|
|
194
261
|
/** Reset all domains to initial state */
|
|
195
262
|
reset() {
|
|
@@ -201,6 +268,21 @@ export class ECSuite {
|
|
|
201
268
|
this.payment.reset();
|
|
202
269
|
this.product.clearCache();
|
|
203
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Tear down the suite: unsubscribe all listeners on the internal pubsub
|
|
273
|
+
* and clear the product cache. Use when the consumer (e.g., a SPA
|
|
274
|
+
* lifecycle hook) is done with the suite — long-lived apps that recreate
|
|
275
|
+
* suites otherwise leak subscribers across instances.
|
|
276
|
+
*
|
|
277
|
+
* Persisted storage is intentionally NOT cleared (cart/wishlist data
|
|
278
|
+
* should survive page reloads); call `reset()` first if you also want
|
|
279
|
+
* to wipe in-memory state.
|
|
280
|
+
*/
|
|
281
|
+
destroy() {
|
|
282
|
+
this.#clog.debug("destroy");
|
|
283
|
+
this.product.clearCache();
|
|
284
|
+
this.#pubsub.unsubscribeAll();
|
|
285
|
+
}
|
|
204
286
|
}
|
|
205
287
|
/**
|
|
206
288
|
* Factory function to create an ECSuite instance.
|
package/dist/types/adapter.d.ts
CHANGED
|
@@ -18,8 +18,6 @@ export interface CartAdapter {
|
|
|
18
18
|
removeItem(productId: UUID, ctx: DomainContext): Promise<CartData>;
|
|
19
19
|
/** Clear all items */
|
|
20
20
|
clear(ctx: DomainContext): Promise<CartData>;
|
|
21
|
-
/** Sync full cart state (for optimistic update reconciliation) */
|
|
22
|
-
sync(cart: CartData, ctx: DomainContext): Promise<CartData>;
|
|
23
21
|
}
|
|
24
22
|
/** Wishlist adapter interface */
|
|
25
23
|
export interface WishlistAdapter {
|
|
@@ -31,18 +29,23 @@ export interface WishlistAdapter {
|
|
|
31
29
|
removeItem(productId: UUID, ctx: DomainContext): Promise<WishlistData>;
|
|
32
30
|
/** Clear all items */
|
|
33
31
|
clear(ctx: DomainContext): Promise<WishlistData>;
|
|
34
|
-
/** Sync full wishlist state */
|
|
35
|
-
sync(wishlist: WishlistData, ctx: DomainContext): Promise<WishlistData>;
|
|
36
32
|
}
|
|
37
33
|
/** Order create payload (status is set by server) */
|
|
38
34
|
export type OrderCreatePayload = Omit<OrderData, "status">;
|
|
39
35
|
export type { OrderCreateResult } from "@marianmeres/collection-types";
|
|
40
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Order adapter interface (read + create).
|
|
38
|
+
*
|
|
39
|
+
* All read/create methods return `OrderCreateResult` (`{ model_id, data }`)
|
|
40
|
+
* so the manager can identify orders by their server-assigned `model_id`.
|
|
41
|
+
* Returning bare `OrderData` previously made dedup/update impossible because
|
|
42
|
+
* `OrderData` has no `model_id` field — only an open index signature.
|
|
43
|
+
*/
|
|
41
44
|
export interface OrderAdapter {
|
|
42
45
|
/** Fetch all orders for customer */
|
|
43
|
-
fetchAll(ctx: DomainContext): Promise<
|
|
46
|
+
fetchAll(ctx: DomainContext): Promise<OrderCreateResult[]>;
|
|
44
47
|
/** Fetch single order by ID */
|
|
45
|
-
fetchOne(orderId: UUID, ctx: DomainContext): Promise<
|
|
48
|
+
fetchOne(orderId: UUID, ctx: DomainContext): Promise<OrderCreateResult>;
|
|
46
49
|
/** Create new order — returns data + model_id */
|
|
47
50
|
create(order: OrderCreatePayload, ctx: DomainContext): Promise<OrderCreateResult>;
|
|
48
51
|
}
|
package/dist/types/events.d.ts
CHANGED
|
@@ -8,8 +8,12 @@ import type { UUID } from "@marianmeres/collection-types";
|
|
|
8
8
|
import type { DomainError, DomainState } from "./state.js";
|
|
9
9
|
/** Domain identifiers */
|
|
10
10
|
export type DomainName = "cart" | "wishlist" | "order" | "customer" | "payment" | "product";
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Domain names that support `initialize()`. As of the unified ProductManager,
|
|
13
|
+
* every domain implements initialize() — for product it is a no-op that
|
|
14
|
+
* transitions to "ready" so consumers can rely on the same readiness contract.
|
|
15
|
+
*/
|
|
16
|
+
export type InitializableDomainName = DomainName;
|
|
13
17
|
/** Event types emitted by the suite */
|
|
14
18
|
export type ECSuiteEventType = "domain:state:changed" | "domain:error" | "domain:synced" | "cart:item:added" | "cart:item:updated" | "cart:item:removed" | "cart:cleared" | "wishlist:item:added" | "wishlist:item:removed" | "wishlist:cleared" | "order:created" | "order:fetched" | "customer:updated" | "customer:fetched" | "payment:fetched" | "payment:initiated" | "payment:captured" | "product:fetched";
|
|
15
19
|
/** Base event data */
|