UNPKG

dynamodb-ts-model

Version:

A DynamoDB model/client with full TypeScript typings

335 lines 14.8 kB
"use strict"; var __await = (this && this.__await) || function (v) { return this instanceof __await ? (this.v = v, this) : new __await(v); } var __asyncGenerator = (this && this.__asyncGenerator) || function (thisArg, _arguments, generator) { if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined."); var g = generator.apply(thisArg, _arguments || []), i, q = []; return i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i; function verb(n) { if (g[n]) i[n] = function (v) { return new Promise(function (a, b) { q.push([n, v, a, b]) > 1 || resume(n, v); }); }; } function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } } function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r); } function fulfill(value) { resume("next", value); } function reject(value) { resume("throw", value); } function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); } }; Object.defineProperty(exports, "__esModule", { value: true }); exports.DynamoModelBuilder = exports.DynamoModel = exports.ModelOptions = void 0; const lib_dynamodb_1 = require("@aws-sdk/lib-dynamodb"); const dynamodb_expressions_1 = require("dynamodb-expressions"); const DynamoWrapper_1 = require("./DynamoWrapper"); const requests_1 = require("./requests"); const utils_1 = require("./utils"); class ModelOptions { } exports.ModelOptions = ModelOptions; /** * A model representing a DynamoDB table. * Type params: * * T represents the data stored in the table. * * K represents the table key definition, which is either a single or a tuple key * * I represents a dictionary of index names to index key definitions * * B represents data being automatically created by one or more creator functions, e.g. automatic timestamp generators. * This type may contain parts of T, meaning that added items only need to contain attributes of T * which aren't in type B. */ class DynamoModel extends DynamoWrapper_1.DynamoWrapper { /** * Check if an error returned by a DynamoModel is a conditional check failed error * @param err */ static isConditionalCheckFailed(err) { return (err === null || err === void 0 ? void 0 : err.name) === 'ConditionalCheckFailedException'; } constructor(client, name, tableName, params) { super(client); this.name = name; this.tableName = tableName; this.params = params; } convertItem(item, projection) { const { converters } = this.params; if (converters) { for (const converter of converters) { converter(item, projection); } } return item; } convertItems(items, projection) { const { converters } = this.params; if (converters) { for (const item of items) { for (const converter of converters) { converter(item, projection); } } } return items; } /** * Get a single item * @param params */ async get(params) { const { Item: item } = await this.command(new lib_dynamodb_1.GetCommand((0, requests_1.createGetRequest)(this, params))); if (item) { return this.convertItem(item, params.projection); } } /** * Perform a scan operation, i.e., a query without any key condition, and return a page of items. * @param params */ async scan(params = {}) { const { Items: items = [], LastEvaluatedKey: lastKey } = await this.command(new lib_dynamodb_1.ScanCommand((0, requests_1.createScanRequest)(this, params))); return { items: this.convertItems(items, params.projection), nextPageToken: (0, utils_1.formatPageToken)(lastKey), }; } /** * Perform a scan operation, i.e., a query without any key condition, and return an item iterator. * @param params */ scanIterator(params = {}) { return __asyncGenerator(this, arguments, function* scanIterator_1() { const p = Object.assign({}, params); do { const { items, nextPageToken } = yield __await(this.scan(p)); for (const item of items) { yield yield __await(item); } p.pageToken = nextPageToken; } while (p.pageToken); }); } /** * Perform a query operation with a key condition, and return a page of items. * @param params */ async query(params) { const { Items: items = [], LastEvaluatedKey: lastKey } = await this.command(new lib_dynamodb_1.QueryCommand((0, requests_1.createQueryRequest)(this, params))); return { items: this.convertItems(items, params.projection), nextPageToken: (0, utils_1.formatPageToken)(lastKey), }; } /** * Perform a query operation with a key condition, and return an item iterator. * @param params */ queryIterator(params) { return __asyncGenerator(this, arguments, function* queryIterator_1() { const p = Object.assign({}, params); do { const { items, nextPageToken } = yield __await(this.query(p)); for (const item of items) { yield yield __await(item); } p.pageToken = nextPageToken; } while (p.pageToken); }); } /** * Put (upsert) an item. If no item with the same key exists, a new item is created; otherwise the existing item is * replaced. * Note that if the model has any creator functions, attributes of T which are also in B do not need to be provided, * such as generated timestamps, auto-generated IDs etc. * @param params */ async put(params) { await this.command(new lib_dynamodb_1.PutCommand((0, requests_1.createPutRequest)(this, params))); const item = this.convertItem(params.item); this.params.triggers.forEach(trigger => trigger(item, 'put', this)); return { item }; } async update(params) { const { Attributes: attributes } = await this.command(new lib_dynamodb_1.UpdateCommand((0, requests_1.createUpdateRequest)(this, params))); const item = this.convertItem(attributes); this.params.triggers.forEach(trigger => trigger(item, 'update', this)); return { item }; } /** * Delete an item * @param params */ async delete(params) { const { Attributes: attributes } = await this.command(new lib_dynamodb_1.DeleteCommand((0, requests_1.createDeleteRequest)(this, params))); const item = this.convertItem(attributes); this.params.triggers.forEach(trigger => trigger(item, 'delete', this)); } /** * Perform an atomic read-modify-write action which fetches an item and calls the supplied function with a key, * the existing item if it exists, and a set of conditions used to verify that the item hasn't been changed * concurrently between the get and the performed action. * The function should update the model using those arguments, using e.g. put() or update(). * * If the action fails due to a conditional check failed error, after a delay the item will be fetched again and * the function called again, up to a certain number of attempts. * * This enables putting or updating an item without overwriting data in case of concurrent modifications. * It relies on the conditionAttribute having a unique value after each update, such as a random version assigned * on each modification or a timestamp of sufficient accuracy being refreshed on each modification. * @param params * @param params.key Key of the item to perform the action on * @param params.conditionAttribute Name of attribute to condition the action on * @param [params.maxAttempts] Max number of attempts * @param action Function called to perform the action on the item */ async atomicAction(params, action) { var _a, _b; const { key, conditionAttribute, maxAttempts = 5 } = params; for (let attempt = 0; attempt < maxAttempts; attempt++) { const item = await this.get({ key }); try { return await action({ key, item, conditions: { [conditionAttribute]: (_a = item === null || item === void 0 ? void 0 : item[conditionAttribute]) !== null && _a !== void 0 ? _a : dynamodb_expressions_1.Condition.attributeNotExists() } }); } catch (err) { (_b = this.logger) === null || _b === void 0 ? void 0 : _b.debug({ attempt, err }, 'Atomic action attempt failed'); if (!DynamoModel.isConditionalCheckFailed(err)) { throw err; } await new Promise(resolve => setTimeout(resolve, Math.random() * 100)); } } throw new Error('Atomic action failed after max attempts'); } } exports.DynamoModel = DynamoModel; /** * A model builder */ class DynamoModelBuilder { constructor(client, name, tableName) { this.client = client; this.name = name; this.tableName = tableName; this.params = { indices: {}, creators: [], updaters: [], triggers: [] }; } /** * Define the key attribute(s) of this model * @param keyAttributes One or two attribute names identifying the HASH and RANGE keys of the table */ withKey(...keyAttributes) { const builder = this; builder.params.keyAttributes = keyAttributes; return builder; } /** * Add an index to this model * @param name Name of the index * @param indexAttributes One or two attribute names identifying the HASH and RANGE keys of the index */ withIndex(name, ...indexAttributes) { const builder = this; builder.params.indices[name] = indexAttributes; return builder; } /** * Add an item creator function that adds or modifies item attributes prior to calling put. * This can for example be used to automatically create timestamps or auto-generated IDs when creating items. * @param creator A function that may modify items being put. */ // Ideally this would be item: WrittenItem<T, _B> but then _B cannot be inferred from the return type withCreator(creator) { const builder = this; builder.params.creators.push(creator); return builder; } /** * Add an item updater function that adds or modifies item attributes prior to calling update. * This can for example be used to automatically update timestamps when updating items. * @param updater A function that may modify items being updated. */ withUpdater(updater) { this.params.updaters.push(updater); return this; } /** * Set a converter function to convert items read from the database to the proper type, e.g. to convert legacy items * missing some attributes added later. This will be called for every item returned by a model operation. * Note that the function should modify the passed item. * @param converter */ withConverter(converter) { if (!this.params.converters) { this.params.converters = [converter]; } else { this.params.converters.push(converter); } return this; } /** * Define default values for stored legacy items * @param values An object containing default values to assign to returned model items missing these properties. */ withDefaultValues(values) { return this.withConverter(item => { for (const [k, v] of Object.entries(values)) { if (item[k] === undefined) { item[k] = v; } } }); } /** * Add a trigger to be called after each successful table write operation. * @param trigger */ withTrigger(trigger) { this.params.triggers.push(trigger); return this; } /** * Build an instance of the model * If the builder was created via the static method `DynamoClient.model()`, the options `client`, `name` and `tableName` * must be supplied now. * If the builder was created via the instance method `client.model(name, tableName)`, the arguments may be omitted, * but if present they will override any options supplied when the builder was created. */ build(options = {}) { const { client = this.client || (0, utils_1.error)('client not supplied'), name = this.name || (0, utils_1.error)('name not supplied'), tableName = this.tableName || name } = options; return new DynamoModel(client, name, tableName, this.params); } /** * Create a class for the model. This is convenient as it also creates a type that can be easily referred to instead * of complex generic types such as DynamoModel<MyItem, 'id', {modifiedTime: string}> etc. * * If the builder was created via the static method `DynamoClient.model()`, the options `client`, `name` and `tableName` * must be supplied either as arguments to this method or as arguments to the returned constructor function on each instance * creation. * If the builder was created via the instance method `client.model(name, tableName)`, the arguments may be omitted both * from this method and the returned constructor function, but if present they will override any options supplied to * this method or when the builder was created. * * Usage: * class PersonModel extends DynamoClient.model<Person>() * .withKey('id') * .withIndex('name-age-index', 'name', 'age') * .class() {} * * const persons = new PersonModel({client, name: 'foo'}); */ class(options = {}) { const builder = Object.assign(Object.assign({}, this), options); return class extends DynamoModel { constructor(options = {}) { const { client = builder.client || (0, utils_1.error)('client not supplied'), name = builder.name || (0, utils_1.error)('name not supplied'), tableName = builder.tableName || name } = options; super(client, name, tableName, builder.params); } }; } } exports.DynamoModelBuilder = DynamoModelBuilder; //# sourceMappingURL=DynamoModel.js.map