@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.
- package/CHANGELOG.md +6 -0
- package/lib/dynamodb/AbstractDynamoDbRepository.d.ts +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.d.ts.map +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.js +6 -5
- package/lib/dynamodb/AbstractDynamoDbRepository.js.map +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.d.ts.map +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.js +15 -14
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.js.map +1 -1
- package/lib/dynamodb/getDynamoDbOptions.d.ts.map +1 -1
- package/lib/externalized/ExternalizedDynamoDbRepository.d.ts +151 -0
- package/lib/externalized/ExternalizedDynamoDbRepository.d.ts.map +1 -0
- package/lib/externalized/ExternalizedDynamoDbRepository.js +463 -0
- package/lib/externalized/ExternalizedDynamoDbRepository.js.map +1 -0
- package/lib/externalized/ExternalizedDynamoDbRepository.spec.d.ts +2 -0
- package/lib/externalized/ExternalizedDynamoDbRepository.spec.d.ts.map +1 -0
- package/lib/externalized/ExternalizedDynamoDbRepository.spec.js +218 -0
- package/lib/externalized/ExternalizedDynamoDbRepository.spec.js.map +1 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +3 -0
- package/lib/index.js.map +1 -1
- package/lib/s3/S3ExternalStorage.d.ts +66 -0
- package/lib/s3/S3ExternalStorage.d.ts.map +1 -0
- package/lib/s3/S3ExternalStorage.js +84 -0
- package/lib/s3/S3ExternalStorage.js.map +1 -0
- package/lib/s3/S3ExternalStorage.spec.d.ts +12 -0
- package/lib/s3/S3ExternalStorage.spec.d.ts.map +1 -0
- package/lib/s3/S3ExternalStorage.spec.js +130 -0
- package/lib/s3/S3ExternalStorage.spec.js.map +1 -0
- package/package.json +7 -6
- package/src/dynamodb/AbstractDynamoDbRepository.spec.ts +2 -1
- package/src/dynamodb/AbstractDynamoDbRepository.ts +3 -1
- package/src/externalized/ExternalizedDynamoDbRepository.spec.ts +274 -0
- package/src/externalized/ExternalizedDynamoDbRepository.ts +545 -0
- package/src/index.ts +4 -0
- package/src/s3/S3ExternalStorage.spec.ts +181 -0
- package/src/s3/S3ExternalStorage.ts +118 -0
- 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';
|