@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
@@ -5,17 +5,19 @@ import { HTTP_ERROR } from "@marianmeres/http-utils";
5
5
  /** Create a mock order adapter for testing */
6
6
  export function createMockOrderAdapter(options = {}) {
7
7
  const delay = options.delay ?? 50;
8
- let orders = options.initialData
8
+ const orders = options.initialData
9
9
  ? options.initialData.map((o, i) => ({
10
- ...structuredClone(o),
11
10
  model_id: `order-${i + 1}`,
11
+ data: structuredClone(o),
12
12
  }))
13
13
  : [];
14
14
  let orderIdCounter = orders.length;
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 {
@@ -41,8 +43,8 @@ export function createMockOrderAdapter(options = {}) {
41
43
  ...structuredClone(orderData),
42
44
  status: "pending",
43
45
  };
44
- orders.push({ ...data, model_id });
45
- return { model_id, data: structuredClone(data) };
46
+ orders.push({ model_id, data: structuredClone(data) });
47
+ return { model_id, data };
46
48
  },
47
49
  };
48
50
  }
@@ -2,6 +2,7 @@
2
2
  * Mock payment adapter for testing.
3
3
  */
4
4
  import type { PaymentData, UUID } from "@marianmeres/collection-types";
5
+ import { HTTP_ERROR } from "@marianmeres/http-utils";
5
6
  import type { PaymentAdapter } from "../../types/adapter.js";
6
7
  /** Mock payment adapter options */
7
8
  export interface MockPaymentAdapterOptions {
@@ -12,7 +13,8 @@ export interface MockPaymentAdapterOptions {
12
13
  /** Force errors for testing */
13
14
  forceError?: {
14
15
  operation?: "fetchForOrder" | "fetchOne" | "initiate" | "capture";
15
- code?: string;
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,11 +11,19 @@ export function createMockPaymentAdapter(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 ??
15
- `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}`);
16
17
  }
17
18
  };
18
- const getAllPayments = () => Object.values(payments).flat();
19
+ const findPaymentByRef = (ref) => {
20
+ for (const [orderId, list] of Object.entries(payments)) {
21
+ const payment = list.find((p) => p.provider_reference === ref);
22
+ if (payment)
23
+ return { payment, orderId: orderId };
24
+ }
25
+ return null;
26
+ };
19
27
  return {
20
28
  async fetchForOrder(orderId, _ctx) {
21
29
  await wait();
@@ -29,17 +37,26 @@ export function createMockPaymentAdapter(options = {}) {
29
37
  async fetchOne(paymentId, _ctx) {
30
38
  await wait();
31
39
  maybeThrow("fetchOne");
32
- const allPayments = getAllPayments();
33
- const payment = allPayments.find((p) => p.provider_reference === paymentId);
34
- if (!payment) {
40
+ const found = findPaymentByRef(paymentId);
41
+ if (!found) {
35
42
  throw new HTTP_ERROR.NotFound(`Payment ${paymentId} not found`);
36
43
  }
37
- return structuredClone(payment);
44
+ return structuredClone(found.payment);
38
45
  },
39
46
  async initiate(orderId, config, _ctx) {
40
47
  await wait();
41
48
  maybeThrow("initiate");
42
49
  const id = `pi_${Math.random().toString(36).slice(2)}`;
50
+ // Persist the initiated (pending) payment so capture() can find it.
51
+ if (!payments[orderId])
52
+ payments[orderId] = [];
53
+ payments[orderId].push({
54
+ provider: config.provider,
55
+ status: "pending",
56
+ amount: config.amount,
57
+ currency: config.currency,
58
+ provider_reference: id,
59
+ });
43
60
  return {
44
61
  id,
45
62
  redirect_url: `https://mock-payment.test/pay/${id}`,
@@ -52,19 +69,16 @@ export function createMockPaymentAdapter(options = {}) {
52
69
  async capture(paymentId, _ctx) {
53
70
  await wait();
54
71
  maybeThrow("capture");
55
- const payment = {
56
- provider: "mock",
57
- status: "completed",
58
- amount: 0,
59
- currency: "EUR",
60
- provider_reference: paymentId,
61
- };
62
- // Store captured payment
63
- const key = Object.keys(payments)[0] ?? "mock-order";
64
- if (!payments[key])
65
- payments[key] = [];
66
- payments[key].push(payment);
67
- return structuredClone(payment);
72
+ // Look up the (pending) payment by reference and complete it.
73
+ // Preserves the original amount/currency/provider that initiate()
74
+ // recorded — bypassing this would force the test to assert against
75
+ // hardcoded zeros.
76
+ const found = findPaymentByRef(paymentId);
77
+ if (!found) {
78
+ throw new HTTP_ERROR.NotFound(`Payment ${paymentId} not found`);
79
+ }
80
+ found.payment.status = "completed";
81
+ return structuredClone(found.payment);
68
82
  },
69
83
  };
70
84
  }
@@ -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
  }
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * @module adapters
3
3
  *
4
- * Adapter exports including mock adapters for testing.
4
+ * Adapter exports. Includes:
5
+ * - Mock adapters for testing (see `./mock/`)
6
+ * - Built-in HTTP adapters for the conventional REST surface (see `./http/`)
5
7
  */
6
8
  export * from "./mock/mod.js";
9
+ export * from "./http/mod.js";
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * @module adapters
3
3
  *
4
- * Adapter exports including mock adapters for testing.
4
+ * Adapter exports. Includes:
5
+ * - Mock adapters for testing (see `./mock/`)
6
+ * - Built-in HTTP adapters for the conventional REST surface (see `./http/`)
5
7
  */
6
8
  export * from "./mock/mod.js";
9
+ export * from "./http/mod.js";
@@ -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];