@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,274 @@
1
+ import { ExternalizedDynamoDbRepository } from './ExternalizedDynamoDbRepository';
2
+ import { S3ExternalStorage, S3StorageLocation } from '../s3/S3ExternalStorage';
3
+ import { DynamoDbManager } from '../dynamodb/DynamoDbManager';
4
+ import { EntityDefinition } from '../dynamodb/AbstractDynamoDbRepository';
5
+
6
+ // Mock model class for testing
7
+ interface TestItemShape {
8
+ id: string;
9
+ name: string;
10
+ data: Array<{ key: string; value: object }>;
11
+ }
12
+
13
+ class TestItem implements TestItemShape {
14
+ id: string;
15
+ name: string;
16
+ data: Array<{ key: string; value: object }>;
17
+ storageLocation?: S3StorageLocation;
18
+
19
+ constructor(input: Record<string, unknown> = {}) {
20
+ this.id = (input.id as string) || '';
21
+ this.name = (input.name as string) || '';
22
+ this.data = (input.data as Array<{ key: string; value: object }>) || [];
23
+ // Note: storageLocation is intentionally not set from input to simulate model stripping it
24
+ }
25
+ }
26
+
27
+ // Test entity definition
28
+ const testEntityDefinition: EntityDefinition = {
29
+ keys: {
30
+ pk: {
31
+ attributeName: 'pk',
32
+ format: 'ITEM#${id}',
33
+ },
34
+ sk: {
35
+ attributeName: 'sk',
36
+ format: 'ITEM#${id}',
37
+ },
38
+ },
39
+ indexes: {},
40
+ fieldsAsJsonString: ['data'],
41
+ };
42
+
43
+ // Concrete test repository
44
+ class TestRepo extends ExternalizedDynamoDbRepository<TestItemShape, TestItem> {
45
+ public mockSuperUpdateItem?: jest.Mock;
46
+
47
+ constructor(dbManager: DynamoDbManager<any>, storage: S3ExternalStorage, overrides: Partial<EntityDefinition> = {}) {
48
+ super('test-table', dbManager, 'test_item', { ...testEntityDefinition, ...overrides }, TestItem, storage);
49
+ }
50
+
51
+ // Override updateItem to allow mocking super.updateItem
52
+ public async updateItem(value: Partial<TestItemShape>): Promise<TestItem | undefined> {
53
+ if (this.mockSuperUpdateItem) {
54
+ // Use mock if provided
55
+ const previousLocation = await (this as any).fetchStoredLocation(value);
56
+ const updatedItem = await this.mockSuperUpdateItem(value);
57
+ if (!updatedItem) {
58
+ return undefined;
59
+ }
60
+ const newLocation = (updatedItem as any).storageLocation;
61
+ if (previousLocation && previousLocation.key !== newLocation?.key) {
62
+ await (this as any).storage.delete(previousLocation);
63
+ }
64
+ return updatedItem;
65
+ }
66
+ return super.updateItem(value);
67
+ }
68
+ }
69
+
70
+ const createDbManager = (clientOverrides: Record<string, jest.Mock> = {}) =>
71
+ ({
72
+ client: {
73
+ get: jest.fn(),
74
+ put: jest.fn(),
75
+ delete: jest.fn(),
76
+ update: jest.fn(),
77
+ ...clientOverrides,
78
+ },
79
+ repositories: {} as any,
80
+ executeInTransaction: async <T>(fn: (transaction: any) => Promise<T>): Promise<T> => fn({}),
81
+ addWriteTransactionItem: jest.fn(),
82
+ } as unknown as DynamoDbManager<any>);
83
+
84
+ const createStorage = () =>
85
+ ({
86
+ save: jest.fn(),
87
+ load: jest.fn(),
88
+ delete: jest.fn(),
89
+ } as unknown as S3ExternalStorage);
90
+
91
+ const createTestItem = (overrides: Partial<TestItem> = {}): TestItem => {
92
+ const item = new TestItem({
93
+ id: 'item-1',
94
+ name: 'Test',
95
+ data: [{ key: 'sample', value: { main: [] } }],
96
+ ...overrides,
97
+ });
98
+ return item;
99
+ };
100
+
101
+ describe('ExternalizedDynamoDbRepository', () => {
102
+ it('externalizes data to S3 when prepareValueForStorage is called', async () => {
103
+ const storage = {
104
+ ...createStorage(),
105
+ save: jest.fn().mockResolvedValue({ location: { type: 's3', key: 'item-key' }, size: 500 }),
106
+ } as unknown as S3ExternalStorage;
107
+ const repo = new TestRepo(createDbManager(), storage);
108
+ const item = createTestItem();
109
+
110
+ const storedValue = await (repo as any).prepareValueForStorage(item);
111
+
112
+ expect(storage.save).toHaveBeenCalled();
113
+ // storedValue is a minimal payload - original item is NOT mutated
114
+ expect(storedValue.data).toEqual([]);
115
+ expect(storedValue.storageLocation).toEqual({ type: 's3', key: 'item-key' });
116
+ });
117
+
118
+ it('creates item with storageLocation preserved', async () => {
119
+ const client = createDbManager().client as any;
120
+ (client.put as jest.Mock).mockResolvedValue({});
121
+ const storage = {
122
+ ...createStorage(),
123
+ save: jest.fn().mockResolvedValue({ location: { type: 's3', key: 'item-key' }, size: 500 }),
124
+ } as unknown as S3ExternalStorage;
125
+
126
+ const repo = new TestRepo({ client } as any, storage);
127
+ const item = createTestItem();
128
+
129
+ // Use prepareValueForStorage to externalize data and set storageLocation
130
+ const preparedItem = await (repo as any).prepareValueForStorage(item);
131
+
132
+ // Verify the prepared value has minimal payload (empty data)
133
+ expect(preparedItem.data).toEqual([]);
134
+ expect(preparedItem.storageLocation).toEqual({ type: 's3', key: 'item-key' });
135
+
136
+ // Original item should NOT be mutated
137
+ expect(item.storageLocation).toBeUndefined();
138
+ });
139
+
140
+ it('loads payload from external storage when storageLocation is set', async () => {
141
+ const storage = createStorage();
142
+ const fullData = {
143
+ id: 'item-1',
144
+ name: 'From S3',
145
+ data: [{ key: 'sample', value: { main: [] } }],
146
+ };
147
+ (storage.load as jest.Mock).mockResolvedValue(fullData);
148
+
149
+ const repo = new TestRepo(createDbManager({ get: jest.fn() as any }), storage);
150
+ const result = await (repo as any).hydrateFromExternalStorage({
151
+ id: 'item-1',
152
+ name: 'Placeholder',
153
+ data: [],
154
+ storageLocation: { type: 's3', key: 'item-key' },
155
+ });
156
+
157
+ expect(storage.load).toHaveBeenCalledWith({ type: 's3', key: 'item-key' });
158
+ expect(result?.name).toBe('From S3');
159
+ expect(result?.data).toEqual([{ key: 'sample', value: { main: [] } }]);
160
+ });
161
+
162
+ it('deletes externalized payloads when deleting items', async () => {
163
+ const client = createDbManager({
164
+ get: jest.fn().mockResolvedValue({ Item: { storageLocation: { type: 's3', key: 'item-key' } } }),
165
+ delete: jest.fn().mockResolvedValue({}),
166
+ }).client as any;
167
+ const storage = createStorage();
168
+ const repo = new TestRepo({ client } as any, storage);
169
+
170
+ await repo.deleteItem({ id: 'item-1' });
171
+
172
+ expect(storage.delete).toHaveBeenCalledWith({ type: 's3', key: 'item-key' });
173
+ });
174
+
175
+ it('returns inline data when no storageLocation is present', async () => {
176
+ const storage = createStorage();
177
+ const repo = new TestRepo(createDbManager({ get: jest.fn() as any }), storage);
178
+ const inlineRecord = {
179
+ id: 'item-1',
180
+ name: 'Inline Item',
181
+ data: [{ key: 'sample', value: { main: [] } }],
182
+ };
183
+
184
+ const result = await (repo as any).hydrateFromExternalStorage(inlineRecord);
185
+
186
+ expect(storage.load).not.toHaveBeenCalled();
187
+ expect(result).toBeDefined();
188
+ expect(result?.name).toBe('Inline Item');
189
+ expect(result?.data).toEqual([{ key: 'sample', value: { main: [] } }]);
190
+ });
191
+
192
+ it('cleans up old S3 file when updating with new storageLocation', async () => {
193
+ const oldLocation = { type: 's3' as const, key: 'old-key' };
194
+ const newLocation = { type: 's3' as const, key: 'new-key' };
195
+
196
+ const client = createDbManager({
197
+ get: jest.fn().mockResolvedValue({
198
+ Item: {
199
+ id: 'item-1',
200
+ name: 'Test',
201
+ storageLocation: oldLocation,
202
+ },
203
+ }),
204
+ update: jest.fn().mockResolvedValue({
205
+ Attributes: {
206
+ id: 'item-1',
207
+ name: 'Updated',
208
+ storageLocation: newLocation,
209
+ },
210
+ }),
211
+ }).client as any;
212
+
213
+ const storage = createStorage();
214
+ const repo = new TestRepo({ client } as any, storage);
215
+
216
+ // Update without storageLocation (it will be fetched from DynamoDB)
217
+ const updateValue = {
218
+ id: 'item-1',
219
+ name: 'Updated',
220
+ };
221
+
222
+ // Create an updated item with the new storageLocation
223
+ const updatedItem = createTestItem({ id: 'item-1', name: 'Updated' });
224
+ (updatedItem as any).storageLocation = newLocation;
225
+
226
+ // Mock super.updateItem using the custom mock hook
227
+ repo.mockSuperUpdateItem = jest.fn().mockResolvedValue(updatedItem as any);
228
+
229
+ await repo.updateItem(updateValue);
230
+
231
+ expect(storage.delete).toHaveBeenCalledWith(oldLocation);
232
+ });
233
+
234
+ it('does not delete S3 file when storageLocation is unchanged', async () => {
235
+ const location = { type: 's3' as const, key: 'same-key' };
236
+
237
+ const client = createDbManager({
238
+ get: jest.fn().mockResolvedValue({
239
+ Item: {
240
+ id: 'item-1',
241
+ name: 'Test',
242
+ storageLocation: location,
243
+ },
244
+ }),
245
+ update: jest.fn().mockResolvedValue({
246
+ Attributes: {
247
+ id: 'item-1',
248
+ name: 'Updated',
249
+ storageLocation: location,
250
+ },
251
+ }),
252
+ }).client as any;
253
+
254
+ const storage = createStorage();
255
+ const repo = new TestRepo({ client } as any, storage);
256
+
257
+ // Update without storageLocation (it will be fetched from DynamoDB)
258
+ const updateValue = {
259
+ id: 'item-1',
260
+ name: 'Updated',
261
+ };
262
+
263
+ // Create an updated item with the same storageLocation
264
+ const updatedItem = createTestItem({ id: 'item-1', name: 'Updated' });
265
+ (updatedItem as any).storageLocation = location;
266
+
267
+ // Mock super.updateItem using the custom mock hook
268
+ repo.mockSuperUpdateItem = jest.fn().mockResolvedValue(updatedItem as any);
269
+
270
+ await repo.updateItem(updateValue);
271
+
272
+ expect(storage.delete).not.toHaveBeenCalled();
273
+ });
274
+ });