@marianmeres/ecsuite
v2.2.1
Published
[](https://www.npmjs.com/package/@marianmeres/ecsuite) [](https://jsr.io/@marianmeres/ecsuite) [method - Adapter Pattern: Pluggable server communication (REST, WebSocket, mock)
- Event System: Subscribe to domain events for error handling and UI feedback
- Local Persistence: Cart and wishlist are saved to localStorage
Installation
# Deno
deno add @marianmeres/ecsuite
# npm
npm install @marianmeres/ecsuiteQuick Start
import { createECSuite } from "@marianmeres/ecsuite";
// Create suite with your adapters
const suite = createECSuite({
context: { customerId: "user-123" },
adapters: {
cart: myCartAdapter,
wishlist: myWishlistAdapter,
order: myOrderAdapter,
customer: myCustomerAdapter,
},
});
// `autoInitialize` is true by default; await `suite.ready` so consumer
// mutations don't race the in-flight initial fetches.
await suite.ready;
// Subscribe to cart state (Svelte-compatible)
suite.cart.subscribe((state) => {
console.log(state.state, state.data);
// state.state: "initializing" | "ready" | "syncing" | "error"
// state.data: CartData | null
});
// Listen for errors
suite.on("domain:error", (event) => {
showToast(event.error.message);
});
// Add item (optimistic update)
await suite.cart.addItem({ product_id: "prod-1", quantity: 2 });Identity switches (login / logout)
When the user signs in or out, use switchIdentity() (or just call
setContext() with a different customerId — auto-reset is on by default):
await suite.switchIdentity({ customerId: "another-user" });
// All domains reset, re-initialized for the new identity, and `suite.ready`
// resolves once the new fetches settle.Teardown
suite.destroy(); // unsubscribes every internal event listenerDomains
| Domain | Persistence | Operations | | -------- | ------------ | ----------------------------------- | | Cart | localStorage | add, update, remove, clear | | Wishlist | localStorage | add, remove, toggle, clear | | Order | none | fetchAll, fetchOne, create | | Customer | none | fetch, refresh, update | | Payment | none | fetchForOrder, fetchOne (read-only) | | Product | cache only | getById, getByIds, prefetch |
State Machine
Each domain follows this state progression:
initializing → ready ↔ syncing → error- initializing: Fetching initial data
- ready: Data loaded, idle
- syncing: Operation in progress
- error: Last operation failed (includes rollback)
Creating Adapters
Implement the adapter interface for your backend:
import type { CartAdapter } from "@marianmeres/ecsuite";
import { HTTP_ERROR } from "@marianmeres/http-utils";
const myCartAdapter: CartAdapter = {
async fetch(ctx) {
const res = await fetch(`/api/cart?customerId=${ctx.customerId}`);
if (!res.ok) throw new HTTP_ERROR.BadRequest("Failed to fetch cart");
return await res.json();
},
async addItem(item, ctx) {
const res = await fetch("/api/cart/items", {
method: "POST",
body: JSON.stringify(item),
});
if (!res.ok) throw new HTTP_ERROR.BadRequest("Failed to add item");
return await res.json();
},
// ... other methods
};Testing with Mock Adapters
import { createECSuite, createMockCartAdapter } from "@marianmeres/ecsuite";
const suite = createECSuite({
adapters: {
cart: createMockCartAdapter({
initialData: { items: [{ product_id: "p1", quantity: 2 }] },
delay: 100,
}),
},
storage: { type: "memory" },
});Built-in HTTP Adapters
For consumers whose backend exposes the conventional commerce REST surface,
ecsuite ships ready-to-use HTTP adapters for every domain. Each factory
takes { baseUrl?, fetch? }; authentication is carried on the context
passed into each call (ctx.sessionId → X-Session-ID; ctx.jwt →
Authorization: Bearer <jwt>).
import {
createECSuite,
createHttpCartAdapter,
createHttpCustomerAdapter,
createHttpOrderAdapter,
createHttpPaymentAdapter,
createHttpProductAdapter,
createHttpWishlistAdapter,
} from "@marianmeres/ecsuite";
const suite = createECSuite({
context: { sessionId: mySessionId, jwt: myJwt, customerId: myCustomerId },
adapters: {
cart: createHttpCartAdapter({ baseUrl: "/api/session" }),
wishlist: createHttpWishlistAdapter({ baseUrl: "/api/session" }),
order: createHttpOrderAdapter({ baseUrl: "/api/order" }),
customer: createHttpCustomerAdapter({ baseUrl: "/api/customer" }),
payment: createHttpPaymentAdapter({ baseUrl: "/api/payment" }),
product: createHttpProductAdapter({ baseUrl: "/api/product" }),
},
});Expected endpoints per adapter (all mutations require X-Session-ID, all
owner-scoped reads require a JWT):
| Adapter | Endpoints |
| -------- | --------------------------------------------------------------------------------------------------- |
| cart | GET/POST/PUT/DELETE {baseUrl}/cart (DELETE with optional ?product_id= for single-item remove) |
| wishlist | GET/POST/DELETE {baseUrl}/wishlist (DELETE with optional ?product_id= for single-item remove) |
| order | GET {baseUrl}/col/order/mod, GET {baseUrl}/col/order/mod/:id, POST {baseUrl}/checkout/start |
| customer | GET/PUT {baseUrl}/me/col/customer/mod/:customerId |
| payment | GET {baseUrl}/by-order/:orderId, GET {baseUrl}/col/payment/mod/:id, POST {baseUrl}/initiate (body: { order_id, provider, return_url, cancel_url } — server derives amount/currency from the order record) |
| product | GET {baseUrl}/col/product/mod/:id (fetchMany = parallel single fetches — no batch endpoint assumed) |
Adapters throw raw HTTP errors (Error with .status and .body
attached); the domain manager normalizes them to DomainError. Responses
may use { model_id, data } model envelopes — adapters unwrap them
transparently.
PaymentAdapter.capture is intentionally omitted from
createHttpPaymentAdapter; capture is typically driven server-side by
provider webhooks + checkout completion. Calls to suite.payment.capture()
will surface as NOT_IMPLEMENTED.
See example/ for a vanilla-JS reference harness exercising
every public verb against either the HTTP adapters or the mock adapters.
Events
Subscribe to domain events:
suite.on("cart:item:added", (event) => {
console.log(`Added ${event.quantity} of ${event.productId}`);
});
suite.onAny(({ event, data }) => {
console.log(event, data);
});
suite.once("order:created", (event) => {
redirectToConfirmation(event.orderId);
});API Reference
For complete API documentation, see API.md.
Migration to next major
This release tightens correctness in several places. Breaking changes:
OrderAdapterreturnsOrderCreateResultfor bothfetchAllandfetchOne({ model_id, data }) so orders are uniquely identifiable.OrderListData.ordersis nowOrderCreateResult[]. Use the neworders.getOrderById(modelId)/getOrderDataById(modelId)helpers, or readresult.data.<field>on returned envelopes.CartAdapter.sync()andWishlistAdapter.sync()removed — they were never called by the manager.PaymentManager.initiate()/capture()throwNOT_IMPLEMENTEDwhen the adapter doesn't implement the optional method (previously returnednullsilently).domain:erroris also emitted.CustomerManager.update()throwsNOT_IMPLEMENTEDwhen no adapter is configured (previously silent no-op).CustomerManagerno longer falls through tofetch()when bothcustomerIdis missing ANDadapter.fetchBySessionis undefined; it now warns and stays inreadywithdata: null. PasscustomerIdin context, or implementfetchBySession.CartManager.addItem/updateItemQuantityvalidate the quantity (must be a finite, non-negative integer); invalid values throw at the call site instead of being persisted optimistically.ProductManagernow extendsBaseDomainManager— exposessubscribe, emitsdomain:error, and gains aninitialize()no-op.setAdapter/getAdapter/setContext/getContextkeep the same signatures.InitializableDomainNamenow includes"product"for parity with the other domains.
New additions:
suite.ready: Promise<void>— resolves when the most recent (auto or manual)initialize()settles.suite.switchIdentity(context)— atomic identity switch (merge context, reset domains, re-initialize). Returns a promise.suite.destroy()— unsubscribes all internal pubsub listeners.ECSuiteConfig.autoResetOnIdentityChange(defaulttrue) — opt out of the auto-reset path onsetContext()if you manage identity transitions yourself.OrderManager.getOrderById(modelId)/getOrderDataById(modelId)lookup helpers.- Per-domain mutation queue (
withOptimisticUpdateis serialized per manager) — concurrentcart.addItem(...)calls no longer race their rollback snapshots. - Cache stampede dedup in
ProductManager.getById— concurrent callers for the same id share a single in-flight request. - Mock adapters now dispatch
forceError.code(any name fromHTTP_ERROR) so tests can simulateNotFound,Conflict, etc., not justBadRequest.
License
MIT
