import type { ApprovalFlow, ApprovalRule, AssociateRole, AttributeGroup, BusinessUnit, Cart, CartDiscount, Category, Channel, Customer, CustomerGroup, CustomObject, DiscountCode, DiscountGroup, Extension, InvalidInputError, InventoryEntry, Order, Payment, Product, ProductDiscount, ProductProjection, ProductTailoring, ProductType, Project, Quote, QuoteRequest, RecurrencePolicy, RecurringOrder, ShippingMethod, ShoppingList, StagedQuote, State, Store, Subscription, TaxCategory, Type, Zone, } from "@commercetools/platform-sdk"; import { CommercetoolsError } from "#src/exceptions.ts"; import { cloneObject } from "../helpers.ts"; import { parseQueryExpression } from "../lib/predicateParser.ts"; import { applySort } from "../lib/sortParser.ts"; import type { PagedQueryResponseMap, ResourceMap, ResourceType, } from "../types.ts"; import type { GetParams, ProjectStorage, QueryParams } from "./abstract.ts"; import { AbstractStorage } from "./abstract.ts"; import { StorageMap } from "./storage-map.ts"; export class InMemoryStorage extends AbstractStorage { protected resources: { [projectKey: string]: ProjectStorage; } = {}; protected projects: { [projectKey: string]: Project; } = {}; // Secondary index for custom objects: projectKey -> "container\0key" -> resource id private _customObjectIndex: Map> = new Map(); async addProject(projectKey: string): Promise { if (!this.projects[projectKey]) { this.projects[projectKey] = { key: projectKey, name: "", countries: [], currencies: [], languages: [], createdAt: "2018-10-04T11:32:12.603Z", trialUntil: "2018-12", carts: { countryTaxRateFallbackEnabled: false, deleteDaysAfterLastModification: 90, priceRoundingMode: "HalfEven", taxRoundingMode: "HalfEven", }, shoppingLists: { deleteDaysAfterLastModification: 360, }, messages: { enabled: false, deleteDaysAfterCreation: 15 }, inventory: { releaseExpiredReservations: false }, shippingRateInputType: undefined, externalOAuth: undefined, searchIndexing: { products: { status: "Deactivated", }, productsSearch: { status: "Deactivated", }, orders: { status: "Deactivated", }, customers: { status: "Deactivated", }, businessUnits: { status: "Deactivated", }, }, discounts: { discountCombinationMode: "Stacking", }, version: 1, }; } return this.projects[projectKey]; } async saveProject(project: Project): Promise { this.projects[project.key] = cloneObject(project); return project; } async getProject(projectKey: string): Promise { await this.addProject(projectKey); return cloneObject(this.projects[projectKey]); } private async forProjectKey(projectKey: string): Promise { await this.addProject(projectKey); let projectStorage = this.resources[projectKey]; if (!projectStorage) { projectStorage = this.resources[projectKey] = { "approval-flow": new StorageMap(), "approval-rule": new StorageMap(), "associate-role": new StorageMap(), "attribute-group": new StorageMap(), "business-unit": new StorageMap(), cart: new StorageMap(), "cart-discount": new StorageMap(), category: new StorageMap(), channel: new StorageMap(), customer: new StorageMap(), "customer-group": new StorageMap(), "discount-code": new StorageMap(), "discount-group": new StorageMap(), extension: new StorageMap(), "inventory-entry": new StorageMap(), "key-value-document": new StorageMap(), order: new StorageMap(), "order-edit": new StorageMap(), payment: new StorageMap(), product: new StorageMap(), quote: new StorageMap(), "quote-request": new StorageMap(), "product-discount": new StorageMap(), "product-selection": new StorageMap(), "product-type": new StorageMap(), "product-projection": new StorageMap(), "product-tailoring": new StorageMap(), "recurrence-policy": new StorageMap(), "recurring-order": new StorageMap(), review: new StorageMap(), "shipping-method": new StorageMap(), "staged-quote": new StorageMap(), state: new StorageMap(), store: new StorageMap(), "shopping-list": new StorageMap(), "standalone-price": new StorageMap(), subscription: new StorageMap(), "tax-category": new StorageMap(), type: new StorageMap(), zone: new StorageMap(), }; } return projectStorage; } async clear(): Promise { for (const [, projectStorage] of Object.entries(this.resources)) { for (const [, value] of Object.entries(projectStorage)) { value?.clear(); } } this._customObjectIndex.clear(); } async all( projectKey: string, typeId: RT, ): Promise { const projectStorage = await this.forProjectKey(projectKey); const store = projectStorage[typeId]; if (store) { // StorageMap.values() already returns cloned values return Array.from(store.values()) as ResourceMap[RT][]; } return []; } async count(projectKey: string, typeId: ResourceType): Promise { const projectStorage = await this.forProjectKey(projectKey); const store = projectStorage[typeId]; return store ? store.size : 0; } async add( projectKey: string, typeId: RT, obj: ResourceMap[RT], params: GetParams = {}, ): Promise { const store = await this.forProjectKey(projectKey); // StorageMap.set() clones the value before storing store[typeId]?.set(obj.id, obj); // Maintain secondary index for custom objects if (typeId === "key-value-document") { const co = obj as unknown as CustomObject; let projectIndex = this._customObjectIndex.get(projectKey); if (!projectIndex) { projectIndex = new Map(); this._customObjectIndex.set(projectKey, projectIndex); } projectIndex.set(`${co.container}\0${co.key}`, co.id); } // StorageMap.get() returns a clone, so we get a fresh copy for expand const clone = store[typeId]?.get(obj.id) as ResourceMap[RT]; return this.expand(projectKey, clone, params.expand); } async get( projectKey: string, typeId: RT, id: string, params: GetParams = {}, ): Promise { const projectStorage = await this.forProjectKey(projectKey); // StorageMap.get() already returns a clone const resource = projectStorage[typeId]?.get(id); if (resource) { const expanded = await this.expand(projectKey, resource, params.expand); return expanded as ResourceMap[RT]; } return null; } async getByKey( projectKey: string, typeId: RT, key: string, params: GetParams = {}, ): Promise { const store = await this.forProjectKey(projectKey); if (!store) { throw new Error("No type"); } const resourceStore = store[typeId]; // StorageMap.values() already returns cloned values const resources: any[] = Array.from(resourceStore.values()); const resource = resources.find((e) => e.key === key); if (resource) { const expanded = await this.expand(projectKey, resource, params.expand); return expanded as ResourceMap[RT]; } return null; } async delete( projectKey: string, typeId: RT, id: string, params: GetParams = {}, ): Promise { const resource = await this.get(projectKey, typeId, id); if (resource) { const projectStorage = await this.forProjectKey(projectKey); projectStorage[typeId]?.delete(id); // Remove from secondary index for custom objects if (typeId === "key-value-document") { const co = resource as unknown as CustomObject; this._customObjectIndex .get(projectKey) ?.delete(`${co.container}\0${co.key}`); } return this.expand(projectKey, resource, params.expand); } return resource; } async getByContainerAndKey( projectKey: string, container: string, key: string, ): Promise { const projectIndex = this._customObjectIndex.get(projectKey); if (!projectIndex) { return null; } const id = projectIndex.get(`${container}\0${key}`); if (!id) { return null; } const resource = await this.get(projectKey, "key-value-document", id); return resource as CustomObject | null; } async query( projectKey: string, typeId: RT, params: QueryParams, ): Promise { const projectStorage = await this.forProjectKey(projectKey); const store = projectStorage[typeId]; if (!store) { throw new Error("No type"); } // all() already returns cloned values via StorageMap let resources = await this.all(projectKey, typeId); // Apply predicates if (params.where) { // Get all key-value pairs starting with 'var.' to pass as variables, removing // the 'var.' prefix. const vars = Object.fromEntries( Object.entries(params) .filter(([key]) => key.startsWith("var.")) .map(([key, value]) => [key.slice(4), value]), ); try { const filterFunc = parseQueryExpression(params.where); resources = resources.filter((resource) => filterFunc(resource, vars)); } catch (err) { throw new CommercetoolsError( { code: "InvalidInput", message: (err as any).message, }, 400, ); } } // Apply sorting before paging: a cursor-based pager (`where id > "..."` // with `sort id asc`) depends on a deterministic order. resources = applySort(resources, params.sort); // Get the total before slicing the array const totalResources = resources.length; // Apply offset, limit const offset = params.offset || 0; const limit = params.limit || 20; resources = resources.slice(offset, offset + limit); // Expand the resources if (params.expand !== undefined) { resources = await Promise.all( resources.map((resource) => this.expand(projectKey, resource, params.expand), ), ); } return { count: resources.length, total: totalResources, offset: offset, limit: limit, // Resources are already clones from StorageMap results: resources, } as PagedQueryResponseMap[RT]; } }