UNPKG

botbuilder-azure

Version:

Azure extensions for Microsoft BotBuilder.

324 lines 16.2 kB
"use strict"; /* eslint-disable @typescript-eslint/ban-types */ /** * @module botbuilder-azure */ /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.CosmosDbPartitionedStorage = void 0; const cosmos_1 = require("@azure/cosmos"); const cosmosDbKeyEscape_1 = require("./cosmosDbKeyEscape"); const doOnce_1 = require("./doOnce"); // eslint-disable-next-line @typescript-eslint/no-var-requires const pjson = require('../package.json'); const _doOnce = new doOnce_1.DoOnce(); const maxDepthAllowed = 127; //Internal data structure for storing items in a CosmosDB Collection. class DocumentStoreItem { // We can't make the partitionKey optional AND have it auto-get this.realId, so we'll use a constructor constructor(storeItem) { this.id = storeItem.id; this.realId = storeItem.realId; this.document = storeItem.document; this.eTag = storeItem.eTag; } /** * Gets the PartitionKey path to be used for this document type. * * @returns {string} the partition key path */ static get partitionKeyPath() { return '/id'; } /** * Gets the PartitionKey value for the document. * * @returns {string} the partition key */ get partitionKey() { return this.id; } } /** * Implements a CosmosDB based storage provider using partitioning for a bot. */ class CosmosDbPartitionedStorage { /** * Initializes a new instance of the <see cref="CosmosDbPartitionedStorage"/> class. * using the provided CosmosDB credentials, database ID, and container ID. * * @param {CosmosDbPartitionedStorageOptions} cosmosDbStorageOptions Cosmos DB partitioned storage configuration options. */ constructor(cosmosDbStorageOptions) { var _a, _b, _c; this.cosmosDbStorageOptions = cosmosDbStorageOptions; this.compatibilityModePartitionKey = false; if (!cosmosDbStorageOptions) { throw new ReferenceError('CosmosDbPartitionedStorageOptions is required.'); } const { cosmosClientOptions } = cosmosDbStorageOptions; (_a = cosmosDbStorageOptions.cosmosDbEndpoint) !== null && _a !== void 0 ? _a : (cosmosDbStorageOptions.cosmosDbEndpoint = cosmosClientOptions === null || cosmosClientOptions === void 0 ? void 0 : cosmosClientOptions.endpoint); if (!cosmosDbStorageOptions.cosmosDbEndpoint) { throw new ReferenceError('cosmosDbEndpoint for CosmosDB is required.'); } (_b = cosmosDbStorageOptions.authKey) !== null && _b !== void 0 ? _b : (cosmosDbStorageOptions.authKey = cosmosClientOptions === null || cosmosClientOptions === void 0 ? void 0 : cosmosClientOptions.key); if (!cosmosDbStorageOptions.authKey && !(cosmosClientOptions === null || cosmosClientOptions === void 0 ? void 0 : cosmosClientOptions.tokenProvider)) { throw new ReferenceError('authKey for CosmosDB is required.'); } if (!cosmosDbStorageOptions.databaseId) { throw new ReferenceError('databaseId is for CosmosDB required.'); } if (!cosmosDbStorageOptions.containerId) { throw new ReferenceError('containerId for CosmosDB is required.'); } // In order to support collections previously restricted to max key length of 255, we default // compatibilityMode to 'true'. No compatibilityMode is opt-in only. (_c = cosmosDbStorageOptions.compatibilityMode) !== null && _c !== void 0 ? _c : (cosmosDbStorageOptions.compatibilityMode = true); if (cosmosDbStorageOptions.keySuffix) { if (cosmosDbStorageOptions.compatibilityMode) { throw new ReferenceError('compatibilityMode cannot be true while using a keySuffix.'); } // In order to reduce key complexity, we do not allow invalid characters in a KeySuffix // If the keySuffix has invalid characters, the escaped key will not match const suffixEscaped = cosmosDbKeyEscape_1.CosmosDbKeyEscape.escapeKey(cosmosDbStorageOptions.keySuffix); if (cosmosDbStorageOptions.keySuffix !== suffixEscaped) { throw new ReferenceError(`Cannot use invalid Row Key characters: ${cosmosDbStorageOptions.keySuffix} in keySuffix`); } } } // Protects against JSON.stringify cycles toJSON() { return { name: 'CosmosDbPartitionedStorage' }; } /** * Read one or more items with matching keys from the Cosmos DB container. * * @param {string[]} keys A collection of Ids for each item to be retrieved. * @returns {Promise<StoreItems>} The read items. */ read(keys) { return __awaiter(this, void 0, void 0, function* () { if (!keys) { throw new ReferenceError('Keys are required when reading.'); } else if (keys.length === 0) { return {}; } yield this.initialize(); const storeItems = {}; yield Promise.all(keys.map((k) => __awaiter(this, void 0, void 0, function* () { try { const escapedKey = cosmosDbKeyEscape_1.CosmosDbKeyEscape.escapeKey(k, this.cosmosDbStorageOptions.keySuffix, this.cosmosDbStorageOptions.compatibilityMode); const readItemResponse = yield this.container .item(escapedKey, this.getPartitionKey(escapedKey)) .read(); const documentStoreItem = readItemResponse.resource; if (documentStoreItem) { storeItems[documentStoreItem.realId] = documentStoreItem.document; storeItems[documentStoreItem.realId].eTag = documentStoreItem._etag; } } catch (err) { // When an item is not found a CosmosException is thrown, but we want to // return an empty collection so in this instance we catch and do not rethrow. // Throw for any other exception. if (err.code === 404) { // no-op } // Throw unique error for 400s else if (err.code === 400) { this.throwInformativeError(`Error reading from container. You might be attempting to read from a non-partitioned container or a container that does not use '/id' as the partitionKeyPath`, err); } else { this.throwInformativeError('Error reading from container', err); } } }))); return storeItems; }); } /** * Insert or update one or more items into the Cosmos DB container. * * @param {StoreItems} changes Dictionary of items to be inserted or updated indexed by key. */ write(changes) { return __awaiter(this, void 0, void 0, function* () { if (!changes) { throw new ReferenceError('Changes are required when writing.'); } else if (changes.length === 0) { return; } yield this.initialize(); yield Promise.all(Object.entries(changes).map((_a) => { var key, _b, eTag, change; return __awaiter(this, void 0, void 0, function* () { [key, _b] = _a, { eTag } = _b, change = __rest(_b, ["eTag"]); const document = new DocumentStoreItem({ id: cosmosDbKeyEscape_1.CosmosDbKeyEscape.escapeKey(key, this.cosmosDbStorageOptions.keySuffix, this.cosmosDbStorageOptions.compatibilityMode), realId: key, document: change, }); const accessCondition = eTag !== '*' && eTag != null && eTag.length > 0 ? { accessCondition: { type: 'IfMatch', condition: eTag } } : undefined; try { yield this.container.items.upsert(document, accessCondition); } catch (err) { // This check could potentially be performed before even attempting to upsert the item // so that a request wouldn't be made to Cosmos if it's expected to fail. // However, performing the check here ensures that this custom exception is only thrown // if Cosmos returns an error first. // This way, the nesting limit is not imposed on the Bot Framework side // and no exception will be thrown if the limit is eventually changed on the Cosmos side. this.checkForNestingError(change, err); this.throwInformativeError('Error upserting document', err); } }); })); }); } /** * Delete one or more items from the Cosmos DB container. * * @param {string[]} keys Array of Ids for the items to be deleted. */ delete(keys) { return __awaiter(this, void 0, void 0, function* () { yield this.initialize(); yield Promise.all(keys.map((k) => __awaiter(this, void 0, void 0, function* () { const escapedKey = cosmosDbKeyEscape_1.CosmosDbKeyEscape.escapeKey(k, this.cosmosDbStorageOptions.keySuffix, this.cosmosDbStorageOptions.compatibilityMode); try { yield this.container.item(escapedKey, this.getPartitionKey(escapedKey)).delete(); } catch (err) { // If trying to delete a document that doesn't exist, do nothing. Otherwise, throw if (err.code === 404) { // no-op } else { this.throwInformativeError('Unable to delete document', err); } } }))); }); } /** * Connects to the CosmosDB database and creates / gets the container. */ initialize() { return __awaiter(this, void 0, void 0, function* () { if (!this.container) { if (!this.client) { this.client = new cosmos_1.CosmosClient(Object.assign({ endpoint: this.cosmosDbStorageOptions.cosmosDbEndpoint, key: this.cosmosDbStorageOptions.authKey, userAgentSuffix: `${pjson.name} ${pjson.version}` }, this.cosmosDbStorageOptions.cosmosClientOptions)); } const dbAndContainerKey = `${this.cosmosDbStorageOptions.databaseId}-${this.cosmosDbStorageOptions.containerId}`; this.container = yield _doOnce.waitFor(dbAndContainerKey, () => __awaiter(this, void 0, void 0, function* () { return yield this.getOrCreateContainer(); })); } }); } getOrCreateContainer() { return __awaiter(this, void 0, void 0, function* () { let createIfNotExists = !this.cosmosDbStorageOptions.compatibilityMode; let container; if (this.cosmosDbStorageOptions.compatibilityMode) { try { container = yield this.client .database(this.cosmosDbStorageOptions.databaseId) .container(this.cosmosDbStorageOptions.containerId); const partitionKeyPath = yield container.readPartitionKeyDefinition(); const paths = partitionKeyPath.resource.paths; if (paths) { // Containers created with CosmosDbStorage had no partition key set, so the default was '/_partitionKey'. if (paths.indexOf('/_partitionKey') !== -1) { this.compatibilityModePartitionKey = true; } else if (paths.indexOf(DocumentStoreItem.partitionKeyPath) === -1) { // We are not supporting custom Partition Key Paths. new Error(`Custom Partition Key Paths are not supported. ${this.cosmosDbStorageOptions.containerId} has a custom Partition Key Path of ${paths[0]}.`); } } else { this.compatibilityModePartitionKey = true; } return container; } catch (_a) { createIfNotExists = true; } } if (createIfNotExists) { const result = yield this.client .database(this.cosmosDbStorageOptions.databaseId) .containers.createIfNotExists({ id: this.cosmosDbStorageOptions.containerId, partitionKey: { paths: [DocumentStoreItem.partitionKeyPath], }, throughput: this.cosmosDbStorageOptions.containerThroughput, }); return result.container; } }); } getPartitionKey(key) { return this.compatibilityModePartitionKey ? undefined : key; } // Return an informative error message if upsert failed due to deeply nested data checkForNestingError(json, err) { const checkDepth = (obj, depth, isInDialogState) => { if (depth > maxDepthAllowed) { let message = `Maximum nesting depth of ${maxDepthAllowed} exceeded.`; if (isInDialogState) { message += ' This is most likely caused by recursive component dialogs. ' + 'Try reworking your dialog code to make sure it does not keep dialogs on the stack ' + "that it's not using. For example, consider using replaceDialog instead of beginDialog."; } else { message += ' Please check your data for signs of unintended recursion.'; } this.throwInformativeError(message, err); } else if (obj && typeof obj === 'object') { for (const [key, value] of Object.entries(obj)) { checkDepth(value, depth + 1, key === 'dialogStack' || isInDialogState); } } }; checkDepth(json, 0, false); } // The Cosmos JS SDK doesn't return very descriptive errors and not all errors contain a body. throwInformativeError(prependedMessage, err) { if (typeof err === 'string') { err = new Error(err); } err.message = `[${prependedMessage}] ${err.message}`; throw err; } } exports.CosmosDbPartitionedStorage = CosmosDbPartitionedStorage; //# sourceMappingURL=cosmosDbPartitionedStorage.js.map