@squiz/db-lib 1.75.1 → 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 +12 -0
- package/lib/AbstractRepository.js.map +1 -1
- package/lib/ConnectionManager.js.map +1 -1
- package/lib/Migrator.js.map +1 -1
- package/lib/PostgresErrorCodes.js +1 -1
- package/lib/PostgresErrorCodes.js.map +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.d.ts +4 -2
- package/lib/dynamodb/AbstractDynamoDbRepository.d.ts.map +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.js +18 -13
- package/lib/dynamodb/AbstractDynamoDbRepository.js.map +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.d.ts +21 -0
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.d.ts.map +1 -1
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.js +126 -15
- package/lib/dynamodb/AbstractDynamoDbRepository.spec.js.map +1 -1
- package/lib/dynamodb/DynamoDbManager.js.map +1 -1
- package/lib/dynamodb/getDynamoDbOptions.d.ts +1 -1
- package/lib/dynamodb/getDynamoDbOptions.d.ts.map +1 -1
- package/lib/dynamodb/getDynamoDbOptions.js.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/getConnectionInfo.js +1 -2
- package/lib/getConnectionInfo.js.map +1 -1
- 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 +9 -8
- package/src/dynamodb/AbstractDynamoDbRepository.spec.ts +140 -1
- package/src/dynamodb/AbstractDynamoDbRepository.ts +22 -12
- 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,181 @@
|
|
|
1
|
+
import { S3ExternalStorage, S3StorageLocation } from './S3ExternalStorage';
|
|
2
|
+
import { S3Client } from '@aws-sdk/client-s3';
|
|
3
|
+
|
|
4
|
+
const TEST_TENANT_ID = 'test-tenant';
|
|
5
|
+
const TEST_BUCKET = 'dx-test-us-db-fallback';
|
|
6
|
+
|
|
7
|
+
type S3MockSet = {
|
|
8
|
+
sendMock: jest.Mock;
|
|
9
|
+
putObjectCommandMock: jest.Mock;
|
|
10
|
+
getObjectCommandMock: jest.Mock;
|
|
11
|
+
deleteObjectCommandMock: jest.Mock;
|
|
12
|
+
headBucketCommandMock: jest.Mock;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
declare global {
|
|
16
|
+
// eslint-disable-next-line no-var
|
|
17
|
+
var __s3MockSet: S3MockSet | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
jest.mock('@aws-sdk/client-s3', () => {
|
|
21
|
+
const buildCommandMock = (name: string) =>
|
|
22
|
+
jest.fn().mockImplementation((input) => ({
|
|
23
|
+
name,
|
|
24
|
+
input,
|
|
25
|
+
}));
|
|
26
|
+
const state: S3MockSet = {
|
|
27
|
+
sendMock: jest.fn(),
|
|
28
|
+
putObjectCommandMock: buildCommandMock('PutObjectCommand'),
|
|
29
|
+
getObjectCommandMock: buildCommandMock('GetObjectCommand'),
|
|
30
|
+
deleteObjectCommandMock: buildCommandMock('DeleteObjectCommand'),
|
|
31
|
+
headBucketCommandMock: buildCommandMock('HeadBucketCommand'),
|
|
32
|
+
};
|
|
33
|
+
(global as any).__s3MockSet = state;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
S3Client: jest.fn().mockImplementation(() => ({
|
|
37
|
+
send: state.sendMock,
|
|
38
|
+
})),
|
|
39
|
+
PutObjectCommand: state.putObjectCommandMock,
|
|
40
|
+
GetObjectCommand: state.getObjectCommandMock,
|
|
41
|
+
DeleteObjectCommand: state.deleteObjectCommandMock,
|
|
42
|
+
HeadBucketCommand: state.headBucketCommandMock,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const { sendMock, putObjectCommandMock, getObjectCommandMock, deleteObjectCommandMock, headBucketCommandMock } = (
|
|
47
|
+
global as any
|
|
48
|
+
).__s3MockSet as S3MockSet;
|
|
49
|
+
|
|
50
|
+
jest.mock('crypto', () => ({
|
|
51
|
+
randomUUID: jest.fn(() => 'fixed-uuid'),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
describe('S3ExternalStorage', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
sendMock.mockReset();
|
|
57
|
+
putObjectCommandMock.mockClear();
|
|
58
|
+
getObjectCommandMock.mockClear();
|
|
59
|
+
deleteObjectCommandMock.mockClear();
|
|
60
|
+
headBucketCommandMock.mockClear();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const createStorage = () => new S3ExternalStorage(TEST_TENANT_ID, new S3Client({ region: 'us-west-2' }), TEST_BUCKET);
|
|
64
|
+
|
|
65
|
+
it('saves payloads to S3 and returns location metadata', async () => {
|
|
66
|
+
const storage = createStorage();
|
|
67
|
+
const payload = { foo: 'bar' };
|
|
68
|
+
sendMock.mockResolvedValueOnce({}); // put object
|
|
69
|
+
|
|
70
|
+
const result = await storage.save('test_entity', 'abc', payload);
|
|
71
|
+
|
|
72
|
+
expect(putObjectCommandMock).toHaveBeenCalledWith(
|
|
73
|
+
expect.objectContaining({
|
|
74
|
+
Bucket: 'dx-test-us-db-fallback',
|
|
75
|
+
Body: JSON.stringify(payload),
|
|
76
|
+
ContentType: 'application/json',
|
|
77
|
+
Key: result.location.key,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
expect(result.location).toMatchObject({
|
|
81
|
+
type: 's3',
|
|
82
|
+
});
|
|
83
|
+
expect(result.location.key).toBe('test-tenant/test_entity/abc.json');
|
|
84
|
+
expect(result.size).toBe(JSON.stringify(payload).length);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('loads payloads from S3 and parses JSON', async () => {
|
|
88
|
+
const storage = createStorage();
|
|
89
|
+
const storedPayload = { baz: 'qux' };
|
|
90
|
+
const transformer = jest.fn().mockResolvedValue(JSON.stringify(storedPayload));
|
|
91
|
+
sendMock.mockResolvedValueOnce({
|
|
92
|
+
Body: {
|
|
93
|
+
transformToString: transformer,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const location: S3StorageLocation = { type: 's3', key: 'items/foo/bar.json' };
|
|
98
|
+
const result = await storage.load(location);
|
|
99
|
+
|
|
100
|
+
expect(getObjectCommandMock).toHaveBeenCalledWith({
|
|
101
|
+
Bucket: 'dx-test-us-db-fallback',
|
|
102
|
+
Key: 'items/foo/bar.json',
|
|
103
|
+
});
|
|
104
|
+
expect(transformer).toHaveBeenCalled();
|
|
105
|
+
expect(result).toEqual(storedPayload);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('deletes payloads from S3 when a location is provided', async () => {
|
|
109
|
+
const storage = createStorage();
|
|
110
|
+
const location: S3StorageLocation = { type: 's3', key: 'items/foo.json' };
|
|
111
|
+
sendMock.mockResolvedValueOnce({}); // delete
|
|
112
|
+
|
|
113
|
+
await storage.delete(location);
|
|
114
|
+
|
|
115
|
+
expect(deleteObjectCommandMock).toHaveBeenCalledWith({
|
|
116
|
+
Bucket: 'dx-test-us-db-fallback',
|
|
117
|
+
Key: 'items/foo.json',
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('handles S3 errors when saving', async () => {
|
|
122
|
+
const storage = createStorage();
|
|
123
|
+
const payload = { foo: 'bar' };
|
|
124
|
+
const s3Error = Object.assign(new Error('AccessDenied'), {
|
|
125
|
+
name: 'AccessDenied',
|
|
126
|
+
$metadata: { httpStatusCode: 403 },
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
sendMock.mockRejectedValueOnce(s3Error);
|
|
130
|
+
|
|
131
|
+
await expect(storage.save('test_entity', 'first', payload)).rejects.toThrow('AccessDenied');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('does not delete when location is undefined', async () => {
|
|
135
|
+
const storage = createStorage();
|
|
136
|
+
|
|
137
|
+
await storage.delete(undefined);
|
|
138
|
+
|
|
139
|
+
expect(deleteObjectCommandMock).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('generates unique S3 keys for different entity types', async () => {
|
|
143
|
+
const storage = createStorage();
|
|
144
|
+
sendMock.mockResolvedValue({});
|
|
145
|
+
|
|
146
|
+
const result1 = await storage.save('entity_type_a', 'id-1', { data: 'test' });
|
|
147
|
+
const result2 = await storage.save('entity_type_b', 'id-2', { data: 'test' });
|
|
148
|
+
|
|
149
|
+
expect(result1.location.key).toContain('entity_type_a/id-1');
|
|
150
|
+
expect(result2.location.key).toContain('entity_type_b/id-2');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('throws error when S3 response has no body', async () => {
|
|
154
|
+
const storage = createStorage();
|
|
155
|
+
sendMock.mockResolvedValueOnce({ Body: undefined });
|
|
156
|
+
|
|
157
|
+
const location: S3StorageLocation = { type: 's3', key: 'items/foo/bar.json' };
|
|
158
|
+
|
|
159
|
+
await expect(storage.load(location)).rejects.toThrow(
|
|
160
|
+
'Failed to load externalised items from S3 object items/foo/bar.json',
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('handles S3 errors gracefully when loading', async () => {
|
|
165
|
+
const storage = createStorage();
|
|
166
|
+
sendMock.mockRejectedValueOnce(new Error('S3 GetObject failed'));
|
|
167
|
+
|
|
168
|
+
const location: S3StorageLocation = { type: 's3', key: 'items/foo/bar.json' };
|
|
169
|
+
|
|
170
|
+
await expect(storage.load(location)).rejects.toThrow('S3 GetObject failed');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('handles S3 errors gracefully when deleting', async () => {
|
|
174
|
+
const storage = createStorage();
|
|
175
|
+
sendMock.mockRejectedValueOnce(new Error('S3 DeleteObject failed'));
|
|
176
|
+
|
|
177
|
+
const location: S3StorageLocation = { type: 's3', key: 'items/foo.json' };
|
|
178
|
+
|
|
179
|
+
await expect(storage.delete(location)).rejects.toThrow('S3 DeleteObject failed');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* @file S3ExternalStorage.ts
|
|
3
|
+
* @description This file contains the S3ExternalStorage class, which is used to store and retrieve page contents from S3.
|
|
4
|
+
* @author Dean Heffernan
|
|
5
|
+
* @copyright 2025 Squiz
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// External
|
|
9
|
+
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Represents the storage location of page contents in S3.
|
|
13
|
+
*/
|
|
14
|
+
export interface S3StorageLocation {
|
|
15
|
+
type: 's3';
|
|
16
|
+
key: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The S3ExternalStorage class is used to store and retrieve page contents from S3.
|
|
21
|
+
* @class S3ExternalStorage
|
|
22
|
+
* @constructor
|
|
23
|
+
* @param {string} bucket - The name of the S3 bucket to use for storing page contents.
|
|
24
|
+
* @param {number} thresholdBytes - The threshold in bytes above which page contents will be stored externally.
|
|
25
|
+
* @param {S3Client} s3Client - The S3 client to use for storing and retrieving page contents.
|
|
26
|
+
* @returns {S3ExternalStorage} A new instance of the S3ExternalStorage class.
|
|
27
|
+
*/
|
|
28
|
+
export class S3ExternalStorage {
|
|
29
|
+
/**
|
|
30
|
+
* The config to use for storing and retrieving page contents.
|
|
31
|
+
* @type {Config}
|
|
32
|
+
*/
|
|
33
|
+
private readonly tenantId: string;
|
|
34
|
+
/**
|
|
35
|
+
* The name of the S3 bucket to use for storing page contents.
|
|
36
|
+
* @type {string}
|
|
37
|
+
*/
|
|
38
|
+
private readonly bucket: string;
|
|
39
|
+
/**
|
|
40
|
+
* The S3 client to use for storing and retrieving page contents.
|
|
41
|
+
* @type {S3Client}
|
|
42
|
+
*/
|
|
43
|
+
private readonly s3Client: S3Client;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Creates a new instance of the S3ExternalStorage class.
|
|
47
|
+
* @constructor
|
|
48
|
+
* @param {S3Client} s3Client - The S3 client to use for storing and retrieving page contents.
|
|
49
|
+
* @returns {S3ExternalStorage} A new instance of the S3ExternalStorage class.
|
|
50
|
+
*/
|
|
51
|
+
constructor(tenantId: string, s3Client: S3Client, bucket: string) {
|
|
52
|
+
this.tenantId = tenantId;
|
|
53
|
+
this.bucket = bucket;
|
|
54
|
+
this.s3Client = s3Client;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Saves a payload to S3.
|
|
59
|
+
* @param {string} entityName - The name of the entity to save the payload for.
|
|
60
|
+
* @param {string} referenceId - The reference ID of the payload.
|
|
61
|
+
* @param {object} payload - The payload to save.
|
|
62
|
+
* @returns {Promise<{ location: S3StorageLocation; size: number }>} A promise that resolves to the location and size of the saved payload.
|
|
63
|
+
*/
|
|
64
|
+
public async save(
|
|
65
|
+
entityName: string,
|
|
66
|
+
referenceId: string,
|
|
67
|
+
payload: object,
|
|
68
|
+
): Promise<{ location: S3StorageLocation; size: number }> {
|
|
69
|
+
const body = JSON.stringify(payload);
|
|
70
|
+
const key = `${this.tenantId}/${entityName}/${referenceId}.json`;
|
|
71
|
+
await this.s3Client.send(
|
|
72
|
+
new PutObjectCommand({
|
|
73
|
+
Bucket: this.bucket,
|
|
74
|
+
Key: key,
|
|
75
|
+
Body: body,
|
|
76
|
+
ContentType: 'application/json',
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
return {
|
|
80
|
+
location: {
|
|
81
|
+
type: 's3',
|
|
82
|
+
key,
|
|
83
|
+
},
|
|
84
|
+
size: Buffer.byteLength(body, 'utf8'),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Loads a payload from S3.
|
|
90
|
+
* @param location - The location of the payload to load.
|
|
91
|
+
* @returns {Promise<Record<string, unknown>>} A promise that resolves to the loaded payload.
|
|
92
|
+
*/
|
|
93
|
+
public async load(location: S3StorageLocation): Promise<Record<string, unknown>> {
|
|
94
|
+
const response = await this.s3Client.send(
|
|
95
|
+
new GetObjectCommand({
|
|
96
|
+
Bucket: this.bucket,
|
|
97
|
+
Key: location.key,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
if (!response.Body) {
|
|
101
|
+
throw new Error(`Failed to load externalised items from S3 object ${location.key}`);
|
|
102
|
+
}
|
|
103
|
+
const contents = await response.Body.transformToString();
|
|
104
|
+
return JSON.parse(contents);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
public async delete(location?: S3StorageLocation) {
|
|
108
|
+
if (!location || !this.bucket) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
await this.s3Client.send(
|
|
112
|
+
new DeleteObjectCommand({
|
|
113
|
+
Bucket: this.bucket,
|
|
114
|
+
Key: location.key,
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|