@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
|
@@ -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
|
-
|
|
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
|
-
|
|
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"
|
|
15
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/domains/base.d.ts
CHANGED
|
@@ -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.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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) */
|
package/dist/domains/base.js
CHANGED
|
@@ -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.
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
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",
|
package/dist/domains/cart.js
CHANGED
|
@@ -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
|
}
|
package/dist/domains/customer.js
CHANGED
|
@@ -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 —
|
|
55
|
-
//
|
|
56
|
-
|
|
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
|
-
|
|
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", () => {
|
package/dist/domains/order.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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:
|
|
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
|
|
33
|
-
* console.log(
|
|
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<
|
|
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
|
|
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
|
|
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
|
-
* @
|
|
90
|
+
* @param modelId - The order's server-assigned model id
|
|
91
|
+
* @returns The order data or undefined if not found
|
|
74
92
|
*/
|
|
75
|
-
|
|
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):
|
|
100
|
+
getOrderByIndex(index: number): OrderCreateResult | undefined;
|
|
83
101
|
}
|
package/dist/domains/order.js
CHANGED
|
@@ -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
|
|
24
|
-
* console.log(
|
|
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
|
|
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 ===
|
|
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] =
|
|
115
|
+
orders[existingIndex] = result;
|
|
119
116
|
}
|
|
120
117
|
else {
|
|
121
|
-
orders = [...current.orders,
|
|
118
|
+
orders = [...current.orders, result];
|
|
122
119
|
}
|
|
123
120
|
this.setData({ orders });
|
|
124
121
|
this.markSynced();
|
|
125
|
-
return
|
|
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
|
|
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
|
|
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
|
|
183
|
+
* Get all order envelopes (`{ model_id, data }`).
|
|
188
184
|
*
|
|
189
|
-
* @returns Array of
|
|
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
|
|
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];
|
package/dist/domains/payment.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 { 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
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
* -
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
|
|
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/
|
|
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
|
}
|