dynamodb-ts-model
Version:
A DynamoDB model/client with full TypeScript typings
335 lines • 14.8 kB
JavaScript
"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