botbuilder-azure
Version:
Azure extensions for Microsoft BotBuilder.
324 lines • 16.2 kB
JavaScript
"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