@squiz/db-lib 1.76.0 → 1.77.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/dynamodb/AbstractDynamoDbRepository.d.ts +1 -1
  3. package/lib/dynamodb/AbstractDynamoDbRepository.d.ts.map +1 -1
  4. package/lib/dynamodb/AbstractDynamoDbRepository.js +6 -5
  5. package/lib/dynamodb/AbstractDynamoDbRepository.js.map +1 -1
  6. package/lib/dynamodb/AbstractDynamoDbRepository.spec.d.ts.map +1 -1
  7. package/lib/dynamodb/AbstractDynamoDbRepository.spec.js +15 -14
  8. package/lib/dynamodb/AbstractDynamoDbRepository.spec.js.map +1 -1
  9. package/lib/dynamodb/getDynamoDbOptions.d.ts.map +1 -1
  10. package/lib/externalized/ExternalizedDynamoDbRepository.d.ts +151 -0
  11. package/lib/externalized/ExternalizedDynamoDbRepository.d.ts.map +1 -0
  12. package/lib/externalized/ExternalizedDynamoDbRepository.js +463 -0
  13. package/lib/externalized/ExternalizedDynamoDbRepository.js.map +1 -0
  14. package/lib/externalized/ExternalizedDynamoDbRepository.spec.d.ts +2 -0
  15. package/lib/externalized/ExternalizedDynamoDbRepository.spec.d.ts.map +1 -0
  16. package/lib/externalized/ExternalizedDynamoDbRepository.spec.js +218 -0
  17. package/lib/externalized/ExternalizedDynamoDbRepository.spec.js.map +1 -0
  18. package/lib/index.d.ts +2 -0
  19. package/lib/index.d.ts.map +1 -1
  20. package/lib/index.js +3 -0
  21. package/lib/index.js.map +1 -1
  22. package/lib/s3/S3ExternalStorage.d.ts +66 -0
  23. package/lib/s3/S3ExternalStorage.d.ts.map +1 -0
  24. package/lib/s3/S3ExternalStorage.js +84 -0
  25. package/lib/s3/S3ExternalStorage.js.map +1 -0
  26. package/lib/s3/S3ExternalStorage.spec.d.ts +12 -0
  27. package/lib/s3/S3ExternalStorage.spec.d.ts.map +1 -0
  28. package/lib/s3/S3ExternalStorage.spec.js +130 -0
  29. package/lib/s3/S3ExternalStorage.spec.js.map +1 -0
  30. package/package.json +7 -6
  31. package/src/dynamodb/AbstractDynamoDbRepository.spec.ts +2 -1
  32. package/src/dynamodb/AbstractDynamoDbRepository.ts +3 -1
  33. package/src/externalized/ExternalizedDynamoDbRepository.spec.ts +274 -0
  34. package/src/externalized/ExternalizedDynamoDbRepository.ts +545 -0
  35. package/src/index.ts +4 -0
  36. package/src/s3/S3ExternalStorage.spec.ts +181 -0
  37. package/src/s3/S3ExternalStorage.ts +118 -0
  38. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,545 @@
1
+ /*!
2
+ * @file ExternalizedDynamoDbRepository.ts
3
+ * @description This file contains the ExternalizedDynamoDbRepository class, which is used to store and retrieve externalized items from DynamoDB and S3.
4
+ * @author Dean Heffernan
5
+ * @copyright 2025 Squiz
6
+ */
7
+
8
+ // External
9
+ import { createHash } from 'crypto';
10
+ import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb';
11
+
12
+ // Team Submodules
13
+ import { AbstractDynamoDbRepository, EntityDefinition, QueryOptions } from '../dynamodb/AbstractDynamoDbRepository';
14
+ import { DynamoDbManager, Transaction } from '../dynamodb/DynamoDbManager';
15
+
16
+ // Local
17
+ import { S3ExternalStorage, S3StorageLocation } from '../s3/S3ExternalStorage';
18
+
19
+ /**
20
+ * The ExternalizedDynamoDbRepository class is used to store and retrieve externalized items from DynamoDB and S3.
21
+ * @class ExternalizedDynamoDbRepository
22
+ * @extends {AbstractDynamoDbRepository<SHAPE, DATA_CLASS>}
23
+ * @param {string} tableName - The name of the DynamoDB table to use for storing externalized items.
24
+ * @param {ContentDynamodbDbManager} dbManager - The DynamoDB database manager to use for storing externalized items.
25
+ * @param {string} entityName - The name of the entity to store externalized items for.
26
+ * @param {EntityDefinition} entityDefinition - The entity definition to use for storing externalized items.
27
+ * @param {DATA_CLASS} classRef - The class to use for storing externalized items.
28
+ * @param {S3ExternalStorage} storage - The S3 storage to use for storing externalized items.
29
+ * @returns {ExternalizedDynamoDbRepository} A new instance of the ExternalizedDynamoDbRepository class.
30
+ */
31
+ export abstract class ExternalizedDynamoDbRepository<
32
+ SHAPE extends object,
33
+ DATA_CLASS extends SHAPE & { storageLocation?: S3StorageLocation },
34
+ > extends AbstractDynamoDbRepository<SHAPE, DATA_CLASS> {
35
+ /**
36
+ * Creates a new instance of the ExternalizedDynamoDbRepository class.
37
+ * @constructor
38
+ * @param {string} tableName - The name of the DynamoDB table to use for storing externalized items.
39
+ * @param {DynamoDbManager} dbManager - The DynamoDB database manager to use for storing externalized items.
40
+ * @param {string} entityName - The name of the entity to store externalized items for.
41
+ * @param {EntityDefinition} entityDefinition - The entity definition to use for storing externalized items.
42
+ * @param {DATA_CLASS} classRef - The class to use for storing externalized items.
43
+ * @param {S3ExternalStorage} storage - The S3 storage to use for storing externalized items.
44
+ * @returns {ExternalizedDynamoDbRepository} A new instance of the ExternalizedDynamoDbRepository class.
45
+ */
46
+ constructor(
47
+ tableName: string,
48
+ dbManager: DynamoDbManager<any>,
49
+ entityName: string,
50
+ entityDefinition: EntityDefinition,
51
+ classRef: { new (data?: Record<string, unknown>): DATA_CLASS },
52
+ private readonly storage: S3ExternalStorage,
53
+ ) {
54
+ super(tableName, dbManager, entityName, entityDefinition, classRef);
55
+ }
56
+
57
+ /**
58
+ * Creates a new item in the repository.
59
+ * If the item exceeds DynamoDB's size limit, it will automatically externalize the content to S3.
60
+ * @param {DATA_CLASS} value - The value to create the item for.
61
+ * @param {Transaction} transaction - The transaction to use for creating the item.
62
+ * @param {Partial<SHAPE>} additionalValue - Additional value to use for creating the item.
63
+ * @param {Object} options - The options to use for creating the item.
64
+ * @returns {Promise<DATA_CLASS>} A promise that resolves to the created item.
65
+ */
66
+ public async createItem(
67
+ value: DATA_CLASS,
68
+ transaction: Transaction = {},
69
+ additionalValue: Partial<SHAPE> = {},
70
+ options: { overrideExisting?: boolean } = { overrideExisting: false },
71
+ ): Promise<DATA_CLASS> {
72
+ try {
73
+ return await this.createItemInternal(value, transaction, additionalValue, options);
74
+ } catch (error) {
75
+ // If the item exceeds DynamoDB's size limit, externalize the content to S3 and retry
76
+ if (this.isDynamoItemSizeLimitError(error)) {
77
+ console.warn(
78
+ `ExternalizedDynamoDbRepository: Item exceeded DynamoDB size limit for ${this.entityName}. Retrying with external storage.`,
79
+ );
80
+ const externalizedValue = await this.prepareValueForStorage(value);
81
+ return await this.createItemInternal(externalizedValue, transaction, additionalValue, options);
82
+ }
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Internal method to create an item in the repository.
89
+ * @param {DATA_CLASS} value - The value to create the item for.
90
+ * @param {Transaction} transaction - The transaction to use for creating the item.
91
+ * @param {Partial<SHAPE>} additionalValue - Additional value to use for creating the item.
92
+ * @param {Object} options - The options to use for creating the item.
93
+ * @returns {Promise<DATA_CLASS>} A promise that resolves to the created item.
94
+ */
95
+ private async createItemInternal(
96
+ value: DATA_CLASS,
97
+ transaction: Transaction = {},
98
+ additionalValue: Partial<SHAPE> = {},
99
+ options: { overrideExisting?: boolean } = { overrideExisting: false },
100
+ ): Promise<DATA_CLASS> {
101
+ let previousLocation: S3StorageLocation | undefined;
102
+ if (options?.overrideExisting) {
103
+ previousLocation = await this.fetchStoredLocation({ ...value, ...additionalValue });
104
+ }
105
+
106
+ // If storageLocation exists, we need to save it directly to DynamoDB
107
+ // We can't use super.createItem because it strips storageLocation during model validation
108
+ // So we build the DynamoDB Item manually
109
+ let createdItem: DATA_CLASS;
110
+
111
+ if (value.storageLocation) {
112
+ // Manually validate the value (without storageLocation)
113
+ const valueWithoutStorageLocation = { ...value };
114
+ delete (valueWithoutStorageLocation as any).storageLocation;
115
+ new this.classRef(valueWithoutStorageLocation as Record<string, unknown>);
116
+
117
+ // Build columns from value properties
118
+ const columns: any = {};
119
+ for (const modelProperty of Object.keys(valueWithoutStorageLocation)) {
120
+ columns[modelProperty] = valueWithoutStorageLocation[modelProperty as keyof DATA_CLASS];
121
+ }
122
+
123
+ // Convert fields defined as JSON strings (like 'layouts') to JSON strings
124
+ this.convertSelectedValuesToJsonString(columns);
125
+
126
+ // Manually add storageLocation to columns (after JSON conversion)
127
+ columns.storageLocation = value.storageLocation;
128
+
129
+ // Build key fields
130
+ const keyFields: Record<string, unknown> = {
131
+ [this.keys.pk.attributeName]: this.getPk({ ...value, ...additionalValue }),
132
+ [this.keys.sk.attributeName]: this.getSk({ ...value, ...additionalValue }),
133
+ };
134
+
135
+ // Add index fields
136
+ for (const [_key, index] of Object.entries(this.indexes)) {
137
+ try {
138
+ keyFields[index.pk.attributeName] = this.getKey({ ...value, ...additionalValue }, index.pk.attributeName);
139
+ keyFields[index.sk.attributeName] = this.getKey({ ...value, ...additionalValue }, index.sk.attributeName);
140
+ } catch (e) {
141
+ // Ignore optional index errors
142
+ if ((e as Error).name === 'MissingKeyValuesError') {
143
+ continue;
144
+ }
145
+ throw e;
146
+ }
147
+ }
148
+
149
+ // Execute DynamoDB put
150
+ const putCommandInput = {
151
+ TableName: this.tableName,
152
+ Item: {
153
+ ...keyFields,
154
+ ...columns,
155
+ },
156
+ ConditionExpression: options.overrideExisting
157
+ ? undefined
158
+ : `attribute_not_exists(${this.keys.pk.attributeName})`,
159
+ };
160
+
161
+ if (transaction.id) {
162
+ // Add to transaction instead of executing directly
163
+ this.dbManager.addWriteTransactionItem(transaction.id, {
164
+ Put: putCommandInput,
165
+ });
166
+ } else {
167
+ await this.client.put(putCommandInput);
168
+ }
169
+ createdItem = value;
170
+ } else {
171
+ // No storageLocation, use parent's method
172
+ // Strip storageLocation before passing to parent to avoid "Excess properties" error
173
+ const { storageLocation: _, ...valueWithoutStorage } = value as any;
174
+ createdItem = await super.createItem(valueWithoutStorage as DATA_CLASS, transaction, additionalValue, options);
175
+ }
176
+
177
+ // Clean up old S3 file if it exists and is different from new location
178
+ const newLocation = createdItem.storageLocation;
179
+ if (previousLocation && previousLocation.key !== newLocation?.key) {
180
+ await this.storage.delete(previousLocation);
181
+ }
182
+
183
+ // If item was externalized, hydrate it from S3 to return full content
184
+ return (await this.hydrateFromExternalStorage(createdItem)) || createdItem;
185
+ }
186
+
187
+ /**
188
+ * Updates an item in the repository.
189
+ * If the item exceeds DynamoDB's size limit, it will automatically externalize the content to S3.
190
+ * @param {Partial<SHAPE>} value - The value to update the item with.
191
+ * @returns {Promise<DATA_CLASS | undefined>} A promise that resolves to the updated item.
192
+ */
193
+ public async updateItem(value: Partial<SHAPE>): Promise<DATA_CLASS | undefined> {
194
+ try {
195
+ return await this.updateItemInternal(value);
196
+ } catch (error) {
197
+ // If the item exceeds DynamoDB's size limit, externalize the content to S3 and retry
198
+ if (this.isDynamoItemSizeLimitError(error)) {
199
+ console.warn(
200
+ `ExternalizedDynamoDbRepository: Update exceeded DynamoDB size limit for ${this.entityName}. Retrying with external storage.`,
201
+ );
202
+ const externalizedValue = await this.prepareValueForStorage(value as DATA_CLASS);
203
+ return await this.updateItemInternal(externalizedValue);
204
+ }
205
+ throw error;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Internal method to update an item in the repository.
211
+ * @param {Partial<SHAPE>} value - The value to update the item with.
212
+ * @returns {Promise<DATA_CLASS | undefined>} A promise that resolves to the updated item.
213
+ */
214
+ private async updateItemInternal(value: Partial<SHAPE>): Promise<DATA_CLASS | undefined> {
215
+ // Fetch previous storageLocation before update
216
+ const previousLocation = await this.fetchStoredLocation(value);
217
+
218
+ // If storageLocation exists in the update, we need to save it directly to DynamoDB
219
+ // We can't use super.updateItem because it strips storageLocation during model validation
220
+ let updatedItem: DATA_CLASS | undefined;
221
+
222
+ if ((value as DATA_CLASS).storageLocation) {
223
+ // Get old value first
224
+ const oldValue = await super.getItem(value);
225
+ if (!oldValue) {
226
+ return undefined;
227
+ }
228
+
229
+ // Merge old and new values
230
+ const mergedValue = { ...oldValue, ...value };
231
+
232
+ // Validate merged value (without storageLocation)
233
+ const mergedWithoutStorageLocation = { ...mergedValue };
234
+ delete (mergedWithoutStorageLocation as any).storageLocation;
235
+ new this.classRef(mergedWithoutStorageLocation as Record<string, unknown>);
236
+
237
+ // Convert fields defined as JSON strings (like 'layouts') to JSON strings
238
+ const newValueCopy = { ...value };
239
+ this.convertSelectedValuesToJsonString(newValueCopy);
240
+ this.convertSelectedValuesToJsonString(oldValue as Record<string, unknown>);
241
+
242
+ // Build UpdateExpression manually including storageLocation
243
+ const updateExpression = [];
244
+ const expressionAttributeNames: Record<string, string> = {};
245
+ const expressionAttributeValues: Record<string, unknown> = {};
246
+ const updatedAttributes: string[] = [];
247
+
248
+ for (const modelProperty of Object.keys(value)) {
249
+ const propValue = (newValueCopy as any)[modelProperty];
250
+
251
+ if (propValue === (oldValue as any)[modelProperty]) {
252
+ continue; // don't update unchanged properties
253
+ }
254
+
255
+ const propName = `#${modelProperty}`;
256
+ const propValuePlaceHolder = `:${modelProperty}`;
257
+
258
+ updateExpression.push(`${propName} = ${propValuePlaceHolder}`);
259
+ expressionAttributeNames[propName] = modelProperty;
260
+ expressionAttributeValues[propValuePlaceHolder] = propValue;
261
+ updatedAttributes.push(modelProperty);
262
+ }
263
+
264
+ if (!updatedAttributes.length) {
265
+ // nothing to update
266
+ updatedItem = mergedValue;
267
+ } else {
268
+ // Execute update
269
+ const updateCommandInput = {
270
+ TableName: this.tableName,
271
+ Key: {
272
+ [this.keys.pk.attributeName]: this.getPk(value),
273
+ [this.keys.sk.attributeName]: this.getSk(value),
274
+ },
275
+ UpdateExpression: `SET ${updateExpression.join(', ')}`,
276
+ ExpressionAttributeNames: expressionAttributeNames,
277
+ ExpressionAttributeValues: expressionAttributeValues,
278
+ ConditionExpression: `attribute_exists(${this.keys.pk.attributeName})`,
279
+ ReturnValues: 'ALL_NEW' as const,
280
+ };
281
+
282
+ const result = await this.client.update(updateCommandInput);
283
+ updatedItem = result.Attributes ? this.hydrateItem(result.Attributes) : undefined;
284
+ }
285
+ } else {
286
+ // No storageLocation, use parent's method
287
+ // Strip storageLocation before passing to parent to avoid "Excess properties" error
288
+ const { storageLocation: _, ...valueWithoutStorage } = value as any;
289
+ updatedItem = await super.updateItem(valueWithoutStorage);
290
+ }
291
+
292
+ if (!updatedItem) {
293
+ return undefined;
294
+ }
295
+
296
+ // Clean up old S3 file if it exists and is different from new location
297
+ const newLocation = updatedItem.storageLocation;
298
+ if (previousLocation && previousLocation.key !== newLocation?.key) {
299
+ await this.storage.delete(previousLocation);
300
+ }
301
+
302
+ // If item was externalized, hydrate it from S3 to return full content
303
+ return (await this.hydrateFromExternalStorage(updatedItem)) || updatedItem;
304
+ }
305
+
306
+ /**
307
+ * Gets an item from the repository.
308
+ * @param {Partial<SHAPE>} item - The item to get.
309
+ * @returns {Promise<DATA_CLASS | undefined>} A promise that resolves to the item.
310
+ */
311
+ public async getItem(item: Partial<SHAPE>): Promise<DATA_CLASS | undefined> {
312
+ const record = await super.getItem(item);
313
+ return await this.hydrateFromExternalStorage(record);
314
+ }
315
+
316
+ /**
317
+ * Gets items from the repository.
318
+ * @param {Partial<SHAPE>[]} items - The items to get.
319
+ * @returns {Promise<DATA_CLASS[]>} A promise that resolves to the items.
320
+ */
321
+ public async getItems(items: Partial<SHAPE>[]): Promise<DATA_CLASS[]> {
322
+ const records = await super.getItems(items);
323
+ return (await Promise.all(records.map((record) => this.hydrateFromExternalStorage(record)))) as DATA_CLASS[];
324
+ }
325
+
326
+ /**
327
+ * Queries items from the repository.
328
+ * @param {Partial<SHAPE>} item - The item to query.
329
+ * @param {QueryOptions} options - The options to use for querying the items.
330
+ * @returns {Promise<DATA_CLASS[]>} A promise that resolves to the items.
331
+ */
332
+ public async queryItems(item: Partial<SHAPE>, options?: QueryOptions): Promise<DATA_CLASS[]> {
333
+ const records = await super.queryItems(item, options);
334
+ return (await Promise.all(records.map((record) => this.hydrateFromExternalStorage(record)))) as DATA_CLASS[];
335
+ }
336
+
337
+ /**
338
+ * Deletes an item from the repository.
339
+ * @param {Partial<SHAPE>} partialItem - The item to delete.
340
+ * @param {Transaction} transaction - The transaction to use for deleting the item.
341
+ * @returns {Promise<number>} A promise that resolves to the number of items deleted.
342
+ */
343
+ public async deleteItem(partialItem: Partial<SHAPE>, transaction: Transaction = {}): Promise<number> {
344
+ const location = await this.fetchStoredLocation(partialItem);
345
+ const deleted = await super.deleteItem(partialItem, transaction);
346
+ if (deleted && location) {
347
+ await this.storage.delete(location);
348
+ }
349
+ return deleted;
350
+ }
351
+
352
+ /**
353
+ * Deletes items from the repository.
354
+ * @param {Partial<SHAPE>[]} items - The items to delete.
355
+ * @returns {Promise<void>} A promise that resolves when the items are deleted.
356
+ */
357
+ public async deleteItems(items: Partial<SHAPE>[]): Promise<void> {
358
+ const locations = await Promise.all(items.map((item) => this.fetchStoredLocation(item)));
359
+ await super.deleteItems(items);
360
+ await Promise.all(locations.map((location) => this.storage.delete(location)));
361
+ }
362
+
363
+ /**
364
+ * Prepares a value for storage by externalizing large content to S3.
365
+ * @param {DATA_CLASS} value - The value to prepare for storage.
366
+ * @returns {Promise<DATA_CLASS>} A promise that resolves to the prepared value for DynamoDB.
367
+ */
368
+ public async prepareValueForStorage(value: DATA_CLASS): Promise<DATA_CLASS> {
369
+ const serialized = JSON.stringify(value);
370
+ const plainObject = JSON.parse(serialized);
371
+
372
+ // Remove any existing storageLocation before saving
373
+ delete plainObject.storageLocation;
374
+
375
+ // Generate reference ID from pk and sk for consistent S3 key
376
+ const pk = this.getPk(value);
377
+ const sk = this.getSk(value);
378
+ // Create hash of pk and sk for S3 key
379
+ const hash = createHash('sha256').update(`${pk}#${sk}`).digest('hex');
380
+
381
+ // Save full payload to S3
382
+ const { location } = await this.storage.save(this.entityName, hash, plainObject);
383
+
384
+ // Create minimal payload for DynamoDB with only key fields
385
+ // Key fields are computed from the entity definition - everything else is externalized
386
+ const minimalPayload = this.getKeyFieldsValues(plainObject);
387
+
388
+ // Add storageLocation reference
389
+ minimalPayload.storageLocation = location;
390
+
391
+ // Return minimal payload to be stored in DynamoDB
392
+ // DO NOT pass through constructor as it will strip storageLocation!
393
+ // DO NOT mutate the original value - storageLocation is an internal implementation detail
394
+ return minimalPayload as DATA_CLASS;
395
+ }
396
+
397
+ /**
398
+ * Extracts only the key field values from an object based on the entity definition.
399
+ * Large content fields (defined in fieldsAsJsonString) are set to empty values.
400
+ * All other fields (key fields and small metadata) are preserved.
401
+ *
402
+ * @param {Record<string, unknown>} obj - The source object to extract key fields from.
403
+ * @returns {Record<string, unknown>} An object containing only the key field values.
404
+ */
405
+ protected getKeyFieldsValues(obj: Record<string, unknown>): Record<string, unknown> {
406
+ const result: Record<string, unknown> = {};
407
+
408
+ // Copy all properties from the source object
409
+ for (const [key, value] of Object.entries(obj)) {
410
+ // Skip storageLocation as it's handled separately
411
+ if (key === 'storageLocation') {
412
+ continue;
413
+ }
414
+
415
+ // If this field is in fieldsAsJsonString, it's large content - set to empty
416
+ if (this.fieldsAsJsonString.includes(key)) {
417
+ // Set arrays to empty arrays, objects to empty objects
418
+ if (Array.isArray(value)) {
419
+ result[key] = [];
420
+ } else if (value !== null && typeof value === 'object') {
421
+ result[key] = {};
422
+ }
423
+ // Skip primitive large fields entirely
424
+ continue;
425
+ }
426
+
427
+ // Keep all other fields (key fields and small metadata)
428
+ result[key] = value;
429
+ }
430
+
431
+ return result;
432
+ }
433
+
434
+ /**
435
+ * Override parent's hydrateItem to preserve storageLocation
436
+ * The parent's hydrateItem creates a new instance which strips storageLocation
437
+ */
438
+ protected hydrateItem(item: Record<string, unknown>): DATA_CLASS {
439
+ // Extract storageLocation before calling parent's hydrateItem
440
+ const storageLocation = item.storageLocation as S3StorageLocation | undefined;
441
+
442
+ // Call parent's hydrateItem which will strip storageLocation
443
+ const hydrated = super.hydrateItem(item);
444
+
445
+ // Add storageLocation back if it existed
446
+ if (storageLocation) {
447
+ (hydrated as any).storageLocation = storageLocation;
448
+ }
449
+
450
+ return hydrated;
451
+ }
452
+
453
+ /**
454
+ * Hydrates a record from external storage.
455
+ * @param {DATA_CLASS} record - The record to hydrate.
456
+ * @returns {Promise<DATA_CLASS | undefined>} A promise that resolves to the hydrated record (without storageLocation).
457
+ */
458
+ private async hydrateFromExternalStorage(record?: DATA_CLASS): Promise<DATA_CLASS | undefined> {
459
+ // If no record or no storageLocation, return record as-is
460
+ if (!record || !record.storageLocation) {
461
+ return record;
462
+ }
463
+
464
+ // Load full content from S3
465
+ return (await this.storage.load(record.storageLocation)) as DATA_CLASS;
466
+ }
467
+
468
+ /**
469
+ * Fetches the stored location of an item.
470
+ * @param {Partial<SHAPE>} partialItem - The item to fetch the stored location for.
471
+ * @returns {Promise<S3StorageLocation | undefined>} A promise that resolves to the stored location.
472
+ */
473
+ private async fetchStoredLocation(partialItem: Partial<SHAPE>): Promise<S3StorageLocation | undefined> {
474
+ try {
475
+ const output = await this.client.get({
476
+ TableName: this.tableName,
477
+ Key: {
478
+ [this.keys.pk.attributeName]: this.getPk(partialItem),
479
+ [this.keys.sk.attributeName]: this.getSk(partialItem),
480
+ },
481
+ ProjectionExpression: 'storageLocation',
482
+ });
483
+ return output.Item?.storageLocation as S3StorageLocation | undefined;
484
+ } catch (error) {
485
+ if (error instanceof ConditionalCheckFailedException) {
486
+ return undefined;
487
+ }
488
+ throw error;
489
+ }
490
+ }
491
+
492
+ /**
493
+ * Check if the error is due to DynamoDB item size limit being exceeded.
494
+ * @param {unknown} error - The error to check.
495
+ * @returns {boolean} True if the error is due to DynamoDB item size limit being exceeded, false otherwise.
496
+ */
497
+ protected isDynamoItemSizeLimitError(error: unknown): boolean {
498
+ if (!error || typeof error !== 'object') {
499
+ return false;
500
+ }
501
+
502
+ const err = error as Record<string, unknown>;
503
+ const cancellationReasons = (err?.CancellationReasons ?? err?.cancellationReasons) as
504
+ | Array<Record<string, unknown>>
505
+ | undefined;
506
+ const validationCodes = ['ValidationException', 'TransactionCanceledException'];
507
+
508
+ const hasValidationCode =
509
+ validationCodes.includes(err?.code as string) ||
510
+ validationCodes.includes(err?.name as string) ||
511
+ validationCodes.includes((err?.originalError as Record<string, unknown>)?.code as string) ||
512
+ validationCodes.includes((err?.originalError as Record<string, unknown>)?.name as string) ||
513
+ (Array.isArray(cancellationReasons) &&
514
+ cancellationReasons.some((reason) => validationCodes.includes((reason?.Code ?? reason?.code) as string)));
515
+
516
+ const hasSizeMessage =
517
+ this.hasDynamoSizeKeyword(err?.message as string) ||
518
+ this.hasDynamoSizeKeyword((err?.originalError as Record<string, unknown>)?.message as string) ||
519
+ (Array.isArray(cancellationReasons) &&
520
+ cancellationReasons.some((reason) =>
521
+ this.hasDynamoSizeKeyword((reason?.Message ?? reason?.message) as string),
522
+ ));
523
+
524
+ return hasValidationCode || hasSizeMessage;
525
+ }
526
+
527
+ /**
528
+ * Check if the message contains the DynamoDB size keyword.
529
+ * @param {string} message - The message to check.
530
+ * @returns {boolean} True if the message contains the DynamoDB size keyword, false otherwise.
531
+ */
532
+ private hasDynamoSizeKeyword(message?: string): boolean {
533
+ if (typeof message !== 'string') {
534
+ return false;
535
+ }
536
+ const normalized = message.toLowerCase();
537
+ return (
538
+ normalized.includes('item size') ||
539
+ normalized.includes('maximum allowed size') ||
540
+ normalized.includes('exceeds') ||
541
+ normalized.includes('400 kb') ||
542
+ normalized.includes('400kb')
543
+ );
544
+ }
545
+ }
package/src/index.ts CHANGED
@@ -15,3 +15,7 @@ export * from './error/InvalidDbSchemaError';
15
15
  export * from './PostgresErrorCodes';
16
16
 
17
17
  export { Pool, PoolClient } from 'pg';
18
+
19
+ // S3 External Storage
20
+ export * from './s3/S3ExternalStorage';
21
+ export * from './externalized/ExternalizedDynamoDbRepository';