@marianmeres/ecsuite 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@
2
2
  * Mock product adapter for testing.
3
3
  */
4
4
  import type { ProductData, UUID } from "@marianmeres/collection-types";
5
+ import { HTTP_ERROR } from "@marianmeres/http-utils";
5
6
  import type { ProductAdapter } from "../../types/adapter.js";
6
7
  /** Product with model_id for internal storage */
7
8
  interface StoredProduct extends ProductData {
@@ -16,7 +17,8 @@ export interface MockProductAdapterOptions {
16
17
  /** Force errors for testing */
17
18
  forceError?: {
18
19
  operation?: "fetchOne" | "fetchMany";
19
- code?: string;
20
+ /** HTTP error class name from `HTTP_ERROR` (default: "BadRequest") */
21
+ code?: keyof typeof HTTP_ERROR;
20
22
  message?: string;
21
23
  };
22
24
  }
@@ -15,7 +15,9 @@ export function createMockProductAdapter(options = {}) {
15
15
  const wait = () => new Promise((r) => setTimeout(r, delay));
16
16
  const maybeThrow = (operation) => {
17
17
  if (options.forceError?.operation === operation) {
18
- throw new HTTP_ERROR.BadRequest(options.forceError.message ?? `Mock error for ${operation}`);
18
+ const code = options.forceError.code ?? "BadRequest";
19
+ const Ctor = HTTP_ERROR[code] ?? HTTP_ERROR.BadRequest;
20
+ throw new Ctor(options.forceError.message ?? `Mock error for ${operation}`);
19
21
  }
20
22
  };
21
23
  return {
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Mock wishlist adapter for testing.
3
3
  */
4
+ import { HTTP_ERROR } from "@marianmeres/http-utils";
4
5
  import type { WishlistAdapter } from "../../types/adapter.js";
5
6
  import type { WishlistData } from "../../types/state.js";
6
7
  /** Mock wishlist adapter options */
@@ -11,8 +12,9 @@ export interface MockWishlistAdapterOptions {
11
12
  delay?: number;
12
13
  /** Force errors for testing */
13
14
  forceError?: {
14
- operation?: "fetch" | "addItem" | "removeItem" | "clear" | "sync";
15
- code?: string;
15
+ operation?: "fetch" | "addItem" | "removeItem" | "clear";
16
+ /** HTTP error class name from `HTTP_ERROR` (default: "BadRequest") */
17
+ code?: keyof typeof HTTP_ERROR;
16
18
  message?: string;
17
19
  };
18
20
  }
@@ -11,7 +11,9 @@ export function createMockWishlistAdapter(options = {}) {
11
11
  const wait = () => new Promise((r) => setTimeout(r, delay));
12
12
  const maybeThrow = (operation) => {
13
13
  if (options.forceError?.operation === operation) {
14
- throw new HTTP_ERROR.BadRequest(options.forceError.message ?? `Mock error for ${operation}`);
14
+ const code = options.forceError.code ?? "BadRequest";
15
+ const Ctor = HTTP_ERROR[code] ?? HTTP_ERROR.BadRequest;
16
+ throw new Ctor(options.forceError.message ?? `Mock error for ${operation}`);
15
17
  }
16
18
  };
17
19
  return {
@@ -44,11 +46,5 @@ export function createMockWishlistAdapter(options = {}) {
44
46
  wishlist = { items: [] };
45
47
  return structuredClone(wishlist);
46
48
  },
47
- async sync(newWishlist, _ctx) {
48
- await wait();
49
- maybeThrow("sync");
50
- wishlist = structuredClone(newWishlist);
51
- return structuredClone(wishlist);
52
- },
53
49
  };
54
50
  }
@@ -36,6 +36,7 @@ export interface BaseDomainOptions {
36
36
  * @typeParam TAdapter - The adapter interface type for server communication
37
37
  */
38
38
  export declare abstract class BaseDomainManager<TData, TAdapter> {
39
+ #private;
39
40
  protected readonly store: StoreLike<DomainStateWrapper<TData>>;
40
41
  protected readonly pubsub: PubSub;
41
42
  protected readonly domainName: DomainName;
@@ -68,12 +69,18 @@ export declare abstract class BaseDomainManager<TData, TAdapter> {
68
69
  /**
69
70
  * Execute an async operation with optimistic update pattern.
70
71
  *
71
- * 1. Captures current state for potential rollback
72
- * 2. Applies optimistic update immediately
73
- * 3. Sets state to "syncing"
74
- * 4. Awaits server sync
75
- * 5. On success: marks synced, calls success callback
76
- * 6. On error: rolls back to previous state, sets error state
72
+ * 1. Waits for any prior in-flight mutation on this domain to settle
73
+ * (per-domain serialization see `#mutationQueue`)
74
+ * 2. Captures current state for potential rollback
75
+ * 3. Applies optimistic update immediately
76
+ * 4. Sets state to "syncing"
77
+ * 5. Awaits server sync
78
+ * 6. On success: marks synced, calls success callback
79
+ * 7. On error: rolls back to previous state, sets error state
80
+ *
81
+ * Concurrent callers see a deterministic order (FIFO) and a correct
82
+ * `previousData` snapshot per operation. Failures do not poison the
83
+ * queue — subsequent operations continue.
77
84
  */
78
85
  protected withOptimisticUpdate<T>(operation: string, optimisticUpdate: () => void, serverSync: () => Promise<T>, onSuccess?: (result: T) => void, onError?: (error: DomainError) => void): Promise<void>;
79
86
  /** Initialize the domain (fetch from server) */
@@ -27,6 +27,13 @@ export class BaseDomainManager {
27
27
  clog;
28
28
  adapter = null;
29
29
  context = {};
30
+ /**
31
+ * Serializes per-domain mutations so concurrent callers don't race on the
32
+ * `previousData` snapshot used for rollback. Each `withOptimisticUpdate`
33
+ * waits for the prior one to settle. Reads (`get()`, `subscribe`) are
34
+ * never blocked by the queue.
35
+ */
36
+ #mutationQueue = Promise.resolve();
30
37
  constructor(domainName, options = {}) {
31
38
  this.domainName = domainName;
32
39
  this.clog = createClog(`ecsuite:${domainName}`, { color: "auto" });
@@ -141,15 +148,28 @@ export class BaseDomainManager {
141
148
  /**
142
149
  * Execute an async operation with optimistic update pattern.
143
150
  *
144
- * 1. Captures current state for potential rollback
145
- * 2. Applies optimistic update immediately
146
- * 3. Sets state to "syncing"
147
- * 4. Awaits server sync
148
- * 5. On success: marks synced, calls success callback
149
- * 6. On error: rolls back to previous state, sets error state
151
+ * 1. Waits for any prior in-flight mutation on this domain to settle
152
+ * (per-domain serialization see `#mutationQueue`)
153
+ * 2. Captures current state for potential rollback
154
+ * 3. Applies optimistic update immediately
155
+ * 4. Sets state to "syncing"
156
+ * 5. Awaits server sync
157
+ * 6. On success: marks synced, calls success callback
158
+ * 7. On error: rolls back to previous state, sets error state
159
+ *
160
+ * Concurrent callers see a deterministic order (FIFO) and a correct
161
+ * `previousData` snapshot per operation. Failures do not poison the
162
+ * queue — subsequent operations continue.
150
163
  */
151
- async withOptimisticUpdate(operation, optimisticUpdate, serverSync, onSuccess, onError) {
152
- // Capture current state for rollback
164
+ withOptimisticUpdate(operation, optimisticUpdate, serverSync, onSuccess, onError) {
165
+ const next = this.#mutationQueue.then(() => this.#runOptimistic(operation, optimisticUpdate, serverSync, onSuccess, onError));
166
+ // Swallow rejection on the chain so a failing op doesn't poison
167
+ // downstream awaiters. The original `next` still rejects to its caller.
168
+ this.#mutationQueue = next.catch(() => { });
169
+ return next;
170
+ }
171
+ async #runOptimistic(operation, optimisticUpdate, serverSync, onSuccess, onError) {
172
+ // Capture current state for rollback (after prior queued ops settled)
153
173
  const previousData = this.store.get().data;
154
174
  // Apply optimistic update immediately
155
175
  optimisticUpdate();
@@ -160,10 +180,9 @@ export class BaseDomainManager {
160
180
  onSuccess?.(result);
161
181
  }
162
182
  catch (e) {
163
- // Rollback on error
164
- if (previousData !== null) {
165
- this.setData(previousData, false);
166
- }
183
+ // Always rollback to previousData (including null) so we never
184
+ // leave optimistic mutations stranded in an "error" state.
185
+ this.store.update((s) => ({ ...s, data: previousData }));
167
186
  const error = {
168
187
  code: "SYNC_FAILED",
169
188
  message: e instanceof Error ? e.message : "Unknown error",
@@ -5,6 +5,21 @@
5
5
  * Manages shopping cart state with automatic server synchronization.
6
6
  */
7
7
  import { BaseDomainManager } from "./base.js";
8
+ /**
9
+ * Reject NaN, Infinity, and non-integer quantities at the entry point so
10
+ * malformed UI input never reaches the optimistic store or the server.
11
+ */
12
+ function assertValidQuantity(quantity, op, { allowZero = false } = {}) {
13
+ if (typeof quantity !== "number" || !Number.isFinite(quantity)) {
14
+ throw new TypeError(`Cart.${op}: quantity must be a finite number, got ${quantity}`);
15
+ }
16
+ if (!Number.isInteger(quantity)) {
17
+ throw new TypeError(`Cart.${op}: quantity must be an integer, got ${quantity}`);
18
+ }
19
+ if (quantity < 0 || (!allowZero && quantity === 0)) {
20
+ throw new RangeError(`Cart.${op}: quantity must be ${allowZero ? "non-negative" : "positive"}, got ${quantity}`);
21
+ }
22
+ }
8
23
  /**
9
24
  * Cart domain manager with optimistic updates and localStorage persistence.
10
25
  *
@@ -81,6 +96,7 @@ export class CartManager extends BaseDomainManager {
81
96
  productId: item.product_id,
82
97
  quantity: item.quantity,
83
98
  });
99
+ assertValidQuantity(item.quantity, "addItem");
84
100
  await this.withOptimisticUpdate("addItem", () => {
85
101
  // Optimistic: update local state immediately
86
102
  const current = this.store.get().data ?? { items: [] };
@@ -129,6 +145,7 @@ export class CartManager extends BaseDomainManager {
129
145
  */
130
146
  async updateItemQuantity(productId, quantity) {
131
147
  this.clog.debug("updateItemQuantity", { productId, quantity });
148
+ assertValidQuantity(quantity, "updateItemQuantity", { allowZero: true });
132
149
  if (quantity <= 0) {
133
150
  return this.removeItem(productId);
134
151
  }
@@ -51,9 +51,13 @@ export class CustomerManager extends BaseDomainManager {
51
51
  data = await this.adapter.fetchBySession(this.context);
52
52
  }
53
53
  else {
54
- // No customerId and no fetchBySession — call fetch()
55
- // for backward compatibility
56
- data = await this.adapter.fetch(this.context);
54
+ // No customerId and no fetchBySession — there is nothing safe
55
+ // to fetch. Mark ready with no data and warn so config bugs
56
+ // don't manifest as adapter-rejection noise.
57
+ this.clog.warn("customer fetch skipped: no customerId in context and " +
58
+ "adapter has no fetchBySession()");
59
+ this.setState("ready");
60
+ return false;
57
61
  }
58
62
  if (data) {
59
63
  this.setData(data);
@@ -115,10 +119,20 @@ export class CustomerManager extends BaseDomainManager {
115
119
  async update(data) {
116
120
  this.clog.debug("update");
117
121
  if (!this.adapter) {
118
- return;
122
+ const error = {
123
+ code: "NOT_IMPLEMENTED",
124
+ message: "CustomerAdapter is not configured",
125
+ operation: "update",
126
+ };
127
+ this.setError(error);
128
+ throw new Error(error.message);
119
129
  }
120
130
  const current = this.store.get().data;
121
131
  if (!current) {
132
+ // No data loaded yet — refuse to optimistically merge into nothing.
133
+ // Treat as a recoverable warning rather than a hard error so the
134
+ // caller can `await refresh()` first and retry.
135
+ this.clog.warn("update called before customer data was loaded — no-op");
122
136
  return;
123
137
  }
124
138
  await this.withOptimisticUpdate("update", () => {
@@ -7,9 +7,13 @@
7
7
  import type { OrderData, UUID } from "@marianmeres/collection-types";
8
8
  import type { OrderAdapter, OrderCreatePayload, OrderCreateResult } from "../types/adapter.js";
9
9
  import { BaseDomainManager, type BaseDomainOptions } from "./base.js";
10
- /** Order list data (array of orders) */
10
+ /**
11
+ * Order list data — array of `{ model_id, data }` envelopes so each order is
12
+ * uniquely identifiable by its server-assigned `model_id`. (Bare `OrderData`
13
+ * has no id field, only an open index signature.)
14
+ */
11
15
  export interface OrderListData {
12
- orders: OrderData[];
16
+ orders: OrderCreateResult[];
13
17
  }
14
18
  export interface OrderManagerOptions extends BaseDomainOptions {
15
19
  /** Order adapter for server communication */
@@ -22,15 +26,15 @@ export interface OrderManagerOptions extends BaseDomainOptions {
22
26
  * - Server-side data source (no local persistence)
23
27
  * - Fetch all orders or individual orders
24
28
  * - Create new orders
25
- * - Order list management
29
+ * - Order list management keyed by `model_id`
26
30
  *
27
31
  * @example
28
32
  * ```typescript
29
33
  * const orders = new OrderManager({ adapter: myOrderAdapter });
30
34
  * await orders.initialize();
31
35
  *
32
- * const newOrder = await orders.create({ items: [...], total: 100 });
33
- * console.log(orders.getOrderCount());
36
+ * const result = await orders.create({ items: [...], total: 100 });
37
+ * console.log(result.model_id, result.data);
34
38
  * ```
35
39
  */
36
40
  export declare class OrderManager extends BaseDomainManager<OrderListData, OrderAdapter> {
@@ -46,18 +50,18 @@ export declare class OrderManager extends BaseDomainManager<OrderListData, Order
46
50
  fetchAll(): Promise<void>;
47
51
  /**
48
52
  * Fetch a single order by ID from the server.
49
- * Updates or adds the order to the local list.
53
+ * Updates or adds the order to the local list, keyed by `model_id`.
50
54
  *
51
55
  * @param orderId - The order ID to fetch
52
- * @returns The fetched order or null on error
56
+ * @returns The fetched order envelope or null on error
53
57
  */
54
- fetchOne(orderId: UUID): Promise<OrderData | null>;
58
+ fetchOne(orderId: UUID): Promise<OrderCreateResult | null>;
55
59
  /**
56
60
  * Create a new order.
57
61
  * The order status is assigned by the server.
58
62
  *
59
63
  * @param orderData - The order data (without status)
60
- * @returns The created order result (with model_id) or null on error
64
+ * @returns The created order envelope (with model_id) or null on error
61
65
  * @emits order:created - On successful creation
62
66
  */
63
67
  create(orderData: OrderCreatePayload): Promise<OrderCreateResult | null>;
@@ -68,16 +72,30 @@ export declare class OrderManager extends BaseDomainManager<OrderListData, Order
68
72
  */
69
73
  getOrderCount(): number;
70
74
  /**
71
- * Get all orders.
75
+ * Get all order envelopes (`{ model_id, data }`).
76
+ *
77
+ * @returns Array of order envelopes
78
+ */
79
+ getOrders(): OrderCreateResult[];
80
+ /**
81
+ * Get an order envelope by its `model_id`.
82
+ *
83
+ * @param modelId - The order's server-assigned model id
84
+ * @returns The order envelope or undefined if not found
85
+ */
86
+ getOrderById(modelId: UUID): OrderCreateResult | undefined;
87
+ /**
88
+ * Get the bare `OrderData` for an order by its `model_id`.
72
89
  *
73
- * @returns Array of orders
90
+ * @param modelId - The order's server-assigned model id
91
+ * @returns The order data or undefined if not found
74
92
  */
75
- getOrders(): OrderData[];
93
+ getOrderDataById(modelId: UUID): OrderData | undefined;
76
94
  /**
77
- * Get an order by its index in the list.
95
+ * Get an order envelope by its index in the list.
78
96
  *
79
97
  * @param index - The index in the orders array
80
- * @returns The order or undefined if index is out of bounds
98
+ * @returns The order envelope or undefined if index is out of bounds
81
99
  */
82
- getOrderByIndex(index: number): OrderData | undefined;
100
+ getOrderByIndex(index: number): OrderCreateResult | undefined;
83
101
  }
@@ -13,15 +13,15 @@ import { BaseDomainManager } from "./base.js";
13
13
  * - Server-side data source (no local persistence)
14
14
  * - Fetch all orders or individual orders
15
15
  * - Create new orders
16
- * - Order list management
16
+ * - Order list management keyed by `model_id`
17
17
  *
18
18
  * @example
19
19
  * ```typescript
20
20
  * const orders = new OrderManager({ adapter: myOrderAdapter });
21
21
  * await orders.initialize();
22
22
  *
23
- * const newOrder = await orders.create({ items: [...], total: 100 });
24
- * console.log(orders.getOrderCount());
23
+ * const result = await orders.create({ items: [...], total: 100 });
24
+ * console.log(result.model_id, result.data);
25
25
  * ```
26
26
  */
27
27
  export class OrderManager extends BaseDomainManager {
@@ -94,10 +94,10 @@ export class OrderManager extends BaseDomainManager {
94
94
  }
95
95
  /**
96
96
  * Fetch a single order by ID from the server.
97
- * Updates or adds the order to the local list.
97
+ * Updates or adds the order to the local list, keyed by `model_id`.
98
98
  *
99
99
  * @param orderId - The order ID to fetch
100
- * @returns The fetched order or null on error
100
+ * @returns The fetched order envelope or null on error
101
101
  */
102
102
  async fetchOne(orderId) {
103
103
  this.clog.debug("fetchOne", { orderId });
@@ -106,23 +106,20 @@ export class OrderManager extends BaseDomainManager {
106
106
  }
107
107
  this.setState("syncing");
108
108
  try {
109
- const data = await this.adapter.fetchOne(orderId, this.context);
110
- // Update the order in our local list
111
- // TODO: fetchAll/fetchOne return OrderData which lacks model_id;
112
- // matching by model_id relies on the index signature on OrderData
109
+ const result = await this.adapter.fetchOne(orderId, this.context);
113
110
  const current = this.store.get().data ?? { orders: [] };
114
- const existingIndex = current.orders.findIndex((o) => o.model_id === orderId);
111
+ const existingIndex = current.orders.findIndex((o) => o.model_id === result.model_id);
115
112
  let orders;
116
113
  if (existingIndex >= 0) {
117
114
  orders = [...current.orders];
118
- orders[existingIndex] = data;
115
+ orders[existingIndex] = result;
119
116
  }
120
117
  else {
121
- orders = [...current.orders, data];
118
+ orders = [...current.orders, result];
122
119
  }
123
120
  this.setData({ orders });
124
121
  this.markSynced();
125
- return data;
122
+ return result;
126
123
  }
127
124
  catch (e) {
128
125
  const isNotFound = e instanceof HTTP_ERROR.NotFound;
@@ -140,7 +137,7 @@ export class OrderManager extends BaseDomainManager {
140
137
  * The order status is assigned by the server.
141
138
  *
142
139
  * @param orderData - The order data (without status)
143
- * @returns The created order result (with model_id) or null on error
140
+ * @returns The created order envelope (with model_id) or null on error
144
141
  * @emits order:created - On successful creation
145
142
  */
146
143
  async create(orderData) {
@@ -151,10 +148,9 @@ export class OrderManager extends BaseDomainManager {
151
148
  this.setState("syncing");
152
149
  try {
153
150
  const result = await this.adapter.create(orderData, this.context);
154
- // Add the new order to our local list
155
151
  const current = this.store.get().data ?? { orders: [] };
156
152
  this.setData({
157
- orders: [...current.orders, result.data],
153
+ orders: [...current.orders, result],
158
154
  });
159
155
  this.markSynced();
160
156
  this.emit({
@@ -184,18 +180,36 @@ export class OrderManager extends BaseDomainManager {
184
180
  return this.store.get().data?.orders.length ?? 0;
185
181
  }
186
182
  /**
187
- * Get all orders.
183
+ * Get all order envelopes (`{ model_id, data }`).
188
184
  *
189
- * @returns Array of orders
185
+ * @returns Array of order envelopes
190
186
  */
191
187
  getOrders() {
192
188
  return this.store.get().data?.orders ?? [];
193
189
  }
194
190
  /**
195
- * Get an order by its index in the list.
191
+ * Get an order envelope by its `model_id`.
192
+ *
193
+ * @param modelId - The order's server-assigned model id
194
+ * @returns The order envelope or undefined if not found
195
+ */
196
+ getOrderById(modelId) {
197
+ return this.store.get().data?.orders.find((o) => o.model_id === modelId);
198
+ }
199
+ /**
200
+ * Get the bare `OrderData` for an order by its `model_id`.
201
+ *
202
+ * @param modelId - The order's server-assigned model id
203
+ * @returns The order data or undefined if not found
204
+ */
205
+ getOrderDataById(modelId) {
206
+ return this.getOrderById(modelId)?.data;
207
+ }
208
+ /**
209
+ * Get an order envelope by its index in the list.
196
210
  *
197
211
  * @param index - The index in the orders array
198
- * @returns The order or undefined if index is out of bounds
212
+ * @returns The order envelope or undefined if index is out of bounds
199
213
  */
200
214
  getOrderByIndex(index) {
201
215
  return this.store.get().data?.orders[index];
@@ -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
  }