@squiz/db-lib 1.65.0 → 1.66.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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';