@travetto/model-dynamodb 7.0.0-rc.2 → 7.0.0-rc.4
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/README.md +8 -2
- package/__index__.ts +1 -0
- package/package.json +4 -4
- package/src/config.ts +8 -1
- package/src/service.ts +62 -132
- package/src/util.ts +159 -0
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ export class Init {
|
|
|
36
36
|
}
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
-
where the [DynamoDBModelConfig](https://github.com/travetto/travetto/tree/main/module/model-dynamodb/src/config.ts#
|
|
39
|
+
where the [DynamoDBModelConfig](https://github.com/travetto/travetto/tree/main/module/model-dynamodb/src/config.ts#L7) is defined by:
|
|
40
40
|
|
|
41
41
|
**Code: Structure of DynamoDBModelConfig**
|
|
42
42
|
```typescript
|
|
@@ -45,8 +45,14 @@ export class DynamoDBModelConfig {
|
|
|
45
45
|
client: dynamodb.DynamoDBClientConfig = {
|
|
46
46
|
endpoint: undefined
|
|
47
47
|
};
|
|
48
|
-
|
|
48
|
+
modifyStorage?: boolean;
|
|
49
49
|
namespace?: string;
|
|
50
|
+
|
|
51
|
+
postConstruct(): void {
|
|
52
|
+
if (!Runtime.production) {
|
|
53
|
+
this.client.endpoint ??= 'http://localhost:8000'; // From docker
|
|
54
|
+
}
|
|
55
|
+
}
|
|
50
56
|
}
|
|
51
57
|
```
|
|
52
58
|
|
package/__index__.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@travetto/model-dynamodb",
|
|
3
|
-
"version": "7.0.0-rc.
|
|
3
|
+
"version": "7.0.0-rc.4",
|
|
4
4
|
"description": "DynamoDB backing for the travetto model module.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@aws-sdk/client-dynamodb": "^3.940.0",
|
|
29
|
-
"@travetto/cli": "^7.0.0-rc.
|
|
30
|
-
"@travetto/config": "^7.0.0-rc.
|
|
31
|
-
"@travetto/model": "^7.0.0-rc.
|
|
29
|
+
"@travetto/cli": "^7.0.0-rc.4",
|
|
30
|
+
"@travetto/config": "^7.0.0-rc.4",
|
|
31
|
+
"@travetto/model": "^7.0.0-rc.4"
|
|
32
32
|
},
|
|
33
33
|
"travetto": {
|
|
34
34
|
"displayName": "DynamoDB Model Support"
|
package/src/config.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import type dynamodb from '@aws-sdk/client-dynamodb';
|
|
2
2
|
|
|
3
3
|
import { Config } from '@travetto/config';
|
|
4
|
+
import { Runtime } from '@travetto/runtime';
|
|
4
5
|
|
|
5
6
|
@Config('model.dynamodb')
|
|
6
7
|
export class DynamoDBModelConfig {
|
|
7
8
|
client: dynamodb.DynamoDBClientConfig = {
|
|
8
9
|
endpoint: undefined
|
|
9
10
|
};
|
|
10
|
-
|
|
11
|
+
modifyStorage?: boolean;
|
|
11
12
|
namespace?: string;
|
|
13
|
+
|
|
14
|
+
postConstruct(): void {
|
|
15
|
+
if (!Runtime.production) {
|
|
16
|
+
this.client.endpoint ??= 'http://localhost:8000'; // From docker
|
|
17
|
+
}
|
|
18
|
+
}
|
|
12
19
|
}
|
package/src/service.ts
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type AttributeDefinition, type AttributeValue, DynamoDB, type GlobalSecondaryIndex,
|
|
3
|
-
type KeySchemaElement, type PutItemCommandInput, type PutItemCommandOutput
|
|
4
|
-
} from '@aws-sdk/client-dynamodb';
|
|
1
|
+
import { type AttributeValue, DynamoDB, type PutItemCommandInput, type PutItemCommandOutput } from '@aws-sdk/client-dynamodb';
|
|
5
2
|
|
|
6
3
|
import { ShutdownManager, TimeUtil, type Class, type DeepPartial } from '@travetto/runtime';
|
|
7
4
|
import { Injectable } from '@travetto/di';
|
|
@@ -13,41 +10,10 @@ import {
|
|
|
13
10
|
} from '@travetto/model';
|
|
14
11
|
|
|
15
12
|
import { DynamoDBModelConfig } from './config.ts';
|
|
13
|
+
import { DynamoDBUtil } from './util.ts';
|
|
16
14
|
|
|
17
15
|
const EXP_ATTR = 'expires_at__';
|
|
18
16
|
|
|
19
|
-
function simpleName(idx: string): string {
|
|
20
|
-
return idx.replace(/[^A-Za-z0-9]/g, '');
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function toValue(value: string | number | boolean | Date | undefined | null): AttributeValue;
|
|
24
|
-
function toValue(value: unknown): AttributeValue | undefined {
|
|
25
|
-
if (value === undefined || value === null || value === '') {
|
|
26
|
-
return { NULL: true };
|
|
27
|
-
} else if (typeof value === 'string') {
|
|
28
|
-
return { S: value };
|
|
29
|
-
} else if (typeof value === 'number') {
|
|
30
|
-
return { N: `${value}` };
|
|
31
|
-
} else if (typeof value === 'boolean') {
|
|
32
|
-
return { BOOL: value };
|
|
33
|
-
} else if (value instanceof Date) {
|
|
34
|
-
return { N: `${value.getTime()}` };
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function loadAndCheckExpiry<T extends ModelType>(cls: Class<T>, doc: string): Promise<T> {
|
|
39
|
-
const item = await ModelCrudUtil.load(cls, doc);
|
|
40
|
-
if (ModelRegistryIndex.getConfig(cls).expiresAt) {
|
|
41
|
-
const expiry = ModelExpiryUtil.getExpiryState(cls, item);
|
|
42
|
-
if (!expiry.expired) {
|
|
43
|
-
return item;
|
|
44
|
-
}
|
|
45
|
-
} else {
|
|
46
|
-
return item;
|
|
47
|
-
}
|
|
48
|
-
throw new NotFoundError(cls, item.id);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
17
|
/**
|
|
52
18
|
* A model service backed by DynamoDB
|
|
53
19
|
*/
|
|
@@ -61,7 +27,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
61
27
|
constructor(config: DynamoDBModelConfig) { this.config = config; }
|
|
62
28
|
|
|
63
29
|
#resolveTable(cls: Class): string {
|
|
64
|
-
let table = ModelRegistryIndex.getStoreName(cls)
|
|
30
|
+
let table = ModelRegistryIndex.getStoreName(cls);
|
|
65
31
|
if (this.config.namespace) {
|
|
66
32
|
table = `${this.config.namespace}_${table}`;
|
|
67
33
|
}
|
|
@@ -84,35 +50,34 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
84
50
|
const indices: Record<string, unknown> = {};
|
|
85
51
|
for (const idx of config.indices ?? []) {
|
|
86
52
|
const { key, sort } = ModelIndexedUtil.computeIndexKey(cls, idx, item);
|
|
87
|
-
const property = simpleName(idx.name);
|
|
88
|
-
indices[`${property}__`] = toValue(key);
|
|
53
|
+
const property = DynamoDBUtil.simpleName(idx.name);
|
|
54
|
+
indices[`${property}__`] = DynamoDBUtil.toValue(key);
|
|
89
55
|
if (sort) {
|
|
90
|
-
indices[`${property}_sort__`] = toValue(+sort);
|
|
56
|
+
indices[`${property}_sort__`] = DynamoDBUtil.toValue(+sort);
|
|
91
57
|
}
|
|
92
58
|
}
|
|
93
59
|
const query: PutItemCommandInput = {
|
|
94
60
|
TableName: this.#resolveTable(cls),
|
|
95
61
|
ConditionExpression: 'attribute_not_exists(body)',
|
|
96
62
|
Item: {
|
|
97
|
-
id: toValue(item.id),
|
|
98
|
-
body: toValue(JSON.stringify(item)),
|
|
99
|
-
...(expiry !== undefined ? { [EXP_ATTR]: toValue(expiry) } : {}),
|
|
63
|
+
id: DynamoDBUtil.toValue(item.id),
|
|
64
|
+
body: DynamoDBUtil.toValue(JSON.stringify(item)),
|
|
65
|
+
...(expiry !== undefined ? { [EXP_ATTR]: DynamoDBUtil.toValue(expiry) } : {}),
|
|
100
66
|
...indices
|
|
101
67
|
},
|
|
102
68
|
ReturnValues: 'NONE'
|
|
103
69
|
};
|
|
104
|
-
console.debug('Querying', { query });
|
|
105
70
|
return await this.client.putItem(query);
|
|
106
71
|
} else {
|
|
107
72
|
const indices: Record<string, unknown> = {};
|
|
108
73
|
const expr: string[] = [];
|
|
109
74
|
for (const idx of config.indices ?? []) {
|
|
110
75
|
const { key, sort } = ModelIndexedUtil.computeIndexKey(cls, idx, item);
|
|
111
|
-
const property = simpleName(idx.name);
|
|
112
|
-
indices[`:${property}`] = toValue(key);
|
|
76
|
+
const property = DynamoDBUtil.simpleName(idx.name);
|
|
77
|
+
indices[`:${property}`] = DynamoDBUtil.toValue(key);
|
|
113
78
|
expr.push(`${property}__ = :${property}`);
|
|
114
79
|
if (sort) {
|
|
115
|
-
indices[`:${property}_sort`] = toValue(+sort);
|
|
80
|
+
indices[`:${property}_sort`] = DynamoDBUtil.toValue(+sort);
|
|
116
81
|
expr.push(`${property}_sort__ = :${property}_sort`);
|
|
117
82
|
}
|
|
118
83
|
}
|
|
@@ -127,8 +92,8 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
127
92
|
...expr
|
|
128
93
|
].filter(part => !!part).join(', ')}`,
|
|
129
94
|
ExpressionAttributeValues: {
|
|
130
|
-
':body': toValue(JSON.stringify(item)),
|
|
131
|
-
...(expiry !== undefined ? { ':expr': toValue(expiry) } : {}),
|
|
95
|
+
':body': DynamoDBUtil.toValue(JSON.stringify(item)),
|
|
96
|
+
...(expiry !== undefined ? { ':expr': DynamoDBUtil.toValue(expiry) } : {}),
|
|
132
97
|
...indices
|
|
133
98
|
},
|
|
134
99
|
ReturnValues: 'ALL_NEW'
|
|
@@ -146,45 +111,9 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
146
111
|
}
|
|
147
112
|
}
|
|
148
113
|
|
|
149
|
-
#computeIndexConfig<T extends ModelType>(cls: Class<T>): { indices?: GlobalSecondaryIndex[], attributes: AttributeDefinition[] } {
|
|
150
|
-
const config = ModelRegistryIndex.getConfig(cls);
|
|
151
|
-
const attributes: AttributeDefinition[] = [];
|
|
152
|
-
const indices: GlobalSecondaryIndex[] = [];
|
|
153
|
-
|
|
154
|
-
for (const idx of config.indices ?? []) {
|
|
155
|
-
const idxName = simpleName(idx.name);
|
|
156
|
-
attributes.push({ AttributeName: `${idxName}__`, AttributeType: 'S' });
|
|
157
|
-
|
|
158
|
-
const keys: KeySchemaElement[] = [{
|
|
159
|
-
AttributeName: `${idxName}__`,
|
|
160
|
-
KeyType: 'HASH'
|
|
161
|
-
}];
|
|
162
|
-
|
|
163
|
-
if (idx.type === 'sorted') {
|
|
164
|
-
keys.push({
|
|
165
|
-
AttributeName: `${idxName}_sort__`,
|
|
166
|
-
KeyType: 'RANGE'
|
|
167
|
-
});
|
|
168
|
-
attributes.push({ AttributeName: `${idxName}_sort__`, AttributeType: 'N' });
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
indices.push({
|
|
172
|
-
IndexName: idxName,
|
|
173
|
-
// ProvisionedThroughput: '',
|
|
174
|
-
Projection: {
|
|
175
|
-
ProjectionType: 'INCLUDE',
|
|
176
|
-
NonKeyAttributes: ['body', 'id']
|
|
177
|
-
},
|
|
178
|
-
KeySchema: keys
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return { indices: indices.length ? indices : undefined, attributes };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
114
|
async postConstruct(): Promise<void> {
|
|
186
115
|
this.client = new DynamoDB({ ...this.config.client });
|
|
187
|
-
await ModelStorageUtil.
|
|
116
|
+
await ModelStorageUtil.storageInitialization(this);
|
|
188
117
|
ShutdownManager.onGracefulShutdown(async () => this.client.destroy());
|
|
189
118
|
}
|
|
190
119
|
|
|
@@ -194,31 +123,51 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
194
123
|
* Add a new model
|
|
195
124
|
* @param cls
|
|
196
125
|
*/
|
|
197
|
-
async
|
|
126
|
+
async upsertModel(cls: Class<ModelType>): Promise<void> {
|
|
198
127
|
const table = this.#resolveTable(cls);
|
|
199
|
-
const idx =
|
|
128
|
+
const idx = DynamoDBUtil.computeIndexConfig(cls);
|
|
200
129
|
|
|
201
|
-
const
|
|
130
|
+
const [currentTable, currentTTL] = await Promise.all([
|
|
131
|
+
this.client.describeTable({ TableName: table }).catch(() => undefined),
|
|
132
|
+
this.client.describeTimeToLive({ TableName: table }).catch(() => ({ TimeToLiveDescription: undefined }))
|
|
133
|
+
]);
|
|
202
134
|
|
|
203
|
-
if (
|
|
204
|
-
|
|
135
|
+
if (!currentTable) {
|
|
136
|
+
console.debug('Creating Table', { table, idx });
|
|
137
|
+
await this.client.createTable({
|
|
138
|
+
TableName: table,
|
|
139
|
+
KeySchema: [{ KeyType: 'HASH', AttributeName: 'id' }],
|
|
140
|
+
BillingMode: 'PAY_PER_REQUEST',
|
|
141
|
+
AttributeDefinitions: [
|
|
142
|
+
{ AttributeName: 'id', AttributeType: 'S' },
|
|
143
|
+
...idx.attributes
|
|
144
|
+
],
|
|
145
|
+
GlobalSecondaryIndexes: idx.indices
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
const indexUpdates = DynamoDBUtil.findChangedGlobalIndexes(currentTable.Table?.GlobalSecondaryIndexes, idx.indices);
|
|
149
|
+
const changedAttributes = DynamoDBUtil.findChangedAttributes(currentTable.Table?.AttributeDefinitions, idx.attributes);
|
|
150
|
+
|
|
151
|
+
console.debug('Updating Table', { table, idx, current: currentTable.Table, indexUpdates, changedAttributes });
|
|
152
|
+
|
|
153
|
+
if (changedAttributes.length || indexUpdates?.length) {
|
|
154
|
+
await this.client.updateTable({
|
|
155
|
+
TableName: table,
|
|
156
|
+
AttributeDefinitions: [
|
|
157
|
+
{ AttributeName: 'id', AttributeType: 'S' },
|
|
158
|
+
...idx.attributes
|
|
159
|
+
],
|
|
160
|
+
GlobalSecondaryIndexUpdates: indexUpdates
|
|
161
|
+
});
|
|
162
|
+
}
|
|
205
163
|
}
|
|
206
164
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
BillingMode: 'PAY_PER_REQUEST',
|
|
211
|
-
AttributeDefinitions: [
|
|
212
|
-
{ AttributeName: 'id', AttributeType: 'S' },
|
|
213
|
-
...idx.attributes
|
|
214
|
-
],
|
|
215
|
-
GlobalSecondaryIndexes: idx.indices
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
if (ModelRegistryIndex.getConfig(cls).expiresAt) {
|
|
165
|
+
const ttlRequired = ModelRegistryIndex.getConfig(cls).expiresAt !== undefined;
|
|
166
|
+
const ttlEnabled = currentTTL.TimeToLiveDescription?.TimeToLiveStatus === 'ENABLED';
|
|
167
|
+
if (ttlEnabled !== ttlRequired) {
|
|
219
168
|
await this.client.updateTimeToLive({
|
|
220
169
|
TableName: table,
|
|
221
|
-
TimeToLiveSpecification: { AttributeName: EXP_ATTR, Enabled:
|
|
170
|
+
TimeToLiveSpecification: { AttributeName: ttlRequired ? EXP_ATTR : undefined, Enabled: ttlRequired }
|
|
222
171
|
});
|
|
223
172
|
}
|
|
224
173
|
}
|
|
@@ -235,25 +184,6 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
235
184
|
}
|
|
236
185
|
}
|
|
237
186
|
|
|
238
|
-
/**
|
|
239
|
-
* When the model changes
|
|
240
|
-
* @param cls
|
|
241
|
-
*/
|
|
242
|
-
async changeModel(cls: Class<ModelType>): Promise<void> {
|
|
243
|
-
const table = this.#resolveTable(cls);
|
|
244
|
-
const idx = this.#computeIndexConfig(cls);
|
|
245
|
-
// const existing = await this.cl.describeTable({ TableName: table });
|
|
246
|
-
|
|
247
|
-
await this.client.updateTable({
|
|
248
|
-
TableName: table,
|
|
249
|
-
AttributeDefinitions: [
|
|
250
|
-
{ AttributeName: 'id', AttributeType: 'S' },
|
|
251
|
-
...idx.attributes
|
|
252
|
-
],
|
|
253
|
-
// TODO: Fill out index computation
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
|
|
257
187
|
async createStorage(): Promise<void> {
|
|
258
188
|
// Do nothing
|
|
259
189
|
}
|
|
@@ -270,11 +200,11 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
270
200
|
async get<T extends ModelType>(cls: Class<T>, id: string): Promise<T> {
|
|
271
201
|
const result = await this.client.getItem({
|
|
272
202
|
TableName: this.#resolveTable(cls),
|
|
273
|
-
Key: { id: toValue(id) }
|
|
203
|
+
Key: { id: DynamoDBUtil.toValue(id) }
|
|
274
204
|
});
|
|
275
205
|
|
|
276
206
|
if (result && result.Item && result.Item.body) {
|
|
277
|
-
return loadAndCheckExpiry(cls, result.Item.body.S!);
|
|
207
|
+
return DynamoDBUtil.loadAndCheckExpiry(cls, result.Item.body.S!);
|
|
278
208
|
}
|
|
279
209
|
throw new NotFoundError(cls, id);
|
|
280
210
|
}
|
|
@@ -334,7 +264,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
334
264
|
if (batch.Count && batch.Items) {
|
|
335
265
|
for (const item of batch.Items) {
|
|
336
266
|
try {
|
|
337
|
-
yield await loadAndCheckExpiry(cls, item.body.S!);
|
|
267
|
+
yield await DynamoDBUtil.loadAndCheckExpiry(cls, item.body.S!);
|
|
338
268
|
} catch (error) {
|
|
339
269
|
if (!(error instanceof NotFoundError)) {
|
|
340
270
|
throw error;
|
|
@@ -368,7 +298,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
368
298
|
throw new IndexNotSupported(cls, idxConfig, 'Sorted indices require the sort field');
|
|
369
299
|
}
|
|
370
300
|
|
|
371
|
-
const idxName = simpleName(idx);
|
|
301
|
+
const idxName = DynamoDBUtil.simpleName(idx);
|
|
372
302
|
|
|
373
303
|
const query = {
|
|
374
304
|
TableName: this.#resolveTable(cls),
|
|
@@ -378,8 +308,8 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
378
308
|
.filter(expr => !!expr)
|
|
379
309
|
.join(' and '),
|
|
380
310
|
ExpressionAttributeValues: {
|
|
381
|
-
[`:${idxName}`]: toValue(key),
|
|
382
|
-
...(sort ? { [`:${idxName}_sort`]: toValue(+sort) } : {})
|
|
311
|
+
[`:${idxName}`]: DynamoDBUtil.toValue(key),
|
|
312
|
+
...(sort ? { [`:${idxName}_sort`]: DynamoDBUtil.toValue(+sort) } : {})
|
|
383
313
|
}
|
|
384
314
|
};
|
|
385
315
|
|
|
@@ -410,7 +340,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
410
340
|
const config = ModelRegistryIndex.getIndex(cls, idx, ['sorted', 'unsorted']);
|
|
411
341
|
const { key } = ModelIndexedUtil.computeIndexKey(cls, config, body, { emptySortValue: null });
|
|
412
342
|
|
|
413
|
-
const idxName = simpleName(idx);
|
|
343
|
+
const idxName = DynamoDBUtil.simpleName(idx);
|
|
414
344
|
|
|
415
345
|
let done = false;
|
|
416
346
|
let token: Record<string, AttributeValue> | undefined;
|
|
@@ -421,7 +351,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
421
351
|
ProjectionExpression: 'body',
|
|
422
352
|
KeyConditionExpression: `${idxName}__ = :${idxName}`,
|
|
423
353
|
ExpressionAttributeValues: {
|
|
424
|
-
[`:${idxName}`]: toValue(key)
|
|
354
|
+
[`:${idxName}`]: DynamoDBUtil.toValue(key)
|
|
425
355
|
},
|
|
426
356
|
ExclusiveStartKey: token
|
|
427
357
|
});
|
|
@@ -429,7 +359,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
429
359
|
if (batch.Count && batch.Items) {
|
|
430
360
|
for (const item of batch.Items) {
|
|
431
361
|
try {
|
|
432
|
-
yield await loadAndCheckExpiry(cls, item.body.S!);
|
|
362
|
+
yield await DynamoDBUtil.loadAndCheckExpiry(cls, item.body.S!);
|
|
433
363
|
} catch (error) {
|
|
434
364
|
if (!(error instanceof NotFoundError)) {
|
|
435
365
|
throw error;
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AttributeDefinition, AttributeValue, GlobalSecondaryIndex, GlobalSecondaryIndexDescription,
|
|
3
|
+
GlobalSecondaryIndexUpdate, KeySchemaElement
|
|
4
|
+
} from '@aws-sdk/client-dynamodb';
|
|
5
|
+
|
|
6
|
+
import type { Class } from '@travetto/runtime';
|
|
7
|
+
import { ModelCrudUtil, ModelExpiryUtil, ModelRegistryIndex, NotFoundError, type ModelType } from '@travetto/model';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Configuration for DynamoDB indices
|
|
11
|
+
*/
|
|
12
|
+
type DynamoIndexConfig = {
|
|
13
|
+
indices?: GlobalSecondaryIndex[];
|
|
14
|
+
attributes: AttributeDefinition[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Utility class for DynamoDB operations and transformations.
|
|
19
|
+
*/
|
|
20
|
+
export class DynamoDBUtil {
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Converts an index name to a simplified format by removing non-alphanumeric characters
|
|
24
|
+
*/
|
|
25
|
+
static simpleName(idx: string): string {
|
|
26
|
+
return idx.replace(/[^A-Za-z0-9]/g, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Converts a JavaScript value to a DynamoDB AttributeValue format
|
|
31
|
+
* @param value The value to convert (string, number, boolean, Date, null, or undefined)
|
|
32
|
+
* @returns The DynamoDB AttributeValue representation
|
|
33
|
+
*/
|
|
34
|
+
static toValue(value: string | number | boolean | Date | undefined | null): AttributeValue;
|
|
35
|
+
static toValue(value: unknown): AttributeValue | undefined {
|
|
36
|
+
if (value === undefined || value === null || value === '') {
|
|
37
|
+
return { NULL: true };
|
|
38
|
+
} else if (typeof value === 'string') {
|
|
39
|
+
return { S: value };
|
|
40
|
+
} else if (typeof value === 'number') {
|
|
41
|
+
return { N: `${value}` };
|
|
42
|
+
} else if (typeof value === 'boolean') {
|
|
43
|
+
return { BOOL: value };
|
|
44
|
+
} else if (value instanceof Date) {
|
|
45
|
+
return { N: `${value.getTime()}` };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Computes the DynamoDB index configuration for a model class.
|
|
51
|
+
* Generates global secondary indices and attribute definitions based on the model's index configuration.
|
|
52
|
+
*/
|
|
53
|
+
static computeIndexConfig<T extends ModelType>(cls: Class<T>): DynamoIndexConfig {
|
|
54
|
+
const config = ModelRegistryIndex.getConfig(cls);
|
|
55
|
+
const attributes: AttributeDefinition[] = [];
|
|
56
|
+
const indices: GlobalSecondaryIndex[] = [];
|
|
57
|
+
|
|
58
|
+
for (const idx of config.indices ?? []) {
|
|
59
|
+
const idxName = this.simpleName(idx.name);
|
|
60
|
+
attributes.push({ AttributeName: `${idxName}__`, AttributeType: 'S' });
|
|
61
|
+
|
|
62
|
+
const keys: KeySchemaElement[] = [{
|
|
63
|
+
AttributeName: `${idxName}__`,
|
|
64
|
+
KeyType: 'HASH'
|
|
65
|
+
}];
|
|
66
|
+
|
|
67
|
+
if (idx.type === 'sorted') {
|
|
68
|
+
keys.push({
|
|
69
|
+
AttributeName: `${idxName}_sort__`,
|
|
70
|
+
KeyType: 'RANGE'
|
|
71
|
+
});
|
|
72
|
+
attributes.push({ AttributeName: `${idxName}_sort__`, AttributeType: 'N' });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
indices.push({
|
|
76
|
+
IndexName: idxName,
|
|
77
|
+
// ProvisionedThroughput: '',
|
|
78
|
+
Projection: {
|
|
79
|
+
ProjectionType: 'INCLUDE',
|
|
80
|
+
NonKeyAttributes: ['body', 'id']
|
|
81
|
+
},
|
|
82
|
+
KeySchema: keys
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { indices: indices.length ? indices : undefined, attributes };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Identifies attribute definitions that have changed between current and requested configurations.
|
|
91
|
+
* Compares attribute names and types to determine additions, removals, or modifications.
|
|
92
|
+
*/
|
|
93
|
+
static findChangedAttributes(current: AttributeDefinition[] | undefined, requested: AttributeDefinition[] | undefined): AttributeDefinition[] {
|
|
94
|
+
const currentMap = Object.fromEntries((current ?? []).map(attr => [attr.AttributeName, attr]));
|
|
95
|
+
const pendingMap = Object.fromEntries((requested ?? []).map(attr => [attr.AttributeName, attr]));
|
|
96
|
+
|
|
97
|
+
const changed: AttributeDefinition[] = [];
|
|
98
|
+
for (const attr of requested ?? []) {
|
|
99
|
+
if (!attr.AttributeName || attr.AttributeName === 'id') {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!currentMap[attr.AttributeName] || currentMap[attr.AttributeName].AttributeType !== attr.AttributeType) {
|
|
103
|
+
changed.push(attr);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const attr of current ?? []) {
|
|
108
|
+
if (!attr.AttributeName || attr.AttributeName === 'id') {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!pendingMap[attr.AttributeName]) {
|
|
112
|
+
changed.push(attr);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return changed;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Identifies global secondary indices that have changed between current and requested configurations.
|
|
120
|
+
* Generates update operations for creating new indices or deleting removed indices.
|
|
121
|
+
*/
|
|
122
|
+
static findChangedGlobalIndexes(current: GlobalSecondaryIndexDescription[] | undefined, requested: GlobalSecondaryIndex[] | undefined): GlobalSecondaryIndexUpdate[] {
|
|
123
|
+
const existingMap = Object.fromEntries((current ?? []).map(index => [index.IndexName, index]));
|
|
124
|
+
const pendingMap = Object.fromEntries((requested ?? []).map(index => [index.IndexName, index]));
|
|
125
|
+
|
|
126
|
+
const out: GlobalSecondaryIndexUpdate[] = [];
|
|
127
|
+
|
|
128
|
+
for (const index of requested ?? []) {
|
|
129
|
+
if (!existingMap[index.IndexName!]) {
|
|
130
|
+
out.push({ Create: index });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const index of current ?? []) {
|
|
135
|
+
if (!pendingMap[index.IndexName!]) {
|
|
136
|
+
out.push({ Delete: { IndexName: index.IndexName! } });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Loads a document from the model store and validates its expiry status.
|
|
145
|
+
* If the model has an expiry configuration and the document is expired, throws NotFoundError.
|
|
146
|
+
*/
|
|
147
|
+
static async loadAndCheckExpiry<T extends ModelType>(cls: Class<T>, doc: string): Promise<T> {
|
|
148
|
+
const item = await ModelCrudUtil.load(cls, doc);
|
|
149
|
+
if (ModelRegistryIndex.getConfig(cls).expiresAt) {
|
|
150
|
+
const expiry = ModelExpiryUtil.getExpiryState(cls, item);
|
|
151
|
+
if (!expiry.expired) {
|
|
152
|
+
return item;
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
return item;
|
|
156
|
+
}
|
|
157
|
+
throw new NotFoundError(cls, item.id);
|
|
158
|
+
}
|
|
159
|
+
}
|