UNPKG

@webda/core

Version:

Expose API with Lambda

1,351 lines (1,349 loc) 65.5 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import { Counter, Histogram } from "../core.js"; import { MemoryStore, ModelMapLoaderImplementation, Throttler, WebdaError } from "../index.js"; import { CoreModel } from "../models/coremodel.js"; import { Route, Service, ServiceParameters } from "../services/service.js"; import { WebdaQL } from "./webdaql/query.js"; export class StoreNotFoundError extends WebdaError.CodeError { constructor(uuid, storeName) { super("STORE_NOTFOUND", `Item not found ${uuid} Store(${storeName})`); } } export class UpdateConditionFailError extends WebdaError.CodeError { constructor(uuid, conditionField, condition) { super("STORE_UPDATE_CONDITION_FAILED", `UpdateCondition not met on ${uuid}.${conditionField} === ${condition instanceof Date ? condition.toISOString() : condition}`); } } /** * Store parameter */ export class StoreParameters extends ServiceParameters { constructor(params, service) { var _a; super(params); this.model ?? (this.model = "Webda/CoreModel"); let expose = params.expose; if (typeof expose == "boolean") { expose = {}; expose.url = "/" + service.getName().toLowerCase(); } else if (typeof expose == "string") { expose = { url: expose }; } else if (typeof expose == "object" && expose.url == undefined) { expose.url = "/" + service.getName().toLowerCase(); } if (expose) { expose.restrict = expose.restrict || {}; this.expose = expose; (_a = this.expose).queryMethod ?? (_a.queryMethod = "GET"); this.url = expose.url; } if (params.map) { throw new Error("Deprecated map usage, use a MapperService"); } if (params.index) { throw new Error("Deprecated index usage, use an AggregatorService"); } this.strict ?? (this.strict = false); this.defaultModel ?? (this.defaultModel = true); this.forceModel ?? (this.forceModel = false); this.slowQueryThreshold ?? (this.slowQueryThreshold = 30000); this.modelAliases ?? (this.modelAliases = {}); this.additionalModels ?? (this.additionalModels = []); } } /** * This class handle NoSQL storage and mapping (duplication) between NoSQL object * TODO Create the mapping documentation * * It use basic CRUD, and can expose those 4 to through HTTP * * It emits events : * Store.Save: Before saving the object * Store.Saved: After saving the object * Store.Update: Before updating the object * Store.Updated: After updating the object * Store.Delete: Before deleting the object * Store.Deleted: After deleting the object * Store.Get: When getting the object * Store.Action: When an action will be done on an object * Store.Actioned: When an action has been done on an object * * Mapping: * * * Parameters * * map: { ... } * expose: { // Enable the HTTP exposure * url: '', // The url to expose to by default it is service name in lowercase ( users for example ) * restrict: { * create: true, // Don't expose the POST /users * update: true, // Don't expose the PUT /users/{uuid} * delete: true, // Don't expose the DELETE /users/{uuid} * get: true // Don't expose the GET /users/{uuid} * } * } * @category CoreServices */ class Store extends Service { constructor() { super(...arguments); /** * Contain the reverse map */ this._reverseMap = []; /** * Store teh manager hierarchy with their depth */ this._modelsHierarchy = {}; /** * Contain the model uuid field */ this._uuidField = "uuid"; } /** * Retrieve the Model * * @throws Error if model is not found */ computeParameters() { super.computeParameters(); const app = this.getWebda().getApplication(); const p = this.parameters; this._model = app.getModel(p.model); this._modelType = this._model.getIdentifier(); this._uuidField = this._model.getUuidField(); if (!this.parameters.noCache) { this._cacheStore = new MemoryStore(this._webda, `_${this.getName()}_cache`, { model: this.parameters.model }); this._cacheStore.computeParameters(); this._cacheStore.initMetrics(); this.cacheStorePatchException(); } const recursive = (tree, depth) => { var _a; for (let i in tree) { (_a = this._modelsHierarchy)[i] ?? (_a[i] = depth); this._modelsHierarchy[i] = Math.min(depth, this._modelsHierarchy[i]); this._modelsHierarchy[app.completeNamespace(i)] = this._modelsHierarchy[i]; recursive(app.getModelHierarchy(i).children, depth + 1); } }; // Compute the hierarchy this._modelsHierarchy[this._model.getIdentifier(false)] = 0; this._modelsHierarchy[this._model.getIdentifier()] = 0; // Strict Store only store their model if (!this.parameters.strict) { recursive(this._model.getHierarchy().children, 1); } // Add additional models if (this.parameters.additionalModels.length) { // Strict mode is to only allow one model per store if (this.parameters.strict) { this.log("ERROR", "Cannot add additional models in strict mode"); } else { for (let modelType of this.parameters.additionalModels) { const model = app.getModel(modelType); this._modelsHierarchy[model.getIdentifier(false)] = 0; this._modelsHierarchy[model.getIdentifier()] = 0; recursive(model.getHierarchy().children, 1); } } } if (this.getParameters().expose) { this.log("WARN", "Exposing a store is not recommended, use a DomainService instead to expose all your CoreModel"); } } logSlowQuery(_query, _reason, _time) { // TODO Need to implement: https://github.com/loopingz/webda.io/issues/202 } /** * Invalidate a cache entry * @param uid */ async invalidateCache(uid) { if (this._cacheStore) { this.metrics.cache_invalidations.inc(); await this._cacheStore.delete(uid, undefined, undefined, true); } } /** * @override */ initMetrics() { super.initMetrics(); this.metrics.operations_total = this.getMetric(Counter, { name: "operations_total", help: "Operations counter for this store", labelNames: ["operation"] }); this.metrics.slow_queries_total = this.getMetric(Counter, { name: "slow_queries", help: "Number of slow queries encountered" }); this.metrics.cache_invalidations = this.getMetric(Counter, { name: "cache_invalidations", help: "Number of cache invalidation encountered" }); this.metrics.cache_hits = this.getMetric(Counter, { name: "cache_hits", help: "Number of cache hits" }); this.metrics.queries = this.getMetric(Histogram, { name: "queries", help: "Query duration" }); } /** * Return Store current model * @returns */ getModel() { return this._model; } /** * Return if a model is handled by the store * @param model * @return distance from the managed class -1 means not managed, 0 manage exactly this model, >0 manage an ancestor model * */ handleModel(model) { const name = this.getWebda().getApplication().getModelName(model); return this._modelsHierarchy[name] ?? -1; } /** * Get From Cache or main * @param uuid * @param raiseIfNotFound * @returns */ async _getFromCache(uuid, raiseIfNotFound = false) { let res = await this._cacheStore?._get(uuid); if (!res) { res = await this._get(uuid, raiseIfNotFound); if (res) { await this._cacheStore?._save(res); } } else { this.metrics.cache_hits.inc(); res.__store = this; } return res; } /** * Get object from store * @param uid * @returns */ async getObject(uid) { this.metrics.operations_total.inc({ operation: "get" }); return this._getFromCache(uid); } /** * @override */ getUrl(url, methods) { // If url is absolute if (url.startsWith("/")) { return url; } // Parent url to find here const expose = this.parameters.expose; if (!expose.url || (url === "." && methods.includes("POST") && expose.restrict.create) || (url === "./{uuid}" && methods.includes("DELETE") && expose.restrict.delete) || (url === "./{uuid}" && methods.includes("PATCH") && expose.restrict.update) || (url === "./{uuid}" && methods.includes("PUT") && expose.restrict.update) || (url === "./{uuid}" && methods.includes("GET") && expose.restrict.get) || (url === ".{?q}" && methods.includes("GET") && expose.restrict.query) || (url === "." && methods.includes("PUT") && expose.restrict.query)) { return undefined; } return super.getUrl(url, methods); } static getOpenAPI() { } /** * @inheritdoc */ initRoutes() { if (!this.parameters.expose) { return; } super.initRoutes(); // We enforce ExposeParameters within the constructor const expose = this.parameters.expose; this.getWebda().getRouter().registerModelUrl(this.parameters.model, expose.url); // Query endpoint if (!expose.restrict.query) { let requestBody; if (expose.queryMethod === "PUT") { requestBody = { content: { "application/json": { schema: { properties: { q: { type: "string" } } } } } }; } this.addRoute(expose.queryMethod === "GET" ? `.{?q}` : ".", [expose.queryMethod], this.httpQuery, { model: this.parameters.model, [expose.queryMethod.toLowerCase()]: { description: `Query on ${this.parameters.model} model with WebdaQL`, summary: "Query " + this.parameters.model, operationId: `query${this._model.name}`, requestBody, responses: { "200": { description: `Retrieve models ${this._model.name}`, content: { "application/json": { schema: { properties: { continuationToken: { type: "string" }, results: { type: "array", items: { $ref: `#/components/schemas/${this._model.name}` } } } } } } }, "400": { description: "Query is invalid" }, "403": { description: "You don't have permissions" } } } }); } // Model actions if (this._model && this._model.getActions) { let actions = this._model.getActions(); Object.keys(actions).forEach(name => { let action = actions[name]; action.method ?? (action.method = name); if (!action.methods) { action.methods = ["PUT"]; } let executer; if (action.global) { // By default will grab the object and then call the action if (!this._model[action.method]) { throw Error("Action static method " + action.method + " does not exist"); } executer = this.httpGlobalAction; this.addRoute(`./${name}`, action.methods, executer, action.openapi); } else { // By default will grab the object and then call the action if (!this._model.prototype[action.method]) { throw Error("Action method " + action.method + " does not exist"); } executer = ctx => this.httpAction(ctx, action.method); this.addRoute(`./{uuid}/${name}`, action.methods, executer, action.openapi); } }); } } /** * OVerwrite the model * Used mainly in test */ setModel(model) { this._model = model; this._cacheStore?.setModel(model); this.parameters.strict = false; } /** * We should ignore exception from the store */ cacheStorePatchException() { const replacer = original => { return (...args) => { return original .bind(this._cacheStore, ...args)() .catch(err => { this.log("TRACE", `Ignoring cache exception ${this._name}: ${err.message}`); }); }; }; for (let i of [ "_get", "_patch", "_update", "_delete", "_incrementAttributes", "_upsertItemToCollection", "_deleteItemFromCollection", "_removeAttribute" ]) { this._cacheStore[i] = replacer(this._cacheStore[i]); } } /** * Init a model from the current stored data * * Initial the reverse map as well * * @param object * @returns */ initModel(object = {}) { var _a; object.__type ?? (object.__type = this.getWebda().getApplication().getModelFromInstance(object) || this._modelType); // Make sure to send a model object if (!(object instanceof this._model)) { // Dynamic load type if (object.__type && !this.getParameters().forceModel) { try { const modelType = this.getWebda() .getApplication() .getModel(this.parameters.modelAliases[object.__type] || object.__type); object = new modelType().load(object, true); } catch (err) { if (!this.parameters.defaultModel) { throw new Error(`Unknown model ${object.__type} found for Store(${this.getName()})`); } object = this._model.factory(object); } } else { object = this._model.factory(object); } } if (!object.getUuid()) { object.setUuid(object.generateUid(object)); } object.__store = this; for (let i in this._reverseMap) { object[_a = this._reverseMap[i].property] ?? (object[_a] = []); for (let j in object[this._reverseMap[i].property]) { if (object[this._reverseMap[i].property][j] instanceof ModelMapLoaderImplementation) { continue; } // Use Partial object[this._reverseMap[i].property][j] = this._reverseMap[i].mapper.newModel(object[this._reverseMap[i].property][j]); object[this._reverseMap[i].property][j].setContext(object.getContext()); } } return object; } /** * Get a new model with this data preloaded * @param object * @returns */ newModel(object = {}) { let result = this.initModel(object); Object.keys(object).forEach(k => result.__dirty.add(k)); return result; } /** * Add reverse map information * * @param prop * @param cascade * @param store */ addReverseMap(prop, store) { this._reverseMap.push({ property: prop, mapper: store }); } /** * Increment attributes of an object * * @param uid * @param info * @returns */ async incrementAttributes(uid, info) { let params = info.filter(i => i.value !== 0); // If value === 0 no need to update anything if (params.length === 0) { return; } let updateDate = new Date(); this.metrics.operations_total.inc({ operation: "increment" }); await this._incrementAttributes(uid, params, updateDate); const evt = { object_id: uid, store: this, updateDate, partial_update: { increments: params } }; await this.emitStoreEvent("Store.PartialUpdated", evt); return updateDate; } /** * Helper function that call incrementAttributes * @param uid * @param prop * @param value * @returns */ async incrementAttribute(uid, prop, value) { return this.incrementAttributes(uid, [{ property: prop, value }]); } /** * Add or update an item to an array in the model * * @param uid of the model * @param prop of the model to add in * @param item to add in the array * @param index if specified update item in this index * @param itemWriteCondition value of the condition to test (in case of update) * @param itemWriteConditionField field to read the condition from (in case of update) */ async upsertItemToCollection(uid, prop, item, index = undefined, itemWriteCondition = undefined, itemWriteConditionField = this._uuidField) { let updateDate = new Date(); this.metrics.operations_total.inc({ operation: "collectionUpsert" }); await this._upsertItemToCollection(uid, prop, item, index, itemWriteCondition, itemWriteConditionField, updateDate); await this.emitStoreEvent("Store.PartialUpdated", { object_id: uid, store: this, updateDate, partial_update: { addItem: { value: item, property: prop, index: index } } }); return updateDate; } /** * Remove an item from an array in the model * * @param uid of the model * @param prop of the model to remove from * @param index of the item to remove in the array * @param itemWriteCondition value of the condition * @param itemWriteConditionField field to read the condition from */ async deleteItemFromCollection(uid, prop, index, itemWriteCondition, itemWriteConditionField = this._uuidField) { let updateDate = new Date(); this.metrics.operations_total.inc({ operation: "collectionDelete" }); await this._deleteItemFromCollection(uid, prop, index, itemWriteCondition, itemWriteConditionField, updateDate); await this.emitStoreEvent("Store.PartialUpdated", { object_id: uid, store: this, updateDate, partial_update: { deleteItem: { property: prop, index: index } } }); return updateDate; } /** * Iterate through the results * * This can be resource consuming * * @param query * @param context */ async *iterate(query = "", context) { if (query.includes("OFFSET")) { throw new Error("Cannot contain an OFFSET for iterate method"); } let continuationToken; do { let q = query + (continuationToken !== undefined ? ` OFFSET "${continuationToken}"` : ""); let page = await this.query(q, context); for (let item of page.results) { yield item; } continuationToken = page.continuationToken; } while (continuationToken); } // REFACTOR . >= 4 /** * Query all the results * * * @param query * @param context * @returns * @deprecated use iterate instead */ async queryAll(query, context) { let res = []; for await (let item of this.iterate(query, context)) { res.push(item); } return res; } // END_REFACTOR /** * Check that __type Comparison is only used with = and CONTAINS * If CONTAINS is used, move __type to __types * If __type = store._model, remove it */ queryTypeUpdater(query) { return query; } /** * Query store with WebdaQL * @param query * @param context to apply permission */ async query(query, context) { let permissionQuery = this._model.getPermissionQuery(context); let partialPermission = true; let fullQuery = query; if (permissionQuery) { partialPermission = permissionQuery.partial; fullQuery = WebdaQL.PrependCondition(query, permissionQuery.query); } let queryValidator = new WebdaQL.QueryValidator(fullQuery); let offset = queryValidator.getOffset(); const limit = queryValidator.getLimit(); const parsedQuery = this.queryTypeUpdater(queryValidator.getQuery()); parsedQuery.limit = limit; // __type is a special field to filter on the type of the object // Emit the default event await this.emitSync("Store.Query", { query, parsedQuery, store: this, context }); const result = { results: [], continuationToken: undefined }; /* Offset are split in two with _ MainOffset is the database offset, retrieving a page from the database SubOffset is the offset within the page As we filter through the page, for additional filters or permissions, the page cut by database does not endup being our final page cut, so we need a page offset to abstract this completely */ let [mainOffset, subOffset] = offset.split("_"); let secondOffset = parseInt(subOffset || "0"); let duration = Date.now(); this.metrics.operations_total.inc({ operation: "query" }); while (result.results.length < limit) { let tmpResults = await this.find({ ...parsedQuery, continuationToken: mainOffset }); // If no filter is returned assume it is by mistake and apply filtering if (tmpResults.filter === undefined) { tmpResults.filter = queryValidator.getExpression(); this.log("WARN", `Store '${this.getName()}' postquery full filtering`); } let subOffsetCount = 0; for (let item of tmpResults.results) { item.setContext(context); // Because of dynamic filter and permission we need to suboffset the pagination subOffsetCount++; if (subOffsetCount <= secondOffset) { continue; } if (tmpResults.filter !== true && !tmpResults.filter.eval(item)) { continue; } if (context && partialPermission && (await item.canAct(context, "get")) !== true) { continue; } result.results.push(item); if (result.results.length >= limit) { if (subOffsetCount === tmpResults.results.length) { result.continuationToken = tmpResults.continuationToken; } else { result.continuationToken = `${mainOffset}_${subOffsetCount}`; } break; } } // Update both offset mainOffset = tmpResults.continuationToken; // Fresh new query so we do not need to skip secondOffset = 0; if (mainOffset === undefined || result.results.length >= limit) { break; } } duration = Date.now() - duration; this.metrics.queries.observe(duration / 1000); if (duration > this.parameters.slowQueryThreshold) { this.logSlowQuery(query, "", duration); this.metrics.slow_queries_total.inc(); } await this.emitSync("Store.Queried", { query, parsedQuery: parsedQuery, store: this, continuationToken: result.continuationToken, results: result.results, context }); return result; } /** * Expose query to http */ async httpQuery(ctx) { let query; if (ctx.getHttpContext().getMethod() === "GET") { query = ctx.getParameters().q; } else { query = WebdaQL.unsanitize((await ctx.getRequestBody()).q); } try { ctx.write(await this.query(query, ctx)); } catch (err) { if (err instanceof SyntaxError) { this.log("INFO", "Query syntax error"); throw new WebdaError.BadRequest("Query syntax error"); } throw err; } } /** * Handle StoreEvent and update cache based on it * Then emit the event, it allows the cache to be updated * before listeners are called * * @param event * @param data */ async emitStoreEvent(event, data) { if (event === "Store.Deleted") { await this._cacheStore?._delete(data.object_id); } else if (event === "Store.PartialUpdated") { const partialEvent = data; if (partialEvent.partial_update.increments) { await this._cacheStore?._incrementAttributes(partialEvent.object_id, partialEvent.partial_update.increments, partialEvent.updateDate); } else if (partialEvent.partial_update.deleteAttribute) { await this._cacheStore?._removeAttribute(partialEvent.object_id, partialEvent.partial_update.deleteAttribute, partialEvent.updateDate); } else if (partialEvent.partial_update.addItem) { await this._cacheStore?._upsertItemToCollection(partialEvent.object_id, partialEvent.partial_update.addItem.property, partialEvent.partial_update.addItem.value, partialEvent.partial_update.addItem.index, undefined, undefined, partialEvent.updateDate); } else if (partialEvent.partial_update.deleteItem) { await this._cacheStore?._deleteItemFromCollection(partialEvent.object_id, partialEvent.partial_update.deleteItem.property, partialEvent.partial_update.deleteItem.index, undefined, undefined, partialEvent.updateDate); } else if (partialEvent.partial_update.patch) { await this._cacheStore?._patch(partialEvent.partial_update.patch, partialEvent.object_id); } else { await this.invalidateCache(partialEvent.object_id); } } else if (event === "Store.Updated") { await this._cacheStore?._update(data.update, data.object_id); } else if (event === "Store.PatchUpdated") { await this._cacheStore?._patch(data.object, data.object_id); } await this.emitSync(event, data); } /** * Save an object * * @param {Object} Object to save * @param {String} Uuid to use, if not specified take the object.uuid or generate one if not found * @return {Promise} with saved object * * Might want to rename to create */ async save(object, ctx = undefined) { if (object instanceof this._model && object._creationDate !== undefined && object._lastUpdate !== undefined) { if (ctx) { object.setContext(ctx); } return await object.save(); } return this.create(object, ctx); } /** * * @param object * @param ctx * @returns */ async create(object, ctx = undefined) { object = this.initModel(object); // Dates should be store by the Store if (!object._creationDate) { object._creationDate = object._lastUpdate = new Date(); } else { object._lastUpdate = new Date(); } const ancestors = this.getWebda().getApplication().getModelHierarchy(object.__type).ancestors; object.__types = [object.__type, ...ancestors].filter(i => i !== "Webda/CoreModel" && i !== "CoreModel"); if (ctx) { object.setContext(ctx); } // Handle object auto listener const evt = { object: object, object_id: object.getUuid(), store: this, context: ctx }; await Promise.all([ this.emitSync("Store.Save", evt), object?.__class.emitSync("Store.Save", evt), object._onSave() ]); this.metrics.operations_total.inc({ operation: "save" }); let res = await this._save(object); await this._cacheStore?._save(object); object = this.initModel(res); const evtSaved = { object: object, object_id: object.getUuid(), store: this, context: ctx }; await Promise.all([ this.emitSync("Store.Saved", evtSaved), object?.__class.emitSync("Store.Saved", evtSaved), object._onSaved() ]); return object; } /** * Patch an object * * @param object * @param reverseMap * @returns */ async patch(object, reverseMap = true, conditionField, conditionValue) { return this.update(object, reverseMap, true, conditionField, conditionValue); } /** * Check if an UpdateCondition is met * @param model * @param conditionField * @param condition * @param uid */ checkUpdateCondition(model, conditionField, condition, uid) { if (conditionField) { // Add toString to manage Date object if (model[conditionField].toString() !== condition.toString()) { throw new UpdateConditionFailError(uid ? uid : model.getUuid(), conditionField, condition); } } } /** * Check if an UpdateCondition is met * @param model * @param conditionField * @param condition * @param uid */ checkCollectionUpdateCondition(model, collection, conditionField, condition, index) { // No index so addition to collection if (index === null) { // The condition must be length of the collection if (!model[collection] || model[collection].length !== condition) { throw new UpdateConditionFailError(model.getUuid(), collection, condition); } } else if (condition && model[collection][index][conditionField] !== condition) { throw new UpdateConditionFailError(model.getUuid(), `${collection}[${index}].${conditionField}`, condition); } } /** * Update conditionally * @param uuid * @param updates * @param conditionField * @param condition */ async conditionalPatch(uuid, updates, conditionField, condition) { try { await this._patch(updates, uuid, condition, conditionField); // CoreModel should also emit this one but cannot do within this context await this.emitStoreEvent("Store.PartialUpdated", { object_id: uuid, partial_update: { patch: updates }, store: this }); return true; } catch (err) { if (err instanceof UpdateConditionFailError) { return false; } throw err; } } /** * * @param model * @param prop * @param item * @param index * @param itemWriteCondition * @param itemWriteConditionField * @param updateDate */ async simulateUpsertItemToCollection(model, prop, item, updateDate, index, itemWriteCondition, itemWriteConditionField) { if (prop === "__proto__") { throw new Error("Cannot update __proto__: js/prototype-polluting-assignment"); } this.checkCollectionUpdateCondition(model, prop, itemWriteConditionField, itemWriteCondition, index); if (index === undefined) { if (model[prop] === undefined) { model[prop] = [item]; } else { model[prop].push(item); } } else { model[prop][index] = item; } model._lastUpdate = updateDate; await this._save(model); } /** * Update an object * * If no attribute can be updated then return undefined * * @param {Object} Object to save * @param {Boolean} reverseMap internal use only, for disable map resolution * @return {Promise} with saved object */ async update(object, reverseMap = true, partial = false, conditionField, conditionValue) { /** @ignore */ let saved; let loaded; // Dont allow to update collections from map if (this._reverseMap != undefined && reverseMap) { for (let i in this._reverseMap) { if (object[this._reverseMap[i].property] != undefined) { delete object[this._reverseMap[i].property]; } } } if (Object.keys(object).length < 2) { return undefined; } object._lastUpdate = new Date(); this.metrics.operations_total.inc({ operation: "get" }); const uuid = object.getUuid ? object.getUuid() : object[this._uuidField]; let load = await this._getFromCache(uuid, true); if (load.__type !== this._modelType && this.parameters.strict) { this.log("WARN", `Object '${uuid}' was not created by this store ${load.__type}:${this._modelType}`); throw new StoreNotFoundError(uuid, this.getName()); } loaded = this.initModel(load); if (object instanceof CoreModel) { loaded.setContext(object.getContext()); } const update = object; const evt = { object: loaded, object_id: loaded.getUuid(), store: this, update }; await Promise.all([ this.emitSync(partial ? `Store.PatchUpdate` : `Store.Update`, evt), object?.__class?.emitSync(partial ? `Store.PatchUpdate` : `Store.Update`, evt), loaded._onUpdate(object) ]); let res; if (conditionField !== null) { conditionField ?? (conditionField = "_lastUpdate"); conditionValue ?? (conditionValue = load[conditionField]); } if (partial) { this.metrics.operations_total.inc({ operation: "partialUpdate" }); await this._patch(object, uuid, conditionValue, conditionField); res = object; } else { // Copy back the mappers for (let i in this._reverseMap) { object[this._reverseMap[i].property] = loaded[this._reverseMap[i].property]; } object = this.initModel(object); this.metrics.operations_total.inc({ operation: "update" }); res = await this._update(object, uuid, conditionValue, conditionField); } // Reinit save saved = this.initModel({ ...loaded, ...res }); const evtUpdated = { object: saved, object_id: saved.getUuid(), store: this, update, previous: loaded }; await Promise.all([ this.emitStoreEvent(partial ? `Store.PatchUpdated` : `Store.Updated`, evtUpdated), saved?.__class.emitSync(partial ? `Store.PatchUpdated` : `Store.Updated`, evtUpdated), saved._onUpdated() ]); return saved; } /** * Manage the store migration for __type case sensitivity */ async v3Migration() { // Compute case for all object await this.recomputeTypeCase(); // Compute all __types await this.recomputeTypes(); // We do not move to short id as it is not compatible with v2 } /** * */ async recomputeTypeShortId() { this.log("INFO", "Ensuring __type is using its short id form"); const app = this.getWebda().getApplication(); // We need to be laxist for migration this.parameters.strict = false; await this.migration("typesShortId", async (item) => { if (item.__type !== undefined && item.__type.includes("/")) { const model = app.getWebdaObject("models", item.__type); const name = app.getShortId(app.getModelName(model)); if (name !== item.__type) { this.log("INFO", "Migrating type " + item.__type + " to " + name); return { __type: name }; } } }); } /** * Ensure model aliases are not used in this store * * So alias can be cleaned */ async cleanModelAliases() { this.log("INFO", "Ensuring __type is not using any aliases"); // We need to be laxist for migration this.parameters.strict = false; await this.migration("cleanAliases", async (item) => { if (this.parameters.modelAliases[item.__type]) { this.log("INFO", "Migrating type " + item.__type + " to " + this.parameters.modelAliases[item.__type]); return { __type: this.parameters.modelAliases[item.__type] }; } }); } /** * Recompute type case */ async recomputeTypeCase() { this.log("INFO", "Ensuring __type is case sensitive from migration from v2.x"); const app = this.getWebda().getApplication(); // We need to be laxist for migration this.parameters.strict = false; await this.migration("typesCase", async (item) => { if (item.__type !== undefined) { if (!app.hasWebdaObject("models", item.__type, true) && app.hasWebdaObject("models", item.__type, false)) { const model = app.getWebdaObject("models", item.__type, false); const name = app.getModelName(model); if (model) { this.log("INFO", "Migrating type " + item.__type + " to " + name); return { __type: name }; } } } }); } /** * Recompute the __types for all objects (storeMigration.Registry.typesCompute) */ async recomputeTypes() { this.log("INFO", "Ensuring __types is correct from migration from v2.x"); // Update __types for each __type will be more efficient await this.migration("typesCompute", async (item) => { let __types = this.getWebda().getApplication().getModelTypes(item); if (!item.__types || item.__types.length !== __types.length || !__types.every((element, index) => element === item.__types[index])) { this.log("INFO", "Migrating types " + JSON.stringify(item.__types) + " to " + JSON.stringify(__types), "for", item.__type, item.getUuid()); return { __types }; } }); } /** * Delete a migration * @param name */ async cancelMigration(name) { await this.getWebda().getRegistry().delete(`storeMigration.${this.getName()}.${name}`); } /** * Get a migration * @param name */ async getMigration(name) { return await this.getWebda().getRegistry().get(`storeMigration.${this.getName()}.${name}`); } /** * Add a migration mechanism to store * @param name * @param patcher */ async migration(name, patcher, batchSize = 500) { let status = await this.getWebda().getRegistry().get(`storeMigration.${this.getName()}.${name}`, undefined, {}); status.count ?? (status.count = 0); status.updated ?? (status.updated = 0); const worker = new Throttler(20); do { const res = await this.query(status.continuationToken ? `LIMIT ${batchSize} OFFSET "${status.continuationToken}"` : `LIMIT ${batchSize}`); status.count += res.results.length; for (let item of res.results) { let updated = await patcher(item); if (updated !== undefined) { status.updated++; if (typeof updated === "function") { worker.queue(updated); } else { worker.queue(async () => { await item.patch(updated, null); }); } } } this.log("INFO", `storeMigration.${this.getName()}.${name}: Migrated ${status.count} items: ${status.updated} updated`); status.continuationToken = res.continuationToken; await worker.wait(); await status.save(); } while (status.continuationToken); } /** * Remove an attribute from an object * * @param uuid * @param attribute * @returns */ async removeAttribute(uuid, attribute, itemWriteCondition, itemWriteConditionField) { this.metrics.operations_total.inc({ operation: "attributeDelete" }); await this._removeAttribute(uuid, attribute, itemWriteCondition, itemWriteConditionField); await this.emitStoreEvent("Store.PartialUpdated", { object_id: uuid, partial_update: { deleteAttribute: attribute }, store: this }); } /** * Cascade delete a related object * * @param obj * @param uuid * @returns */ async cascadeDelete(obj, _uuid) { // We dont need uuid but Binary store will need it return this.delete(obj.getUuid()); } /** * Delete an object from the store without condition nor async * @param uid to delete * @returns */ async forceDelete(uid) { return this.delete(uid, undefined, undefined, true); } /** * Delete an object * * @param {String} uuid to delete * @param {Boolean} delete sync even if asyncDelete is active * @return {Promise} the deletion promise */ async delete(uid, writeCondition, writeConditionField, sync = false) { /** @ignore */ let to_delete; // Allow full object or just its uuid if (typeof uid === "object") { to_delete = uid; } else { this.metrics.operations_total.inc({ operation: "get" }); to_delete = await this._getFromCache(uid); if (to_delete === undefined) { return; } if (to_delete.__type !== this._modelType && this.parameters.strict) { this.log("WARN", `Object '${uid}' was not created by this store ${to_delete.__type}:${this._modelType}`); return; } to_delete = this.initModel(to_delete); } // Check condition as we have the object if (writeCondition) { if (to_delete[writeConditionField] !== writeCondition) { throw new UpdateConditionFailError(to_delete.getUuid(), writeConditionField, writeCondition); } } const evt = { object: to_delete, object_id: to_delete.getUuid(), store: this }; // Send preevent await Promise.all([ this.emitSync("Store.Delete", evt), to_delete?.__class.emitSync("Store.Delete", evt), to_delete._onDelete() ]); // If async we just tag the object as deleted if (this.parameters.asyncDelete && !sync) { this.metrics.operations_total.inc({ operation: "partialUpdate" }); await this._patch({ __deleted: true }, to_delete.getUuid()); await this._cacheStore?._patch({ __deleted: true }, to_delete.getUuid()); } else { this.metrics.operations_total.inc({ operation: "delete" }); // Delete from the DB for real await this._delete(to_delete.getUuid(), writeCondition, writeConditionField); await this._cacheStore?._delete(to_delete.getUuid(), writeCondition, writeConditionField); } // Send post event const evtDeleted = { object: to_delete, object_id: to_delete.getUuid(), store: this }; await Promise.all([ this.emitStoreEvent("Store.Deleted", evtDeleted), to_delete.__class.emitSync("Store.Deleted", evtDeleted), to_delete._onDeleted() ]); } /** * By default we cannot know if the store will trigger or not * * @param id * @param callback */ canTriggerConfiguration(_id, _callback) { return false; } /** * Provide a way to store configuration in store * @param {string} id * @returns {Promise<Map<string, any>>} */ async getConfiguration(id) { this.metrics.operations_total.inc({ operation: "get" }); let object = await this._getFromCache(id); if (!object) { return undefined; } let result = {}; for (let i in object) { if (i === this._uuidField || i === "_lastUpdate" || i.startsWith("_")) { continue; } result[i] = object[i]; } return result; } /** * Upsert the uuid object * @param uuid * @param data */ async put(uuid, data) { if (await this.exists(uuid)) { return this.update({ ...data, uuid }); } return this.save(data instanceof CoreModel ? data.setUuid(uuid) : { ...data, uuid }); } /** * Get an object * * @param {String} uuid to get * @return {Promise} the object retrieved ( can be undefined if not found ) */ async get(uid, ctx = undefined, defaultValue = undefined) { /** @ignore */ if (!uid) { return undefined; } this.metrics.operations_total.inc({ operation: "get" }); let object = await this._getFromCache(uid); if (!object) { return defaultValue ? this.initModel(defaultValue).setUuid(uid) : undefined; } if (object.__type !== this._modelType && this.parameters.strict) { this.log("WARN", `Object '${uid}' was not created by this store ${object.__type}:${this._modelType}`); return undefined; } object = this.initModel(object); object.setContext(ctx); const evt = { object: object, object_id: object.getUuid(), store: this, context: ctx }; await Promise.all([this.emitSync("Store.Get", evt), object.__class.emi