@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@squiz/db-lib",
3
- "version": "1.68.0",
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
- this.assertValueMatchesModel({ ...oldValue, ...newValue });
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({ ...oldValue, ...newValue });
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 await this.client.transactWrite({
51
- ClientRequestToken: transactionId,
52
- TransactItems: this.transactionItems[transactionId],
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) {
@@ -0,0 +1,8 @@
1
+ import { InternalServerError } from '@squiz/dx-common-lib';
2
+
3
+ export class InvalidDataFormatError extends InternalServerError {
4
+ name = 'InvalidDataFormatError';
5
+ constructor(message: string) {
6
+ super(message);
7
+ }
8
+ }