@squiz/db-lib 1.77.0 → 1.77.1

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.
@@ -54,6 +54,20 @@ export abstract class ExternalizedDynamoDbRepository<
54
54
  super(tableName, dbManager, entityName, entityDefinition, classRef);
55
55
  }
56
56
 
57
+ /**
58
+ * Removes storageLocation from the response.
59
+ * storageLocation is an internal S3 storage implementation detail that should never be exposed to API consumers.
60
+ * @param {DATA_CLASS} item - The item to process.
61
+ * @returns {DATA_CLASS} The item without storageLocation.
62
+ */
63
+ private removeStorageLocationFromResponse(item: DATA_CLASS): DATA_CLASS {
64
+ if ((item as any).storageLocation !== undefined) {
65
+ const { storageLocation: _storageLocation, ...itemWithoutStorage } = item as any;
66
+ return new this.classRef(itemWithoutStorage);
67
+ }
68
+ return item;
69
+ }
70
+
57
71
  /**
58
72
  * Creates a new item in the repository.
59
73
  * If the item exceeds DynamoDB's size limit, it will automatically externalize the content to S3.
@@ -70,7 +84,8 @@ export abstract class ExternalizedDynamoDbRepository<
70
84
  options: { overrideExisting?: boolean } = { overrideExisting: false },
71
85
  ): Promise<DATA_CLASS> {
72
86
  try {
73
- return await this.createItemInternal(value, transaction, additionalValue, options);
87
+ const result = await this.createItemInternal(value, transaction, additionalValue, options);
88
+ return this.removeStorageLocationFromResponse(result);
74
89
  } catch (error) {
75
90
  // If the item exceeds DynamoDB's size limit, externalize the content to S3 and retry
76
91
  if (this.isDynamoItemSizeLimitError(error)) {
@@ -78,7 +93,8 @@ export abstract class ExternalizedDynamoDbRepository<
78
93
  `ExternalizedDynamoDbRepository: Item exceeded DynamoDB size limit for ${this.entityName}. Retrying with external storage.`,
79
94
  );
80
95
  const externalizedValue = await this.prepareValueForStorage(value);
81
- return await this.createItemInternal(externalizedValue, transaction, additionalValue, options);
96
+ const result = await this.createItemInternal(externalizedValue, transaction, additionalValue, options);
97
+ return this.removeStorageLocationFromResponse(result);
82
98
  }
83
99
  throw error;
84
100
  }
@@ -180,8 +196,14 @@ export abstract class ExternalizedDynamoDbRepository<
180
196
  await this.storage.delete(previousLocation);
181
197
  }
182
198
 
183
- // If item was externalized, hydrate it from S3 to return full content
184
- return (await this.hydrateFromExternalStorage(createdItem)) || createdItem;
199
+ // Check if we should hydrate from S3
200
+ // Only hydrate if content is actually stored in S3 (not just stale storageLocation)
201
+ if (this.isContentInS3(createdItem)) {
202
+ // Content fields are empty, so full content is in S3
203
+ return (await this.hydrateFromExternalStorage(createdItem)) || createdItem;
204
+ }
205
+
206
+ return createdItem;
185
207
  }
186
208
 
187
209
  /**
@@ -192,7 +214,8 @@ export abstract class ExternalizedDynamoDbRepository<
192
214
  */
193
215
  public async updateItem(value: Partial<SHAPE>): Promise<DATA_CLASS | undefined> {
194
216
  try {
195
- return await this.updateItemInternal(value);
217
+ const result = await this.updateItemInternal(value);
218
+ return result ? this.removeStorageLocationFromResponse(result) : undefined;
196
219
  } catch (error) {
197
220
  // If the item exceeds DynamoDB's size limit, externalize the content to S3 and retry
198
221
  if (this.isDynamoItemSizeLimitError(error)) {
@@ -200,7 +223,8 @@ export abstract class ExternalizedDynamoDbRepository<
200
223
  `ExternalizedDynamoDbRepository: Update exceeded DynamoDB size limit for ${this.entityName}. Retrying with external storage.`,
201
224
  );
202
225
  const externalizedValue = await this.prepareValueForStorage(value as DATA_CLASS);
203
- return await this.updateItemInternal(externalizedValue);
226
+ const result = await this.updateItemInternal(externalizedValue);
227
+ return result ? this.removeStorageLocationFromResponse(result) : undefined;
204
228
  }
205
229
  throw error;
206
230
  }
@@ -281,9 +305,14 @@ export abstract class ExternalizedDynamoDbRepository<
281
305
 
282
306
  const result = await this.client.update(updateCommandInput);
283
307
  updatedItem = result.Attributes ? this.hydrateItem(result.Attributes) : undefined;
308
+
309
+ // Preserve storageLocation from DynamoDB response (hydrateItem might strip it)
310
+ if (updatedItem && result.Attributes?.storageLocation) {
311
+ (updatedItem as any).storageLocation = result.Attributes.storageLocation;
312
+ }
284
313
  }
285
314
  } else {
286
- // No storageLocation, use parent's method
315
+ // No storageLocation in update value, use parent's method
287
316
  // Strip storageLocation before passing to parent to avoid "Excess properties" error
288
317
  const { storageLocation: _, ...valueWithoutStorage } = value as any;
289
318
  updatedItem = await super.updateItem(valueWithoutStorage);
@@ -293,14 +322,39 @@ export abstract class ExternalizedDynamoDbRepository<
293
322
  return undefined;
294
323
  }
295
324
 
325
+ // Check for stale storageLocation - this happens when content transitions from large (S3) to small (inline DynamoDB)
326
+ // If storageLocation exists but content is NOT in S3 (content is inline), the storageLocation is stale
327
+ let newLocation = updatedItem.storageLocation;
328
+ if (newLocation && !this.isContentInS3(updatedItem)) {
329
+ // Remove the stale storageLocation from DynamoDB
330
+ await this.client.update({
331
+ TableName: this.tableName,
332
+ Key: {
333
+ [this.keys.pk.attributeName]: this.getPk(value),
334
+ [this.keys.sk.attributeName]: this.getSk(value),
335
+ },
336
+ UpdateExpression: 'REMOVE storageLocation',
337
+ });
338
+
339
+ // Mark that there's no longer a new S3 location (we just removed it)
340
+ newLocation = undefined;
341
+ delete (updatedItem as any).storageLocation;
342
+ }
343
+
296
344
  // Clean up old S3 file if it exists and is different from new location
297
- const newLocation = updatedItem.storageLocation;
298
345
  if (previousLocation && previousLocation.key !== newLocation?.key) {
299
346
  await this.storage.delete(previousLocation);
300
347
  }
301
348
 
302
- // If item was externalized, hydrate it from S3 to return full content
303
- return (await this.hydrateFromExternalStorage(updatedItem)) || updatedItem;
349
+ // Check if we should hydrate from S3
350
+ // Only hydrate if content is actually stored in S3 (not just stale storageLocation)
351
+ // For large->small updates, DynamoDB might still have storageLocation but content is in DynamoDB
352
+ if (this.isContentInS3(updatedItem)) {
353
+ // Content fields are empty, so full content is in S3
354
+ return (await this.hydrateFromExternalStorage(updatedItem)) || updatedItem;
355
+ }
356
+
357
+ return updatedItem;
304
358
  }
305
359
 
306
360
  /**
@@ -364,6 +418,7 @@ export abstract class ExternalizedDynamoDbRepository<
364
418
  * Prepares a value for storage by externalizing large content to S3.
365
419
  * @param {DATA_CLASS} value - The value to prepare for storage.
366
420
  * @returns {Promise<DATA_CLASS>} A promise that resolves to the prepared value for DynamoDB.
421
+ * @throws {Error} If S3 storage is not configured or if saving to S3 fails.
367
422
  */
368
423
  public async prepareValueForStorage(value: DATA_CLASS): Promise<DATA_CLASS> {
369
424
  const serialized = JSON.stringify(value);
@@ -450,6 +505,31 @@ export abstract class ExternalizedDynamoDbRepository<
450
505
  return hydrated;
451
506
  }
452
507
 
508
+ /**
509
+ * Checks if content is actually stored in S3 by examining if large content fields are empty.
510
+ * When content is externalized to S3, fieldsAsJsonString fields are set to empty objects/arrays.
511
+ * @param {DATA_CLASS} item - The item to check.
512
+ * @returns {boolean} True if content is in S3, false if content is in DynamoDB.
513
+ */
514
+ private isContentInS3(item: DATA_CLASS): boolean {
515
+ if (!item.storageLocation) {
516
+ return false;
517
+ }
518
+
519
+ // Check if large content fields are empty (indicating content is in S3)
520
+ for (const fieldName of this.fieldsAsJsonString) {
521
+ const fieldValue = (item as any)[fieldName];
522
+ if (fieldValue !== undefined) {
523
+ const isEmpty = typeof fieldValue === 'object' && Object.keys(fieldValue).length === 0;
524
+ if (isEmpty) {
525
+ return true;
526
+ }
527
+ }
528
+ }
529
+
530
+ return false;
531
+ }
532
+
453
533
  /**
454
534
  * Hydrates a record from external storage.
455
535
  * @param {DATA_CLASS} record - The record to hydrate.