@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
@@ -141,7 +141,15 @@ export class PaymentManager extends BaseDomainManager {
141
141
  async initiate(orderId, config) {
142
142
  this.clog.debug("initiate", { orderId });
143
143
  if (!this.adapter?.initiate) {
144
- return null;
144
+ // Optional adapter method missing — surface as a typed error so
145
+ // callers don't conflate "not configured" with "server rejected".
146
+ const error = {
147
+ code: "NOT_IMPLEMENTED",
148
+ message: "PaymentAdapter.initiate is not implemented",
149
+ operation: "initiate",
150
+ };
151
+ this.setError(error);
152
+ throw new Error(error.message);
145
153
  }
146
154
  this.setState("syncing");
147
155
  try {
@@ -178,7 +186,13 @@ export class PaymentManager extends BaseDomainManager {
178
186
  async capture(paymentId) {
179
187
  this.clog.debug("capture", { paymentId });
180
188
  if (!this.adapter?.capture) {
181
- return null;
189
+ const error = {
190
+ code: "NOT_IMPLEMENTED",
191
+ message: "PaymentAdapter.capture is not implemented",
192
+ operation: "capture",
193
+ };
194
+ this.setError(error);
195
+ throw new Error(error.message);
182
196
  }
183
197
  this.setState("syncing");
184
198
  try {
@@ -3,36 +3,37 @@
3
3
  *
4
4
  * Product domain manager with in-memory caching.
5
5
  *
6
- * Read-only domain for fetching product data with caching support.
7
- * Does not use state machine - just a simple cache layer.
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 { type PubSub } from "@marianmeres/pubsub";
10
13
  import type { ProductData, UUID } from "@marianmeres/collection-types";
11
14
  import type { ProductAdapter } from "../types/adapter.js";
12
- import type { DomainContext } from "../types/state.js";
15
+ import { BaseDomainManager, type BaseDomainOptions } from "./base.js";
13
16
  /** Options for ProductManager */
14
- export interface ProductManagerOptions {
17
+ export interface ProductManagerOptions extends BaseDomainOptions {
15
18
  /** Product adapter for server communication */
16
19
  adapter?: ProductAdapter;
17
- /** Initial context (customerId, sessionId) */
18
- context?: DomainContext;
19
- /** Shared pubsub instance for events */
20
- pubsub?: PubSub;
21
20
  /** Cache TTL in milliseconds (default: 5 minutes) */
22
21
  cacheTtl?: number;
23
22
  }
24
23
  /**
25
- * Product manager with in-memory caching.
24
+ * Product manager with in-memory TTL caching.
26
25
  *
27
- * Unlike other domain managers, ProductManager uses a simple cache layer
28
- * instead of a full state machine. Products are fetched on-demand and cached
29
- * with a configurable TTL.
26
+ * Extends `BaseDomainManager` for unified observability (Svelte-compatible
27
+ * `subscribe`, `domain:state:changed`, `domain:error` events) but skips the
28
+ * per-product state machine — fetching a single product never transitions
29
+ * the whole domain into "syncing" because that would be misleading.
30
30
  *
31
31
  * Features:
32
- * - In-memory cache with TTL expiration
32
+ * - In-memory cache with per-entry TTL expiration
33
33
  * - Single and batch product fetching
34
+ * - In-flight request dedup (cache stampede prevention)
34
35
  * - Prefetching support for UI optimization
35
- * - Event emission on fetch
36
+ * - `domain:error` event emission on adapter failures
36
37
  *
37
38
  * @example
38
39
  * ```typescript
@@ -40,45 +41,30 @@ export interface ProductManagerOptions {
40
41
  * adapter: myProductAdapter,
41
42
  * cacheTtl: 10 * 60 * 1000, // 10 minutes
42
43
  * });
44
+ * await products.initialize();
43
45
  *
44
46
  * const product = await products.getById("prod-123");
45
47
  * const many = await products.getByIds(["prod-1", "prod-2"]);
46
48
  * ```
47
49
  */
48
- export declare class ProductManager {
50
+ export declare class ProductManager extends BaseDomainManager<null, ProductAdapter> {
49
51
  #private;
50
52
  constructor(options?: ProductManagerOptions);
51
53
  /**
52
- * Set the product adapter for server communication.
53
- *
54
- * @param adapter - The ProductAdapter implementation
55
- */
56
- setAdapter(adapter: ProductAdapter): void;
57
- /**
58
- * Get the current adapter.
59
- *
60
- * @returns The adapter or null if not set
61
- */
62
- getAdapter(): ProductAdapter | null;
63
- /**
64
- * Update context (customerId, sessionId).
65
- *
66
- * @param context - Context to merge with existing context
67
- */
68
- setContext(context: DomainContext): void;
69
- /**
70
- * Get the current context.
71
- *
72
- * @returns Copy of the current context
54
+ * Initialize. Lazy domain there's nothing to fetch eagerly. Just
55
+ * transitions to "ready" so consumers can rely on the same readiness
56
+ * contract as other domains.
73
57
  */
74
- getContext(): DomainContext;
58
+ initialize(): Promise<void>;
75
59
  /**
76
60
  * Get a single product by ID.
77
- * Returns from cache if valid, otherwise fetches from server.
61
+ * Returns from cache if valid, otherwise fetches from server. Concurrent
62
+ * callers for the same id share a single in-flight request.
78
63
  *
79
64
  * @param productId - The product ID to fetch
80
- * @returns The product data or null if not found/error
65
+ * @returns The product data or null if not found / no adapter
81
66
  * @emits product:fetched - On successful server fetch
67
+ * @emits domain:error - On adapter failure
82
68
  */
83
69
  getById(productId: UUID): Promise<ProductData | null>;
84
70
  /**
@@ -88,6 +74,7 @@ export declare class ProductManager {
88
74
  * @param productIds - Array of product IDs to fetch
89
75
  * @returns Map of productId to ProductData
90
76
  * @emits product:fetched - For each product fetched from server
77
+ * @emits domain:error - On adapter failure
91
78
  */
92
79
  getByIds(productIds: UUID[]): Promise<Map<UUID, ProductData>>;
93
80
  /**
@@ -116,4 +103,6 @@ export declare class ProductManager {
116
103
  * @returns Number of cached products (includes expired entries)
117
104
  */
118
105
  getCacheSize(): number;
106
+ /** Reset clears the cache too (overrides base reset). */
107
+ reset(): void;
119
108
  }
@@ -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 support.
7
- * Does not use state machine - just a simple cache layer.
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 { createClog } from "@marianmeres/clog";
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
- * Unlike other domain managers, ProductManager uses a simple cache layer
15
- * instead of a full state machine. Products are fetched on-demand and cached
16
- * with a configurable TTL.
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
- * - Event emission on fetch
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
- this.#adapter = options.adapter ?? null;
44
- this.#context = options.context ?? {};
45
- this.#pubsub = options.pubsub ?? createPubSub();
46
- this.#cacheTtl = options.cacheTtl ?? 5 * 60 * 1000; // 5 minutes default
47
- this.#clog.debug("initialized", { cacheTtl: this.#cacheTtl });
48
- }
49
- /**
50
- * Set the product adapter for server communication.
51
- *
52
- * @param adapter - The ProductAdapter implementation
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
- * Get the current context.
76
- *
77
- * @returns Copy of the current context
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
- getContext() {
80
- return { ...this.#context };
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/error
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
- // Fetch from server
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
- // Fetch missing from server
135
- if (missingIds.length > 0 && this.#adapter) {
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.#adapter.fetchMany(missingIds, this.#context);
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.#clog.error("getByIds failed", { missingIds, error: e });
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.#clog.debug("prefetch", { count: missingIds.length });
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.#clog.debug("cache cleared for product", { productId });
174
+ this.clog.debug("cache cleared for product", { productId });
181
175
  }
182
176
  else {
183
177
  this.#cache.clear();
184
- this.#clog.debug("cache cleared entirely");
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.#pubsub.publish(event.type, event);
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
  }
@@ -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
- const current = this.store.get().data ?? { items: [] };
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
- /** Update context across all domains */
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.