@squiz/db-lib 1.68.0 → 1.70.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.
- package/CHANGELOG.md +16 -0
- package/lib/dynamodb/AbstractDynamoDbRepository.d.ts +3 -0
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.d.ts +2 -0
- package/lib/error/InvalidDataFormatError.d.ts +5 -0
- package/lib/index.js +1721 -28
- package/lib/index.js.map +4 -4
- package/package.json +2 -1
- package/src/dynamodb/AbstractDynamoDbRepository.spec.ts +44 -0
- package/src/dynamodb/AbstractDynamoDbRepository.ts +29 -2
- package/src/dynamodb/DynamoDbManager.ts +21 -4
- package/src/error/InvalidDataFormatError.ts +8 -0
- package/tsconfig.tsbuildinfo +1 -1
    
        package/package.json
    CHANGED
    
    | @@ -1,6 +1,6 @@ | |
| 1 1 | 
             
            {
         | 
| 2 2 | 
             
              "name": "@squiz/db-lib",
         | 
| 3 | 
            -
              "version": "1. | 
| 3 | 
            +
              "version": "1.70.0",
         | 
| 4 4 | 
             
              "description": "",
         | 
| 5 5 | 
             
              "main": "lib/index.js",
         | 
| 6 6 | 
             
              "private": false,
         | 
| @@ -35,6 +35,7 @@ | |
| 35 35 | 
             
                "@aws-sdk/client-dynamodb": "^3.632.0",
         | 
| 36 36 | 
             
                "@aws-sdk/client-secrets-manager": "3.614.0",
         | 
| 37 37 | 
             
                "@aws-sdk/lib-dynamodb": "^3.632.0",
         | 
| 38 | 
            +
                "@opentelemetry/api": "^1.6.0",
         | 
| 38 39 | 
             
                "@squiz/dx-common-lib": "^1.66.4",
         | 
| 39 40 | 
             
                "@squiz/dx-logger-lib": "^1.64.0",
         | 
| 40 41 | 
             
                "dotenv": "16.0.3",
         | 
| @@ -23,6 +23,7 @@ import { mockClient } from 'aws-sdk-client-mock'; | |
| 23 23 | 
             
            import 'aws-sdk-client-mock-jest';
         | 
| 24 24 | 
             
            import { DynamoDB } from '@aws-sdk/client-dynamodb';
         | 
| 25 25 | 
             
            import crypto from 'crypto';
         | 
| 26 | 
            +
            import { InvalidDataFormatError } from '../error/InvalidDataFormatError';
         | 
| 26 27 |  | 
| 27 28 | 
             
            const ddbClientMock = mockClient(DynamoDBDocumentClient);
         | 
| 28 29 | 
             
            const ddbDoc = DynamoDBDocument.from(new DynamoDB({}));
         | 
| @@ -32,6 +33,7 @@ interface ITestItem { | |
| 32 33 | 
             
              age: number;
         | 
| 33 34 | 
             
              country: string;
         | 
| 34 35 | 
             
              data?: object;
         | 
| 36 | 
            +
              data2?: object;
         | 
| 35 37 | 
             
            }
         | 
| 36 38 |  | 
| 37 39 | 
             
            class TestItem implements ITestItem {
         | 
| @@ -39,12 +41,14 @@ class TestItem implements ITestItem { | |
| 39 41 | 
             
              public age: number;
         | 
| 40 42 | 
             
              public country: string;
         | 
| 41 43 | 
             
              public data: object;
         | 
| 44 | 
            +
              public data2?: object;
         | 
| 42 45 |  | 
| 43 46 | 
             
              constructor(data: Partial<ITestItem> = {}) {
         | 
| 44 47 | 
             
                this.name = data.name ?? 'default name';
         | 
| 45 48 | 
             
                this.age = data.age ?? 0;
         | 
| 46 49 | 
             
                this.country = data.country ?? 'default country';
         | 
| 47 50 | 
             
                this.data = data.data ?? {};
         | 
| 51 | 
            +
                this.data2 = data.data2 ?? {};
         | 
| 48 52 |  | 
| 49 53 | 
             
                if (typeof this.name !== 'string') {
         | 
| 50 54 | 
             
                  throw Error('Invalid "name"');
         | 
| @@ -92,6 +96,8 @@ const TEST_ITEM_ENTITY_DEFINITION = { | |
| 92 96 | 
             
                  },
         | 
| 93 97 | 
             
                },
         | 
| 94 98 | 
             
              },
         | 
| 99 | 
            +
              // field to be stored as JSON string
         | 
| 100 | 
            +
              fieldsAsJsonString: ['data2'],
         | 
| 95 101 | 
             
            };
         | 
| 96 102 |  | 
| 97 103 | 
             
            class TestItemRepository extends AbstractDynamoDbRepository<ITestItem, TestItem> {
         | 
| @@ -134,6 +140,8 @@ describe('AbstractRepository', () => { | |
| 134 140 | 
             
                      age: 99,
         | 
| 135 141 | 
             
                      country: 'au',
         | 
| 136 142 | 
             
                      data: {},
         | 
| 143 | 
            +
                      // "data2" property is defined to be stored as JSON string
         | 
| 144 | 
            +
                      data2: '{"foo":"bar","num":123}',
         | 
| 137 145 | 
             
                    },
         | 
| 138 146 | 
             
                    ConditionExpression: `attribute_not_exists(pk)`,
         | 
| 139 147 | 
             
                  };
         | 
| @@ -143,6 +151,10 @@ describe('AbstractRepository', () => { | |
| 143 151 | 
             
                    age: 99,
         | 
| 144 152 | 
             
                    country: 'au',
         | 
| 145 153 | 
             
                    data: {},
         | 
| 154 | 
            +
                    data2: {
         | 
| 155 | 
            +
                      foo: 'bar',
         | 
| 156 | 
            +
                      num: 123,
         | 
| 157 | 
            +
                    },
         | 
| 146 158 | 
             
                  };
         | 
| 147 159 | 
             
                  const result = await repository.createItem(item);
         | 
| 148 160 | 
             
                  expect(ddbClientMock).toHaveReceivedCommandWith(PutCommand, input);
         | 
| @@ -152,6 +164,10 @@ describe('AbstractRepository', () => { | |
| 152 164 | 
             
                      age: 99,
         | 
| 153 165 | 
             
                      country: 'au',
         | 
| 154 166 | 
             
                      data: {},
         | 
| 167 | 
            +
                      data2: {
         | 
| 168 | 
            +
                        foo: 'bar',
         | 
| 169 | 
            +
                        num: 123,
         | 
| 170 | 
            +
                      },
         | 
| 155 171 | 
             
                    }),
         | 
| 156 172 | 
             
                  );
         | 
| 157 173 | 
             
                });
         | 
| @@ -367,6 +383,7 @@ describe('AbstractRepository', () => { | |
| 367 383 | 
             
                      age: 99,
         | 
| 368 384 | 
             
                      country: 'au',
         | 
| 369 385 | 
             
                      data: {},
         | 
| 386 | 
            +
                      data2: '{"foo":"bar","num":123}',
         | 
| 370 387 | 
             
                    },
         | 
| 371 388 | 
             
                  });
         | 
| 372 389 | 
             
                  const input: GetCommandInput = {
         | 
| @@ -383,6 +400,10 @@ describe('AbstractRepository', () => { | |
| 383 400 | 
             
                      age: 99,
         | 
| 384 401 | 
             
                      country: 'au',
         | 
| 385 402 | 
             
                      data: {},
         | 
| 403 | 
            +
                      data2: {
         | 
| 404 | 
            +
                        foo: 'bar',
         | 
| 405 | 
            +
                        num: 123,
         | 
| 406 | 
            +
                      },
         | 
| 386 407 | 
             
                    }),
         | 
| 387 408 | 
             
                  );
         | 
| 388 409 | 
             
                });
         | 
| @@ -427,6 +448,29 @@ describe('AbstractRepository', () => { | |
| 427 448 | 
             
                    new MissingKeyValuesError('Key field "name" must be specified in the input item in entity test-item-entity'),
         | 
| 428 449 | 
             
                  );
         | 
| 429 450 | 
             
                });
         | 
| 451 | 
            +
             | 
| 452 | 
            +
                it('should throw error if JSON string field has non-string data', async () => {
         | 
| 453 | 
            +
                  ddbClientMock.on(GetCommand).resolves({
         | 
| 454 | 
            +
                    $metadata: {
         | 
| 455 | 
            +
                      httpStatusCode: 200,
         | 
| 456 | 
            +
                    },
         | 
| 457 | 
            +
                    Item: {
         | 
| 458 | 
            +
                      name: 'foo',
         | 
| 459 | 
            +
                      age: 99, // should be number
         | 
| 460 | 
            +
                      country: 'au',
         | 
| 461 | 
            +
                      data: {},
         | 
| 462 | 
            +
                      data2: {
         | 
| 463 | 
            +
                        foo: 'bar',
         | 
| 464 | 
            +
                        num: 123,
         | 
| 465 | 
            +
                      },
         | 
| 466 | 
            +
                    },
         | 
| 467 | 
            +
                  });
         | 
| 468 | 
            +
             | 
| 469 | 
            +
                  const partialItem = { name: 'foo' };
         | 
| 470 | 
            +
                  await expect(repository.getItem(partialItem)).rejects.toEqual(
         | 
| 471 | 
            +
                    new InvalidDataFormatError(`Field 'data2' defined as JSON String has a non-string data`),
         | 
| 472 | 
            +
                  );
         | 
| 473 | 
            +
                });
         | 
| 430 474 | 
             
              });
         | 
| 431 475 |  | 
| 432 476 | 
             
              describe('queryItems()', () => {
         | 
| @@ -7,6 +7,7 @@ import { | |
| 7 7 | 
             
            } from '@aws-sdk/lib-dynamodb';
         | 
| 8 8 |  | 
| 9 9 | 
             
            import { Transaction, DynamoDbManager, MissingKeyValuesError, InvalidDbSchemaError } from '..';
         | 
| 10 | 
            +
            import { InvalidDataFormatError } from '../error/InvalidDataFormatError';
         | 
| 10 11 |  | 
| 11 12 | 
             
            interface Reader<T> {
         | 
| 12 13 | 
             
              queryItems(partialItem: Partial<T>, useSortKey?: boolean, index?: keyof TableIndexes): Promise<T[]>;
         | 
| @@ -41,6 +42,7 @@ export type KeysFormat = Record<keyof TableKeys | keyof TableIndexes, string>; | |
| 41 42 | 
             
            export type EntityDefinition = {
         | 
| 42 43 | 
             
              keys: TableKeys;
         | 
| 43 44 | 
             
              indexes: TableIndexes;
         | 
| 45 | 
            +
              fieldsAsJsonString: string[];
         | 
| 44 46 | 
             
            };
         | 
| 45 47 |  | 
| 46 48 | 
             
            export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLASS extends SHAPE>
         | 
| @@ -52,6 +54,9 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS | |
| 52 54 | 
             
              protected indexes: TableIndexes;
         | 
| 53 55 | 
             
              protected keysFormat: KeysFormat;
         | 
| 54 56 |  | 
| 57 | 
            +
              // fields listed in this property are stored as a JSON string value in db
         | 
| 58 | 
            +
              protected fieldsAsJsonString: string[];
         | 
| 59 | 
            +
             | 
| 55 60 | 
             
              constructor(
         | 
| 56 61 | 
             
                protected tableName: string,
         | 
| 57 62 | 
             
                protected dbManager: DynamoDbManager<Repositories>,
         | 
| @@ -63,6 +68,7 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS | |
| 63 68 |  | 
| 64 69 | 
             
                this.keys = entityDefinition.keys;
         | 
| 65 70 | 
             
                this.indexes = entityDefinition.indexes;
         | 
| 71 | 
            +
                this.fieldsAsJsonString = entityDefinition.fieldsAsJsonString;
         | 
| 66 72 |  | 
| 67 73 | 
             
                this.keysFormat = {
         | 
| 68 74 | 
             
                  [this.keys.pk.attributeName]: this.keys.pk.format,
         | 
| @@ -170,7 +176,10 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS | |
| 170 176 | 
             
                  return undefined;
         | 
| 171 177 | 
             
                }
         | 
| 172 178 |  | 
| 173 | 
            -
                 | 
| 179 | 
            +
                const value = { ...oldValue, ...newValue };
         | 
| 180 | 
            +
                this.assertValueMatchesModel(value);
         | 
| 181 | 
            +
             | 
| 182 | 
            +
                this.convertSelectedValuesToJsonString(newValue);
         | 
| 174 183 |  | 
| 175 184 | 
             
                const updateExpression = [];
         | 
| 176 185 | 
             
                const expressionAttributeNames: Record<string, string> = {};
         | 
| @@ -203,7 +212,7 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS | |
| 203 212 | 
             
                  this.dbManager.addWriteTransactionItem(transaction.id, {
         | 
| 204 213 | 
             
                    Update: updateCommandInput,
         | 
| 205 214 | 
             
                  });
         | 
| 206 | 
            -
                  return new this.classRef( | 
| 215 | 
            +
                  return new this.classRef(value);
         | 
| 207 216 | 
             
                }
         | 
| 208 217 |  | 
| 209 218 | 
             
                let output: UpdateCommandOutput;
         | 
| @@ -243,6 +252,7 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS | |
| 243 252 | 
             
                for (const modelProperty of Object.keys(value)) {
         | 
| 244 253 | 
             
                  columns[modelProperty] = value[modelProperty as keyof DATA_CLASS];
         | 
| 245 254 | 
             
                }
         | 
| 255 | 
            +
                this.convertSelectedValuesToJsonString(columns);
         | 
| 246 256 |  | 
| 247 257 | 
             
                const keyFields: Record<string, unknown> = {
         | 
| 248 258 | 
             
                  [this.keys.pk.attributeName]: this.getPk(value),
         | 
| @@ -322,9 +332,26 @@ export abstract class AbstractDynamoDbRepository<SHAPE extends object, DATA_CLAS | |
| 322 332 | 
             
               * @returns
         | 
| 323 333 | 
             
               */
         | 
| 324 334 | 
             
              protected hydrateItem(item: Record<string, unknown>): DATA_CLASS {
         | 
| 335 | 
            +
                for (const fieldName of Object.keys(item)) {
         | 
| 336 | 
            +
                  if (this.fieldsAsJsonString.includes(fieldName)) {
         | 
| 337 | 
            +
                    if (typeof item[fieldName] === 'string') {
         | 
| 338 | 
            +
                      item[fieldName] = JSON.parse(item[fieldName] as string);
         | 
| 339 | 
            +
                    } else {
         | 
| 340 | 
            +
                      throw new InvalidDataFormatError(`Field '${fieldName}' defined as JSON String has a non-string data`);
         | 
| 341 | 
            +
                    }
         | 
| 342 | 
            +
                  }
         | 
| 343 | 
            +
                }
         | 
| 325 344 | 
             
                return new this.classRef(item);
         | 
| 326 345 | 
             
              }
         | 
| 327 346 |  | 
| 347 | 
            +
              protected convertSelectedValuesToJsonString(item: Record<string, unknown>) {
         | 
| 348 | 
            +
                for (const fieldName of Object.keys(item)) {
         | 
| 349 | 
            +
                  if (this.fieldsAsJsonString.includes(fieldName)) {
         | 
| 350 | 
            +
                    item[fieldName] = JSON.stringify(item[fieldName]);
         | 
| 351 | 
            +
                  }
         | 
| 352 | 
            +
                }
         | 
| 353 | 
            +
              }
         | 
| 354 | 
            +
             | 
| 328 355 | 
             
              /**
         | 
| 329 356 | 
             
               * Evaluate the partition key value from the partial item
         | 
| 330 357 | 
             
               * @param item
         | 
| @@ -1,6 +1,9 @@ | |
| 1 1 | 
             
            import { DynamoDBDocument, TransactWriteCommandInput } from '@aws-sdk/lib-dynamodb';
         | 
| 2 2 | 
             
            import { randomUUID } from 'crypto';
         | 
| 3 3 | 
             
            import { TransactionError } from '../error/TransactionError';
         | 
| 4 | 
            +
            import { trace } from '@opentelemetry/api';
         | 
| 5 | 
            +
             | 
| 6 | 
            +
            const tracer = trace.getTracer('db-lib:DynamoDbManager');
         | 
| 4 7 |  | 
| 5 8 | 
             
            export type Transaction = {
         | 
| 6 9 | 
             
              id?: string;
         | 
| @@ -47,10 +50,24 @@ export class DynamoDbManager<TRepositories> { | |
| 47 50 | 
             
                  throw new TransactionError(`No items in transaction '${transactionId}' to execute`);
         | 
| 48 51 | 
             
                }
         | 
| 49 52 |  | 
| 50 | 
            -
                return  | 
| 51 | 
            -
                   | 
| 52 | 
            -
                   | 
| 53 | 
            -
             | 
| 53 | 
            +
                return tracer.startActiveSpan(
         | 
| 54 | 
            +
                  'executeTransaction',
         | 
| 55 | 
            +
                  {
         | 
| 56 | 
            +
                    attributes: {
         | 
| 57 | 
            +
                      'transactionItems.length': this.transactionItems[transactionId].length,
         | 
| 58 | 
            +
                    },
         | 
| 59 | 
            +
                  },
         | 
| 60 | 
            +
                  async (span) => {
         | 
| 61 | 
            +
                    try {
         | 
| 62 | 
            +
                      return await this.client.transactWrite({
         | 
| 63 | 
            +
                        ClientRequestToken: transactionId,
         | 
| 64 | 
            +
                        TransactItems: this.transactionItems[transactionId],
         | 
| 65 | 
            +
                      });
         | 
| 66 | 
            +
                    } finally {
         | 
| 67 | 
            +
                      span.end();
         | 
| 68 | 
            +
                    }
         | 
| 69 | 
            +
                  },
         | 
| 70 | 
            +
                );
         | 
| 54 71 | 
             
              }
         | 
| 55 72 |  | 
| 56 73 | 
             
              private startTransaction(transactionId: string) {
         |