@travetto/model-dynamodb 7.0.0-rc.1 → 7.0.0-rc.3
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 +3 -3
- package/__index__.ts +1 -0
- package/package.json +4 -4
- package/src/config.ts +8 -1
- package/src/service.ts +85 -153
- package/src/util.ts +159 -0
package/README.md
CHANGED
|
@@ -30,8 +30,8 @@ import { DynamoDBModelService, DynamoDBModelConfig } from '@travetto/model-dynam
|
|
|
30
30
|
|
|
31
31
|
export class Init {
|
|
32
32
|
@InjectableFactory({ primary: true })
|
|
33
|
-
static getModelService(
|
|
34
|
-
return new DynamoDBModelService(
|
|
33
|
+
static getModelService(config: DynamoDBModelConfig) {
|
|
34
|
+
return new DynamoDBModelService(config);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
```
|
|
@@ -45,7 +45,7 @@ export class DynamoDBModelConfig {
|
|
|
45
45
|
client: dynamodb.DynamoDBClientConfig = {
|
|
46
46
|
endpoint: undefined
|
|
47
47
|
};
|
|
48
|
-
|
|
48
|
+
modifyStorage?: boolean;
|
|
49
49
|
namespace?: string;
|
|
50
50
|
}
|
|
51
51
|
```
|
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.3",
|
|
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.3",
|
|
30
|
+
"@travetto/config": "^7.0.0-rc.3",
|
|
31
|
+
"@travetto/model": "^7.0.0-rc.3"
|
|
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(val: string | number | boolean | Date | undefined | null): AttributeValue;
|
|
24
|
-
function toValue(val: unknown): AttributeValue | undefined {
|
|
25
|
-
if (val === undefined || val === null || val === '') {
|
|
26
|
-
return { NULL: true };
|
|
27
|
-
} else if (typeof val === 'string') {
|
|
28
|
-
return { S: val };
|
|
29
|
-
} else if (typeof val === 'number') {
|
|
30
|
-
return { N: `${val}` };
|
|
31
|
-
} else if (typeof val === 'boolean') {
|
|
32
|
-
return { BOOL: val };
|
|
33
|
-
} else if (val instanceof Date) {
|
|
34
|
-
return { N: `${val.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,36 +50,35 @@ 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
|
|
88
|
-
indices[`${
|
|
53
|
+
const property = DynamoDBUtil.simpleName(idx.name);
|
|
54
|
+
indices[`${property}__`] = DynamoDBUtil.toValue(key);
|
|
89
55
|
if (sort) {
|
|
90
|
-
indices[`${
|
|
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
|
|
112
|
-
indices[`:${
|
|
113
|
-
expr.push(`${
|
|
76
|
+
const property = DynamoDBUtil.simpleName(idx.name);
|
|
77
|
+
indices[`:${property}`] = DynamoDBUtil.toValue(key);
|
|
78
|
+
expr.push(`${property}__ = :${property}`);
|
|
114
79
|
if (sort) {
|
|
115
|
-
indices[`:${
|
|
116
|
-
expr.push(`${
|
|
80
|
+
indices[`:${property}_sort`] = DynamoDBUtil.toValue(+sort);
|
|
81
|
+
expr.push(`${property}_sort__ = :${property}_sort`);
|
|
117
82
|
}
|
|
118
83
|
}
|
|
119
84
|
|
|
@@ -125,66 +90,30 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
125
90
|
'body=:body',
|
|
126
91
|
expiry !== undefined ? `${EXP_ATTR}=:expr` : undefined,
|
|
127
92
|
...expr
|
|
128
|
-
].filter(
|
|
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'
|
|
135
100
|
});
|
|
136
101
|
}
|
|
137
|
-
} catch (
|
|
138
|
-
if (
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof Error && error.name === 'ConditionalCheckFailedException') {
|
|
139
104
|
if (mode === 'create') {
|
|
140
105
|
throw new ExistsError(cls, id);
|
|
141
106
|
} else if (mode === 'update') {
|
|
142
107
|
throw new NotFoundError(cls, id);
|
|
143
108
|
}
|
|
144
109
|
}
|
|
145
|
-
throw
|
|
110
|
+
throw error;
|
|
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
|
}
|
|
@@ -332,12 +262,12 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
332
262
|
});
|
|
333
263
|
|
|
334
264
|
if (batch.Count && batch.Items) {
|
|
335
|
-
for (const
|
|
265
|
+
for (const item of batch.Items) {
|
|
336
266
|
try {
|
|
337
|
-
yield await loadAndCheckExpiry(cls,
|
|
338
|
-
} catch (
|
|
339
|
-
if (!(
|
|
340
|
-
throw
|
|
267
|
+
yield await DynamoDBUtil.loadAndCheckExpiry(cls, item.body.S!);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (!(error instanceof NotFoundError)) {
|
|
270
|
+
throw error;
|
|
341
271
|
}
|
|
342
272
|
}
|
|
343
273
|
}
|
|
@@ -360,24 +290,26 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
360
290
|
async #getIdByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<string> {
|
|
361
291
|
ModelCrudUtil.ensureNotSubType(cls);
|
|
362
292
|
|
|
363
|
-
const
|
|
293
|
+
const idxConfig = ModelRegistryIndex.getIndex(cls, idx, ['sorted', 'unsorted']);
|
|
364
294
|
|
|
365
|
-
const { key, sort } = ModelIndexedUtil.computeIndexKey(cls,
|
|
295
|
+
const { key, sort } = ModelIndexedUtil.computeIndexKey(cls, idxConfig, body);
|
|
366
296
|
|
|
367
|
-
if (
|
|
368
|
-
throw new IndexNotSupported(cls,
|
|
297
|
+
if (idxConfig.type === 'sorted' && sort === undefined) {
|
|
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),
|
|
375
305
|
IndexName: idxName,
|
|
376
306
|
ProjectionExpression: 'id',
|
|
377
|
-
KeyConditionExpression: [sort ? `${idxName}_sort__ = :${idxName}_sort` : '', `${idxName}__ = :${idxName}`]
|
|
307
|
+
KeyConditionExpression: [sort ? `${idxName}_sort__ = :${idxName}_sort` : '', `${idxName}__ = :${idxName}`]
|
|
308
|
+
.filter(expr => !!expr)
|
|
309
|
+
.join(' and '),
|
|
378
310
|
ExpressionAttributeValues: {
|
|
379
|
-
[`:${idxName}`]: toValue(key),
|
|
380
|
-
...(sort ? { [`:${idxName}_sort`]: toValue(+sort) } : {})
|
|
311
|
+
[`:${idxName}`]: DynamoDBUtil.toValue(key),
|
|
312
|
+
...(sort ? { [`:${idxName}_sort`]: DynamoDBUtil.toValue(+sort) } : {})
|
|
381
313
|
}
|
|
382
314
|
};
|
|
383
315
|
|
|
@@ -405,10 +337,10 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
405
337
|
async * listByIndex<T extends ModelType>(cls: Class<T>, idx: string, body?: DeepPartial<T>): AsyncIterable<T> {
|
|
406
338
|
ModelCrudUtil.ensureNotSubType(cls);
|
|
407
339
|
|
|
408
|
-
const
|
|
409
|
-
const { key } = ModelIndexedUtil.computeIndexKey(cls,
|
|
340
|
+
const config = ModelRegistryIndex.getIndex(cls, idx, ['sorted', 'unsorted']);
|
|
341
|
+
const { key } = ModelIndexedUtil.computeIndexKey(cls, config, body, { emptySortValue: null });
|
|
410
342
|
|
|
411
|
-
const idxName = simpleName(idx);
|
|
343
|
+
const idxName = DynamoDBUtil.simpleName(idx);
|
|
412
344
|
|
|
413
345
|
let done = false;
|
|
414
346
|
let token: Record<string, AttributeValue> | undefined;
|
|
@@ -419,18 +351,18 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
419
351
|
ProjectionExpression: 'body',
|
|
420
352
|
KeyConditionExpression: `${idxName}__ = :${idxName}`,
|
|
421
353
|
ExpressionAttributeValues: {
|
|
422
|
-
[`:${idxName}`]: toValue(key)
|
|
354
|
+
[`:${idxName}`]: DynamoDBUtil.toValue(key)
|
|
423
355
|
},
|
|
424
356
|
ExclusiveStartKey: token
|
|
425
357
|
});
|
|
426
358
|
|
|
427
359
|
if (batch.Count && batch.Items) {
|
|
428
|
-
for (const
|
|
360
|
+
for (const item of batch.Items) {
|
|
429
361
|
try {
|
|
430
|
-
yield await loadAndCheckExpiry(cls,
|
|
431
|
-
} catch (
|
|
432
|
-
if (!(
|
|
433
|
-
throw
|
|
362
|
+
yield await DynamoDBUtil.loadAndCheckExpiry(cls, item.body.S!);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (!(error instanceof NotFoundError)) {
|
|
365
|
+
throw error;
|
|
434
366
|
}
|
|
435
367
|
}
|
|
436
368
|
}
|
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
|
+
}
|