@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.
- package/AGENTS.md +134 -20
- package/API.md +108 -25
- package/README.md +127 -0
- package/dist/adapters/http/_http.d.ts +34 -0
- package/dist/adapters/http/_http.js +75 -0
- package/dist/adapters/http/cart.d.ts +21 -0
- package/dist/adapters/http/cart.js +52 -0
- package/dist/adapters/http/customer.d.ts +22 -0
- package/dist/adapters/http/customer.js +35 -0
- package/dist/adapters/http/mod.d.ts +21 -0
- package/dist/adapters/http/mod.js +20 -0
- package/dist/adapters/http/order.d.ts +24 -0
- package/dist/adapters/http/order.js +43 -0
- package/dist/adapters/http/payment.d.ts +32 -0
- package/dist/adapters/http/payment.js +77 -0
- package/dist/adapters/http/product.d.ts +18 -0
- package/dist/adapters/http/product.js +30 -0
- package/dist/adapters/http/wishlist.d.ts +19 -0
- package/dist/adapters/http/wishlist.js +42 -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/adapters/mod.d.ts +4 -1
- package/dist/adapters/mod.js +4 -1
- 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/dist/types/state.d.ts +2 -0
- package/docs/future-improvements.md +116 -0
- 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
|
-
|
|
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
|
-
|
|
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({
|
|
45
|
-
return { model_id, 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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
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
|
|
33
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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/adapters/mod.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module adapters
|
|
3
3
|
*
|
|
4
|
-
* Adapter exports
|
|
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";
|
package/dist/adapters/mod.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @module adapters
|
|
3
3
|
*
|
|
4
|
-
* Adapter exports
|
|
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";
|
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];
|