@squiz/db-lib 1.64.0 → 1.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,445 @@
1
+ import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
2
+
3
+ import {
4
+ DynamoDBDocument,
5
+ QueryCommandInput,
6
+ UpdateCommandOutput,
7
+ PutCommandInput,
8
+ DeleteCommandInput,
9
+ } from '@aws-sdk/lib-dynamodb';
10
+
11
+ import { Transaction, DynamoDbManager, MissingKeyValuesError, DuplicateItemError, InvalidDbSchemaError } from '..';
12
+
13
+ interface Reader<T> {
14
+ queryItems(partialItem: Partial<T>, useSortKey?: boolean, index?: keyof TableIndexes): Promise<T[]>;
15
+ getItem(id: string | Partial<T>): Promise<T | undefined>;
16
+ }
17
+
18
+ interface Writer<T> {
19
+ createItem(item: Partial<T>): Promise<T>;
20
+ updateItem(partialItem: Partial<T>, newValue: Partial<T>): Promise<T | undefined>;
21
+ deleteItem(partialItem: Partial<T>): Promise<number>;
22
+ }
23
+
24
+ type Repository<T> = Reader<T> & Writer<T>;
25
+
26
+ type Repositories = Record<string, Repository<any>>;
27
+
28
+ export type TableKeys = {
29
+ pk: {
30
+ attributeName: string;
31
+ format: string;
32
+ };
33
+ sk: {
34
+ attributeName: string;
35
+ format: string;
36
+ };
37
+ };
38
+
39
+ export type TableIndexes = Record<string, TableKeys>;
40
+
41
+ export type KeysFormat = Record<keyof TableKeys | keyof TableIndexes, string>;
42
+
43
+ export type EntityDefinition = {
44
+ keys: TableKeys;
45
+ indexes: TableIndexes;
46
+ };
47
+
48
+ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLASS extends SHAPE>
49
+ implements Reader<SHAPE>, Writer<SHAPE>
50
+ {
51
+ protected client: DynamoDBDocument;
52
+
53
+ protected keys: TableKeys;
54
+ protected indexes: TableIndexes;
55
+ protected keysFormat: KeysFormat;
56
+
57
+ constructor(
58
+ protected tableName: string,
59
+ protected dbManager: DynamoDbManager<Repositories>,
60
+ protected entityName: string,
61
+ protected entityDefinition: EntityDefinition,
62
+ protected classRef: { new (data?: Record<string, unknown>): DATA_CLASS },
63
+ ) {
64
+ this.client = dbManager.client;
65
+
66
+ this.keys = entityDefinition.keys;
67
+ this.indexes = entityDefinition.indexes;
68
+
69
+ this.keysFormat = {
70
+ [this.keys.pk.attributeName]: this.keys.pk.format,
71
+ [this.keys.sk.attributeName]: this.keys.sk.format,
72
+ };
73
+ Object.keys(this.indexes).forEach((key) => {
74
+ const index = this.indexes[key];
75
+ this.keysFormat[index.pk.attributeName] = index.pk.format;
76
+ this.keysFormat[index.sk.attributeName] = index.sk.format;
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Get the single item matching the key fields value in the given
82
+ * partial item. Will throw MissingKeyValuesError if key field values
83
+ * are missing
84
+ *
85
+ * @param item
86
+ *
87
+ * @throws MissingKeyValuesError
88
+ */
89
+ public async getItem(item: Partial<SHAPE>): Promise<DATA_CLASS | undefined> {
90
+ const output = await this.client.get({
91
+ TableName: this.tableName,
92
+ Key: {
93
+ [this.keys.pk.attributeName]: this.getPk(item),
94
+ [this.keys.sk.attributeName]: this.getSk(item),
95
+ },
96
+ });
97
+ if (output.Item === undefined) {
98
+ return undefined;
99
+ }
100
+ return this.hydrateItem(output.Item);
101
+ }
102
+
103
+ /**
104
+ * Finds all the items matching the partition key or
105
+ * the gsi key (when gsi index name is specified)
106
+ *
107
+ * @param item
108
+ * @param useSortKey
109
+ * @param index
110
+ * @throws MissingKeyValuesError
111
+ */
112
+ public async queryItems(
113
+ item: Partial<SHAPE>,
114
+ useSortKey: boolean = false,
115
+ index?: keyof TableIndexes,
116
+ ): Promise<DATA_CLASS[]> {
117
+ let pkName = this.keys.pk.attributeName;
118
+ let skName = this.keys.sk.attributeName;
119
+ let indexName = null;
120
+
121
+ if (index) {
122
+ if (this.indexes[index] === undefined) {
123
+ throw new MissingKeyValuesError(`Table index '${index}' not defined on entity ${this.entityName}`);
124
+ }
125
+ indexName = index;
126
+ pkName = this.indexes[index].pk.attributeName;
127
+ skName = this.indexes[index].sk.attributeName;
128
+ }
129
+
130
+ const pk = this.getKey(item, pkName);
131
+ const keyConditionExpression = ['#pkName = :pkValue'];
132
+ const expressionAttributeNames: Record<string, string> = { '#pkName': pkName };
133
+ const expressionAttributeValues: Record<string, unknown> = { ':pkValue': pk };
134
+ if (useSortKey) {
135
+ const sk = this.getKey(item, skName);
136
+ keyConditionExpression.push('#skName = :skValue');
137
+ expressionAttributeNames['#skName'] = skName;
138
+ expressionAttributeValues[':skValue'] = sk;
139
+ }
140
+
141
+ const queryCommandInput: QueryCommandInput = {
142
+ TableName: this.tableName,
143
+ KeyConditionExpression: keyConditionExpression.join(' AND '),
144
+ ExpressionAttributeNames: expressionAttributeNames,
145
+ ExpressionAttributeValues: expressionAttributeValues,
146
+ };
147
+ if (indexName) {
148
+ queryCommandInput['IndexName'] = String(indexName);
149
+ }
150
+ const output = await this.client.query(queryCommandInput);
151
+
152
+ return !output.Items ? [] : output.Items.map((item) => this.hydrateItem(item));
153
+ }
154
+
155
+ /**
156
+ * Update the existing item matching the key fields value
157
+ * in the passed in partialItem
158
+ * @param partialItem
159
+ * @param newValue
160
+ * @param transaction
161
+ *
162
+ * @returns Promise<SHAPE | undefined>
163
+ * @throws MissingKeyValuesError
164
+ */
165
+ public async updateItem(
166
+ partialItem: Partial<SHAPE>,
167
+ newValue: Exclude<Partial<SHAPE>, Record<string, never>>,
168
+ transaction: Transaction = {},
169
+ ): Promise<DATA_CLASS | undefined> {
170
+ const oldValue = await this.getItem(partialItem);
171
+ if (oldValue === undefined) {
172
+ return undefined;
173
+ }
174
+
175
+ this.assertValueMatchesModel({ ...oldValue, ...newValue });
176
+
177
+ const updateExpression = [];
178
+ const expressionAttributeNames: Record<string, string> = {};
179
+ const expressionAttributeValues: Record<string, unknown> = {};
180
+ for (const modelProperty of Object.keys(newValue)) {
181
+ const propName = `#${modelProperty}`;
182
+ const propValue = `:${modelProperty}`;
183
+
184
+ updateExpression.push(`${propName} = ${propValue}`);
185
+ expressionAttributeNames[propName] = modelProperty;
186
+
187
+ expressionAttributeValues[propValue] = newValue[modelProperty as keyof SHAPE];
188
+ }
189
+
190
+ const updateCommandInput = {
191
+ TableName: this.tableName,
192
+ Key: {
193
+ [this.keys.pk.attributeName]: this.getPk(partialItem),
194
+ [this.keys.sk.attributeName]: this.getSk(partialItem),
195
+ },
196
+ UpdateExpression: 'SET ' + updateExpression.join(','),
197
+ ExpressionAttributeValues: expressionAttributeValues,
198
+ ExpressionAttributeNames: expressionAttributeNames,
199
+ ConditionExpression: `attribute_exists(${this.keys.pk.attributeName})`,
200
+ };
201
+
202
+ if (transaction.id?.length) {
203
+ // this command will be executed together with
204
+ // other db write commands in the "transaction" block
205
+ this.dbManager.addWriteTransactionItem(transaction.id, {
206
+ Update: updateCommandInput,
207
+ });
208
+ return new this.classRef({ ...oldValue, ...newValue });
209
+ }
210
+
211
+ let output: UpdateCommandOutput;
212
+ try {
213
+ output = await this.client.update({
214
+ ...updateCommandInput,
215
+ ReturnValues: 'ALL_NEW',
216
+ });
217
+ } catch (e) {
218
+ if (e instanceof ConditionalCheckFailedException) {
219
+ return undefined;
220
+ }
221
+ throw e;
222
+ }
223
+
224
+ let item: DATA_CLASS | undefined = undefined;
225
+ if (output.Attributes) {
226
+ item = this.hydrateItem(output.Attributes);
227
+ }
228
+ return item ? item : undefined;
229
+ }
230
+
231
+ /**
232
+ * Adds new item to the table
233
+ *
234
+ * @param value
235
+ * @param transaction
236
+ *
237
+ * @returns Promise<SHAPE>
238
+ * @throws DuplicateItemError
239
+ * @throws MissingKeyValuesError
240
+ */
241
+ public async createItem(value: DATA_CLASS, transaction: Transaction = {}): Promise<DATA_CLASS> {
242
+ this.assertValueMatchesModel(value);
243
+
244
+ const columns: any = {};
245
+ for (const modelProperty of Object.keys(value)) {
246
+ columns[modelProperty] = value[modelProperty as keyof DATA_CLASS];
247
+ }
248
+
249
+ const keyFields: Record<string, unknown> = {
250
+ [this.keys.pk.attributeName]: this.getPk(value),
251
+ [this.keys.sk.attributeName]: this.getSk(value),
252
+ };
253
+
254
+ Object.keys(this.indexes).forEach((key) => {
255
+ const index = this.indexes[key];
256
+ keyFields[index.pk.attributeName] = this.getKey(value, index.pk.attributeName);
257
+ keyFields[index.sk.attributeName] = this.getKey(value, index.sk.attributeName);
258
+ });
259
+
260
+ const putCommandInput: PutCommandInput = {
261
+ TableName: this.tableName,
262
+ Item: {
263
+ ...keyFields,
264
+ ...columns,
265
+ },
266
+ ConditionExpression: `attribute_not_exists(${this.keys.pk.attributeName})`,
267
+ };
268
+
269
+ if (transaction.id?.length) {
270
+ // this command will be executed together with
271
+ // other db write commands in the "transaction block"
272
+ this.dbManager.addWriteTransactionItem(transaction.id, {
273
+ Put: putCommandInput,
274
+ });
275
+ return value;
276
+ }
277
+
278
+ try {
279
+ await this.client.put(putCommandInput);
280
+ } catch (e) {
281
+ if (e instanceof ConditionalCheckFailedException) {
282
+ throw new DuplicateItemError(`Item already exists`);
283
+ }
284
+ throw e;
285
+ }
286
+ return value;
287
+ }
288
+
289
+ /**
290
+ * Deletes an item from the table
291
+ *
292
+ * @param partialItem
293
+ * @param transaction
294
+ * @returns number
295
+ * @throw MissingKeyValuesError
296
+ */
297
+ public async deleteItem(partialItem: Partial<SHAPE>, transaction: Transaction = {}): Promise<number> {
298
+ const deleteCommandInput: DeleteCommandInput = {
299
+ TableName: this.tableName,
300
+ Key: {
301
+ [this.keys.pk.attributeName]: this.getPk(partialItem),
302
+ [this.keys.sk.attributeName]: this.getSk(partialItem),
303
+ },
304
+ ConditionExpression: `attribute_exists(${this.keys.pk.attributeName})`,
305
+ };
306
+
307
+ if (transaction.id?.length) {
308
+ // this command will be executed together with
309
+ // other db write commands in the "transaction block"
310
+ this.dbManager.addWriteTransactionItem(transaction.id, {
311
+ Delete: deleteCommandInput,
312
+ });
313
+ return 1;
314
+ }
315
+
316
+ try {
317
+ await this.client.delete(deleteCommandInput);
318
+ } catch (e) {
319
+ if (e instanceof ConditionalCheckFailedException) {
320
+ return 0;
321
+ }
322
+ throw e;
323
+ }
324
+ return 1;
325
+ }
326
+
327
+ /**
328
+ * Return repo model object from the db value
329
+ * @param item
330
+ * @returns
331
+ */
332
+ protected hydrateItem(item: Record<string, unknown>): DATA_CLASS {
333
+ return new this.classRef(item);
334
+ }
335
+
336
+ /**
337
+ * Evaluate the partition key value from the partial item
338
+ * @param item
339
+ * @returns string
340
+ * @throw MissingKeyValuesError
341
+ */
342
+ protected getPk(item: Partial<SHAPE>): string {
343
+ return this.getKey(item, this.keys.pk.attributeName);
344
+ }
345
+
346
+ /**
347
+ * Evaluate the sort key value from the partial item
348
+ * @param item
349
+ * @returns string
350
+ *
351
+ * @throw MissingKeyValuesError
352
+ */
353
+ protected getSk(item: Partial<SHAPE>): string {
354
+ return this.getKey(item, this.keys.sk.attributeName);
355
+ }
356
+
357
+ /**
358
+ * Evaluate the key value from the
359
+ *
360
+ * Example 1:
361
+ * Input:
362
+ * - item: {id: foo, name: 'some-name' }
363
+ * - attributeName: pk
364
+ * - this.keysFormat = { pk: 'item#{id}', 'sk': '#meta', ... }
365
+ * Output:
366
+ * - 'item#foo'
367
+ *
368
+ * Example 2:
369
+ * Input:
370
+ * - item: {id: foo, name: 'some-name', itemType: 'A' }
371
+ * - attributeName: sk
372
+ * - this.keysFormat = { pk: 'item#{id}', 'sk': 'type#{itemType}', ... }
373
+ * Output:
374
+ * - 'type#A'
375
+ *
376
+ * Example 3:
377
+ * Input:
378
+ * - item: {id: foo, name: 'some-name' }
379
+ * - attributeName: sk
380
+ * - this.keysFormat = { pk: 'item#{id}', 'sk': 'name-type#{itemType}{name}', ... }
381
+ * Output:
382
+ * - Error: "Key field "itemType" must be specified in the input item"
383
+ *
384
+ * @param item
385
+ * @param attributeName
386
+ *
387
+ * @returns string
388
+ * @throw MissingKeyValuesError
389
+ */
390
+ protected getKey(item: Partial<SHAPE>, attributeName: keyof KeysFormat): string {
391
+ let keyFormat = this.keysFormat[attributeName];
392
+ if (keyFormat == undefined || !keyFormat.length) {
393
+ throw new MissingKeyValuesError(
394
+ `Key format not defined or empty for key attribute '${attributeName}' in entity ${this.entityName}`,
395
+ );
396
+ }
397
+
398
+ const matches = keyFormat.match(/{[a-zA-Z]+?}/g);
399
+ const replacements: { property: keyof SHAPE; placeholder: string }[] = !matches
400
+ ? []
401
+ : matches.map((match) => {
402
+ return {
403
+ property: match.slice(1, -1) as keyof SHAPE,
404
+ placeholder: match,
405
+ };
406
+ });
407
+
408
+ for (let i = 0; i < replacements.length; i++) {
409
+ const field = replacements[i].property;
410
+ if (item[field] === undefined) {
411
+ throw new MissingKeyValuesError(
412
+ `Key field "${String(field)}" must be specified in the input item in entity ${this.entityName}`,
413
+ );
414
+ }
415
+ keyFormat = keyFormat.replace(replacements[i].placeholder, String(item[field] ?? ''));
416
+ }
417
+
418
+ const moreMatches = keyFormat.match(/{[a-zA-Z]+?}/g);
419
+ if (moreMatches?.length) {
420
+ throw new MissingKeyValuesError(
421
+ `Cannot resolve key placeholder(s) for key attribute format '${this.keysFormat[attributeName]} in entity ${
422
+ this.entityName
423
+ }: '${moreMatches.join("','")}'`,
424
+ );
425
+ }
426
+ return keyFormat;
427
+ }
428
+
429
+ /**
430
+ * Validate the data matches with "DATA_MODEL"
431
+ * @param value
432
+ * @return void
433
+ */
434
+ private assertValueMatchesModel(value: unknown) {
435
+ // Trigger AssertionError if model instantiation fails
436
+ // see the DATA_CLASS model class
437
+ const obj = new this.classRef(value as Record<string, any>);
438
+ const inputProperties = Object.keys(value as object);
439
+ const modelProperties = Object.keys(obj);
440
+ const excessProperties = inputProperties.filter((prop) => !modelProperties.includes(prop));
441
+ if (excessProperties.length > 0) {
442
+ throw new InvalidDbSchemaError(`Excess properties in entity ${this.entityName}: ${excessProperties.join(', ')}`);
443
+ }
444
+ }
445
+ }
@@ -0,0 +1,66 @@
1
+ import { DynamoDBDocument, TransactWriteCommandInput } from '@aws-sdk/lib-dynamodb';
2
+ import { randomUUID } from 'crypto';
3
+ import { TransactionError } from '../error/TransactionError';
4
+
5
+ export type Transaction = {
6
+ id?: string;
7
+ };
8
+
9
+ type TransactionItems = TransactWriteCommandInput['TransactItems'];
10
+ export type TransactionItem = NonNullable<TransactionItems>[number];
11
+
12
+ export class DynamoDbManager<TRepositories> {
13
+ private transactionItems: Record<string, TransactionItem[]>;
14
+ public repositories: TRepositories;
15
+
16
+ constructor(
17
+ public client: DynamoDBDocument,
18
+ repositoryCreator: (dbManager: DynamoDbManager<TRepositories>) => TRepositories,
19
+ ) {
20
+ this.transactionItems = {};
21
+ this.repositories = repositoryCreator(this);
22
+ }
23
+
24
+ public async executeInTransaction<T>(func: (transaction: Transaction) => Promise<T>): Promise<T> {
25
+ const transactionId = randomUUID();
26
+
27
+ try {
28
+ this.startTransaction(transactionId);
29
+ const value = await func({ id: transactionId });
30
+ await this.executeTransaction(transactionId);
31
+ return value;
32
+ } finally {
33
+ this.closeTransaction(transactionId);
34
+ }
35
+ }
36
+
37
+ public addWriteTransactionItem(transactionId: string, item: TransactionItem) {
38
+ if (this.transactionItems[transactionId] === undefined) {
39
+ throw new TransactionError(`No items in transaction '${transactionId}' to add transaction item to`);
40
+ }
41
+
42
+ this.transactionItems[transactionId].push(item);
43
+ }
44
+
45
+ private async executeTransaction(transactionId: string) {
46
+ if (this.transactionItems[transactionId] === undefined) {
47
+ throw new TransactionError(`No items in transaction '${transactionId}' to execute`);
48
+ }
49
+
50
+ return await this.client.transactWrite({
51
+ ClientRequestToken: transactionId,
52
+ TransactItems: this.transactionItems[transactionId],
53
+ });
54
+ }
55
+
56
+ private startTransaction(transactionId: string) {
57
+ if (this.transactionItems[transactionId] !== undefined) {
58
+ throw new TransactionError(`Transaction '${transactionId}' already started`);
59
+ }
60
+ this.transactionItems[transactionId] = [];
61
+ }
62
+
63
+ private closeTransaction(transactionId: string) {
64
+ delete this.transactionItems[transactionId];
65
+ }
66
+ }
@@ -0,0 +1,8 @@
1
+ import { InternalServerError } from '@squiz/dx-common-lib';
2
+
3
+ export class DuplicateItemError extends InternalServerError {
4
+ name = 'DuplicateItemError';
5
+ constructor(message: string) {
6
+ super(message);
7
+ }
8
+ }
@@ -0,0 +1,8 @@
1
+ import { BadRequestError } from '@squiz/dx-common-lib';
2
+
3
+ export class InvalidDbSchemaError extends BadRequestError {
4
+ name = 'InvalidDbSchemaError';
5
+ constructor(message: string) {
6
+ super(message);
7
+ }
8
+ }
@@ -0,0 +1,8 @@
1
+ import { InternalServerError } from '@squiz/dx-common-lib';
2
+
3
+ export class MissingKeyValuesError extends InternalServerError {
4
+ name = 'MissingKeyValuesError';
5
+ constructor(message: string) {
6
+ super(message);
7
+ }
8
+ }
@@ -0,0 +1,8 @@
1
+ import { InternalServerError } from '@squiz/dx-common-lib';
2
+
3
+ export class TransactionError extends InternalServerError {
4
+ name = 'TransactionError';
5
+ constructor(message: string) {
6
+ super(message);
7
+ }
8
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  export * from './AbstractRepository';
2
2
  export * from './ConnectionManager';
3
+ export * from './dynamodb/DynamoDbManager';
4
+ export * from './dynamodb/AbstractDynamoDbRepository';
3
5
  export * from './Migrator';
4
6
  export * from './Repositories';
5
7
  export * from './getConnectionInfo';
8
+ export * from './error/DuplicateItemError';
9
+ export * from './error/TransactionError';
10
+ export * from './error/MissingKeyValuesError';
11
+ export * from './error/InvalidDbSchemaError';
6
12
 
7
13
  // Postgres
8
14
  export * from './PostgresErrorCodes';