@travetto/model-dynamodb 8.0.0-alpha.9 → 8.0.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/README.md +4 -5
- package/__index__.ts +1 -1
- package/package.json +18 -17
- package/src/config.ts +1 -1
- package/src/service.ts +308 -142
- package/src/util.ts +71 -34
- package/support/service.dynamodb.ts +1 -1
package/README.md
CHANGED
|
@@ -13,20 +13,19 @@ npm install @travetto/model-dynamodb
|
|
|
13
13
|
yarn add @travetto/model-dynamodb
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
This module provides an [DynamoDB](https://aws.amazon.com/dynamodb/)-based implementation for the [Data Modeling Support](https://github.com/travetto/travetto/tree/main/module/model#readme "Datastore abstraction for core operations.").
|
|
16
|
+
This module provides an [DynamoDB](https://aws.amazon.com/dynamodb/)-based implementation for the [Data Modeling Support](https://github.com/travetto/travetto/tree/main/module/model#readme "Datastore abstraction for core operations."). This source allows the [Data Modeling Support](https://github.com/travetto/travetto/tree/main/module/model#readme "Datastore abstraction for core operations.") module to read, write and query against [DynamoDB](https://aws.amazon.com/dynamodb/). The entire document is stored as a single value, so nothing is needed to handle schema updates in real time. Indices on the other hand are more complicated, and will not be retroactively computed for new values.
|
|
17
17
|
|
|
18
18
|
Supported features:
|
|
19
|
-
* [CRUD](https://github.com/travetto/travetto/tree/main/module/model/src/types/crud.ts#
|
|
19
|
+
* [CRUD](https://github.com/travetto/travetto/tree/main/module/model/src/types/crud.ts#L10)
|
|
20
20
|
* [Expiry](https://github.com/travetto/travetto/tree/main/module/model/src/types/expiry.ts#L10)
|
|
21
|
-
* [Indexed](https://github.com/travetto/travetto/tree/main/module/model/src/types/
|
|
21
|
+
* [Indexed](https://github.com/travetto/travetto/tree/main/module/model-indexed/src/types/service.ts#L21)
|
|
22
22
|
|
|
23
23
|
Out of the box, by installing the module, everything should be wired up by default.If you need to customize any aspect of the source or config, you can override and register it with the [Dependency Injection](https://github.com/travetto/travetto/tree/main/module/di#readme "Dependency registration/management and injection support.") module.
|
|
24
24
|
|
|
25
25
|
**Code: Wiring up a custom Model Source**
|
|
26
26
|
```typescript
|
|
27
27
|
import { InjectableFactory } from '@travetto/di';
|
|
28
|
-
|
|
29
|
-
import { DynamoDBModelService, type DynamoDBModelConfig } from '@travetto/model-dynamodb';
|
|
28
|
+
import { type DynamoDBModelConfig, DynamoDBModelService } from '@travetto/model-dynamodb';
|
|
30
29
|
|
|
31
30
|
export class Init {
|
|
32
31
|
@InjectableFactory({ primary: true })
|
package/__index__.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,37 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@travetto/model-dynamodb",
|
|
3
|
-
"version": "8.0.0
|
|
4
|
-
"type": "module",
|
|
3
|
+
"version": "8.0.0",
|
|
5
4
|
"description": "DynamoDB backing for the travetto model module.",
|
|
6
5
|
"keywords": [
|
|
7
|
-
"
|
|
8
|
-
"travetto",
|
|
6
|
+
"dynamodb",
|
|
9
7
|
"model",
|
|
10
|
-
"
|
|
8
|
+
"travetto",
|
|
9
|
+
"typescript"
|
|
11
10
|
],
|
|
12
11
|
"homepage": "https://travetto.io",
|
|
13
12
|
"license": "MIT",
|
|
14
13
|
"author": {
|
|
15
|
-
"
|
|
16
|
-
"
|
|
14
|
+
"name": "Travetto Framework",
|
|
15
|
+
"email": "travetto.framework@gmail.com"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"url": "git+https://github.com/travetto/travetto.git",
|
|
19
|
+
"directory": "module/model-dynamodb"
|
|
17
20
|
},
|
|
18
21
|
"files": [
|
|
19
22
|
"__index__.ts",
|
|
20
23
|
"src",
|
|
21
24
|
"support"
|
|
22
25
|
],
|
|
26
|
+
"type": "module",
|
|
23
27
|
"main": "__index__.ts",
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"directory": "module/model-dynamodb"
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
27
30
|
},
|
|
28
31
|
"dependencies": {
|
|
29
|
-
"@aws-sdk/client-dynamodb": "^3.
|
|
30
|
-
"@travetto/config": "^8.0.0
|
|
31
|
-
"@travetto/model": "^8.0.0
|
|
32
|
+
"@aws-sdk/client-dynamodb": "^3.1116.0",
|
|
33
|
+
"@travetto/config": "^8.0.0",
|
|
34
|
+
"@travetto/model": "^8.0.0",
|
|
35
|
+
"@travetto/model-indexed": "^8.0.0"
|
|
32
36
|
},
|
|
33
37
|
"peerDependencies": {
|
|
34
|
-
"@travetto/cli": "^8.0.0
|
|
38
|
+
"@travetto/cli": "^8.0.0"
|
|
35
39
|
},
|
|
36
40
|
"peerDependenciesMeta": {
|
|
37
41
|
"@travetto/cli": {
|
|
@@ -40,8 +44,5 @@
|
|
|
40
44
|
},
|
|
41
45
|
"travetto": {
|
|
42
46
|
"displayName": "DynamoDB Model Support"
|
|
43
|
-
},
|
|
44
|
-
"publishConfig": {
|
|
45
|
-
"access": "public"
|
|
46
47
|
}
|
|
47
48
|
}
|
package/src/config.ts
CHANGED
package/src/service.ts
CHANGED
|
@@ -1,30 +1,67 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type AttributeValue,
|
|
3
|
+
DynamoDB,
|
|
4
|
+
type PutItemCommandInput,
|
|
5
|
+
type PutItemCommandOutput,
|
|
6
|
+
type QueryCommandInput,
|
|
7
|
+
type QueryCommandOutput
|
|
8
|
+
} from '@aws-sdk/client-dynamodb';
|
|
2
9
|
|
|
3
|
-
import { JSONUtil, ShutdownManager, TimeUtil, type Class, type DeepPartial } from '@travetto/runtime';
|
|
4
10
|
import { Injectable, PostConstruct } from '@travetto/di';
|
|
5
11
|
import {
|
|
6
|
-
|
|
7
|
-
type
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
ExistsError,
|
|
13
|
+
type ModelCrudSupport,
|
|
14
|
+
ModelCrudUtil,
|
|
15
|
+
type ModelExpirySupport,
|
|
16
|
+
ModelExpiryUtil,
|
|
17
|
+
type ModelListOptions,
|
|
18
|
+
ModelRegistryIndex,
|
|
19
|
+
type ModelStorageSupport,
|
|
20
|
+
ModelStorageUtil,
|
|
21
|
+
type ModelType,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
type OptionalId
|
|
10
24
|
} from '@travetto/model';
|
|
25
|
+
import {
|
|
26
|
+
type FullKeyedIndexBody,
|
|
27
|
+
type FullKeyedIndexWithPartialBody,
|
|
28
|
+
isModelIndexedIndex,
|
|
29
|
+
type KeyedIndexBody,
|
|
30
|
+
type KeyedIndexSelection,
|
|
31
|
+
ModelIndexedComputedIndex,
|
|
32
|
+
type ModelIndexedSearchOptions,
|
|
33
|
+
type ModelIndexedSupport,
|
|
34
|
+
ModelIndexedUtil,
|
|
35
|
+
type ModelPageOptions,
|
|
36
|
+
type ModelPageResult,
|
|
37
|
+
type SingleItemIndex,
|
|
38
|
+
type SortedIndex,
|
|
39
|
+
type SortedIndexSelection,
|
|
40
|
+
type SortedIndexSelectionType
|
|
41
|
+
} from '@travetto/model-indexed';
|
|
42
|
+
import { type Class, castTo, JSONUtil, ShutdownManager, TimeUtil } from '@travetto/runtime';
|
|
11
43
|
|
|
12
44
|
import type { DynamoDBModelConfig } from './config.ts';
|
|
13
45
|
import { DynamoDBUtil } from './util.ts';
|
|
14
46
|
|
|
15
|
-
const
|
|
47
|
+
const EXPIRES_ATTRIBUTE = 'expires_at__';
|
|
48
|
+
|
|
49
|
+
const getKey = <T extends ModelType>(computed: ModelIndexedComputedIndex<T>): AttributeValue =>
|
|
50
|
+
DynamoDBUtil.toValue(computed.getKey() || 'NULL');
|
|
51
|
+
const getSort = <T extends ModelType>(computed: ModelIndexedComputedIndex<T>): AttributeValue => DynamoDBUtil.toValue(computed.getSort());
|
|
16
52
|
|
|
17
53
|
/**
|
|
18
54
|
* A model service backed by DynamoDB
|
|
19
55
|
*/
|
|
20
56
|
@Injectable()
|
|
21
57
|
export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySupport, ModelStorageSupport, ModelIndexedSupport {
|
|
22
|
-
|
|
23
58
|
idSource = ModelCrudUtil.uuidSource();
|
|
24
59
|
client: DynamoDB;
|
|
25
60
|
config: DynamoDBModelConfig;
|
|
26
61
|
|
|
27
|
-
constructor(config: DynamoDBModelConfig) {
|
|
62
|
+
constructor(config: DynamoDBModelConfig) {
|
|
63
|
+
this.config = config;
|
|
64
|
+
}
|
|
28
65
|
|
|
29
66
|
#resolveTable(cls: Class): string {
|
|
30
67
|
let table = ModelRegistryIndex.getStoreName(cls);
|
|
@@ -34,7 +71,119 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
34
71
|
return table;
|
|
35
72
|
}
|
|
36
73
|
|
|
37
|
-
async
|
|
74
|
+
async *#scanCollection<T extends ModelType>(
|
|
75
|
+
cls: Class<T>,
|
|
76
|
+
query: (batchSize: number, lastKey: Record<string, AttributeValue> | undefined) => Promise<QueryCommandOutput>,
|
|
77
|
+
options?: ModelListOptions & ModelPageOptions<Record<string, AttributeValue>>
|
|
78
|
+
): AsyncIterable<{ items: T[]; lastKey?: Record<string, AttributeValue> }> {
|
|
79
|
+
const batchSize = options?.batchSizeHint ?? 100;
|
|
80
|
+
const limit = options?.limit ?? Number.MAX_SAFE_INTEGER;
|
|
81
|
+
let startKey = options?.offset ?? undefined;
|
|
82
|
+
let produced = 0;
|
|
83
|
+
do {
|
|
84
|
+
const remaining = limit - produced;
|
|
85
|
+
const batch = await query(Math.min(remaining, batchSize), startKey);
|
|
86
|
+
|
|
87
|
+
if (batch.Count && batch.Items) {
|
|
88
|
+
produced += batch.Count;
|
|
89
|
+
|
|
90
|
+
const items = produced > limit ? batch.Items.slice(0, remaining) : batch.Items;
|
|
91
|
+
startKey = batch.LastEvaluatedKey;
|
|
92
|
+
yield {
|
|
93
|
+
items: await ModelCrudUtil.filterOutNotFound(items.map(item => DynamoDBUtil.loadAndCheckExpiry(cls, item.body.S!))),
|
|
94
|
+
lastKey: startKey
|
|
95
|
+
};
|
|
96
|
+
} else {
|
|
97
|
+
startKey = undefined;
|
|
98
|
+
}
|
|
99
|
+
} while (startKey && produced < limit && !options?.abort?.aborted);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async *#scanIndex<T extends ModelType>(
|
|
103
|
+
cls: Class<T>,
|
|
104
|
+
idx: SortedIndex<T>,
|
|
105
|
+
body: KeyedIndexBody<T>,
|
|
106
|
+
options?: ModelPageOptions<Record<string, AttributeValue>> & ModelListOptions,
|
|
107
|
+
transform?: (query: QueryCommandInput) => QueryCommandInput
|
|
108
|
+
): AsyncIterable<{ items: T[]; lastKey?: Record<string, AttributeValue> }> {
|
|
109
|
+
ModelCrudUtil.ensureNotSubType(cls);
|
|
110
|
+
const computed = ModelIndexedComputedIndex.get(idx, body).validate();
|
|
111
|
+
const { keyIndexName, keyIndexAttribute } = DynamoDBUtil.indexNames(idx.name);
|
|
112
|
+
const expression = { [`:${keyIndexName}`]: getKey(computed) };
|
|
113
|
+
|
|
114
|
+
const finalTransform = transform ?? ((query): QueryCommandInput => query);
|
|
115
|
+
|
|
116
|
+
yield* this.#scanCollection(
|
|
117
|
+
cls,
|
|
118
|
+
(batchSize, lastKey) => {
|
|
119
|
+
const finalized = finalTransform({
|
|
120
|
+
TableName: this.#resolveTable(cls),
|
|
121
|
+
IndexName: keyIndexName,
|
|
122
|
+
ProjectionExpression: 'body',
|
|
123
|
+
KeyConditionExpression: `${keyIndexAttribute} = :${keyIndexName}`,
|
|
124
|
+
ExpressionAttributeValues: expression,
|
|
125
|
+
Limit: batchSize,
|
|
126
|
+
ExclusiveStartKey: lastKey
|
|
127
|
+
});
|
|
128
|
+
return this.client.query(finalized);
|
|
129
|
+
},
|
|
130
|
+
options
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async #getIdByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
|
|
135
|
+
cls: Class<T>,
|
|
136
|
+
idx: SingleItemIndex<T, K, S>,
|
|
137
|
+
body: FullKeyedIndexBody<T, K, S>
|
|
138
|
+
): Promise<string> {
|
|
139
|
+
ModelCrudUtil.ensureNotSubType(cls);
|
|
140
|
+
|
|
141
|
+
const computed = ModelIndexedComputedIndex.get(idx, body).validate({ sort: true });
|
|
142
|
+
|
|
143
|
+
const { keyIndexName, keyIndexAttribute, sortIndexAttribute, sortIndexName } = DynamoDBUtil.indexNames(idx.name);
|
|
144
|
+
const sorted = idx.type === 'indexed:sorted';
|
|
145
|
+
|
|
146
|
+
const query: QueryCommandInput = {
|
|
147
|
+
TableName: this.#resolveTable(cls),
|
|
148
|
+
IndexName: keyIndexName,
|
|
149
|
+
ProjectionExpression: 'id',
|
|
150
|
+
KeyConditionExpression: [
|
|
151
|
+
...(sorted ? [`${sortIndexAttribute} = :${sortIndexName}`] : []),
|
|
152
|
+
`${keyIndexAttribute} = :${keyIndexName}`
|
|
153
|
+
].join(' and '),
|
|
154
|
+
...(computed.idPart
|
|
155
|
+
? {
|
|
156
|
+
FilterExpression: 'id = :id'
|
|
157
|
+
}
|
|
158
|
+
: {}),
|
|
159
|
+
ExpressionAttributeValues: {
|
|
160
|
+
[`:${keyIndexName}`]: getKey(computed),
|
|
161
|
+
...(sorted ? { [`:${sortIndexName}`]: getSort(computed) } : {}),
|
|
162
|
+
...(computed.idPart ? { ':id': DynamoDBUtil.toValue(computed.idPart.value) } : {})
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const result = await this.client.query(query);
|
|
168
|
+
|
|
169
|
+
if (result.Count && result.Items?.[0]) {
|
|
170
|
+
return result.Items[0].id.S!;
|
|
171
|
+
}
|
|
172
|
+
throw new NotFoundError(`${cls.name} Index=${idx}`, computed.getKey({ sort: true }));
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error instanceof Error && error.message.includes('The table does not have the specified index')) {
|
|
175
|
+
throw new NotFoundError(`${cls.name} Index=${idx}`, computed.getKey({ sort: true }));
|
|
176
|
+
}
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async #putItem<T extends ModelType>(
|
|
182
|
+
cls: Class<T>,
|
|
183
|
+
id: string,
|
|
184
|
+
item: T,
|
|
185
|
+
mode: 'create' | 'update' | 'upsert'
|
|
186
|
+
): Promise<PutItemCommandOutput> {
|
|
38
187
|
const config = ModelRegistryIndex.getConfig(cls);
|
|
39
188
|
let expiry: number | undefined;
|
|
40
189
|
|
|
@@ -48,12 +197,22 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
48
197
|
try {
|
|
49
198
|
if (mode === 'create') {
|
|
50
199
|
const indices: Record<string, unknown> = {};
|
|
51
|
-
for (const idx of
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
200
|
+
for (const idx of ModelRegistryIndex.getIndices(cls)) {
|
|
201
|
+
if (isModelIndexedIndex(idx)) {
|
|
202
|
+
const { keyIndexAttribute, sortIndexAttribute } = DynamoDBUtil.indexNames(idx.name);
|
|
203
|
+
const computed = ModelIndexedComputedIndex.get(idx, item).validate({ sort: true });
|
|
204
|
+
switch (idx.type) {
|
|
205
|
+
case 'indexed:keyed':
|
|
206
|
+
indices[keyIndexAttribute] = getKey(computed);
|
|
207
|
+
break;
|
|
208
|
+
case 'indexed:sorted': {
|
|
209
|
+
indices[keyIndexAttribute] = getKey(computed);
|
|
210
|
+
indices[sortIndexAttribute] = getSort(computed);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
console.warn('Unsupported index type on update', { cls: cls.name, idx });
|
|
57
216
|
}
|
|
58
217
|
}
|
|
59
218
|
const query: PutItemCommandInput = {
|
|
@@ -62,7 +221,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
62
221
|
Item: {
|
|
63
222
|
id: DynamoDBUtil.toValue(item.id),
|
|
64
223
|
body: DynamoDBUtil.toValue(JSONUtil.toUTF8(item)),
|
|
65
|
-
...(expiry !== undefined ? { [
|
|
224
|
+
...(expiry !== undefined ? { [EXPIRES_ATTRIBUTE]: DynamoDBUtil.toValue(expiry) } : {}),
|
|
66
225
|
...indices
|
|
67
226
|
},
|
|
68
227
|
ReturnValues: 'NONE'
|
|
@@ -71,14 +230,27 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
71
230
|
} else {
|
|
72
231
|
const indices: Record<string, unknown> = {};
|
|
73
232
|
const expr: string[] = [];
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
233
|
+
|
|
234
|
+
for (const idx of ModelRegistryIndex.getIndices(cls)) {
|
|
235
|
+
if (isModelIndexedIndex(idx)) {
|
|
236
|
+
const { keyIndexAttribute, sortIndexAttribute, keyIndexName, sortIndexName } = DynamoDBUtil.indexNames(idx.name);
|
|
237
|
+
const computed = ModelIndexedComputedIndex.get(idx, item).validate({ sort: true });
|
|
238
|
+
switch (idx.type) {
|
|
239
|
+
case 'indexed:keyed': {
|
|
240
|
+
indices[`:${keyIndexName}`] = getKey(computed);
|
|
241
|
+
expr.push(`${keyIndexAttribute} = :${keyIndexName}`);
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
case 'indexed:sorted': {
|
|
245
|
+
indices[`:${keyIndexName}`] = getKey(computed);
|
|
246
|
+
indices[`:${sortIndexName}`] = getSort(computed);
|
|
247
|
+
expr.push(`${keyIndexAttribute} = :${keyIndexName}`);
|
|
248
|
+
expr.push(`${sortIndexAttribute} = :${sortIndexName}`);
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
console.warn('Unsupported index type on update', { cls: cls.name, idx });
|
|
82
254
|
}
|
|
83
255
|
}
|
|
84
256
|
|
|
@@ -86,11 +258,9 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
86
258
|
TableName: this.#resolveTable(cls),
|
|
87
259
|
ConditionExpression: mode === 'update' ? 'attribute_exists(body)' : undefined,
|
|
88
260
|
Key: { id: { S: id } },
|
|
89
|
-
UpdateExpression: `SET ${[
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
...expr
|
|
93
|
-
].filter(part => !!part).join(', ')}`,
|
|
261
|
+
UpdateExpression: `SET ${['body=:body', expiry !== undefined ? `${EXPIRES_ATTRIBUTE}=:expr` : undefined, ...expr]
|
|
262
|
+
.filter(part => !!part)
|
|
263
|
+
.join(', ')}`,
|
|
94
264
|
ExpressionAttributeValues: {
|
|
95
265
|
':body': DynamoDBUtil.toValue(JSONUtil.toUTF8(item)),
|
|
96
266
|
...(expiry !== undefined ? { ':expr': DynamoDBUtil.toValue(expiry) } : {}),
|
|
@@ -139,10 +309,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
139
309
|
TableName: table,
|
|
140
310
|
KeySchema: [{ KeyType: 'HASH', AttributeName: 'id' }],
|
|
141
311
|
BillingMode: 'PAY_PER_REQUEST',
|
|
142
|
-
AttributeDefinitions: [
|
|
143
|
-
{ AttributeName: 'id', AttributeType: 'S' },
|
|
144
|
-
...idx.attributes
|
|
145
|
-
],
|
|
312
|
+
AttributeDefinitions: [{ AttributeName: 'id', AttributeType: 'S' }, ...idx.attributes],
|
|
146
313
|
GlobalSecondaryIndexes: idx.indices
|
|
147
314
|
});
|
|
148
315
|
} else {
|
|
@@ -154,10 +321,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
154
321
|
if (changedAttributes.length || indexUpdates?.length) {
|
|
155
322
|
await this.client.updateTable({
|
|
156
323
|
TableName: table,
|
|
157
|
-
AttributeDefinitions: [
|
|
158
|
-
{ AttributeName: 'id', AttributeType: 'S' },
|
|
159
|
-
...idx.attributes
|
|
160
|
-
],
|
|
324
|
+
AttributeDefinitions: [{ AttributeName: 'id', AttributeType: 'S' }, ...idx.attributes],
|
|
161
325
|
GlobalSecondaryIndexUpdates: indexUpdates
|
|
162
326
|
});
|
|
163
327
|
}
|
|
@@ -168,7 +332,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
168
332
|
if (ttlEnabled !== ttlRequired) {
|
|
169
333
|
await this.client.updateTimeToLive({
|
|
170
334
|
TableName: table,
|
|
171
|
-
TimeToLiveSpecification: { AttributeName: ttlRequired ?
|
|
335
|
+
TimeToLiveSpecification: { AttributeName: ttlRequired ? EXPIRES_ATTRIBUTE : undefined, Enabled: ttlRequired }
|
|
172
336
|
});
|
|
173
337
|
}
|
|
174
338
|
}
|
|
@@ -179,7 +343,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
179
343
|
*/
|
|
180
344
|
async deleteModel(cls: Class<ModelType>): Promise<void> {
|
|
181
345
|
const table = this.#resolveTable(cls);
|
|
182
|
-
const { Table: verify } =
|
|
346
|
+
const { Table: verify } = await this.client.describeTable({ TableName: table }).catch(() => ({ Table: undefined }));
|
|
183
347
|
if (verify) {
|
|
184
348
|
await this.client.deleteTable({ TableName: table });
|
|
185
349
|
}
|
|
@@ -191,9 +355,11 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
191
355
|
|
|
192
356
|
async deleteStorage(): Promise<void> {
|
|
193
357
|
for (const model of ModelRegistryIndex.getClasses()) {
|
|
194
|
-
await this.client
|
|
195
|
-
|
|
196
|
-
|
|
358
|
+
await this.client
|
|
359
|
+
.deleteTable({
|
|
360
|
+
TableName: this.#resolveTable(model)
|
|
361
|
+
})
|
|
362
|
+
.catch(() => {});
|
|
197
363
|
}
|
|
198
364
|
}
|
|
199
365
|
|
|
@@ -204,7 +370,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
204
370
|
Key: { id: DynamoDBUtil.toValue(id) }
|
|
205
371
|
});
|
|
206
372
|
|
|
207
|
-
if (result
|
|
373
|
+
if (result?.Item?.body) {
|
|
208
374
|
return DynamoDBUtil.loadAndCheckExpiry(cls, result.Item.body.S!);
|
|
209
375
|
}
|
|
210
376
|
throw new NotFoundError(cls, id);
|
|
@@ -253,32 +419,18 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
253
419
|
}
|
|
254
420
|
}
|
|
255
421
|
|
|
256
|
-
async *
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
yield await DynamoDBUtil.loadAndCheckExpiry(cls, item.body.S!);
|
|
269
|
-
} catch (error) {
|
|
270
|
-
if (!(error instanceof NotFoundError)) {
|
|
271
|
-
throw error;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
if (!batch.Count || !batch.LastEvaluatedKey) {
|
|
278
|
-
done = true;
|
|
279
|
-
} else {
|
|
280
|
-
token = batch.LastEvaluatedKey;
|
|
281
|
-
}
|
|
422
|
+
async *list<T extends ModelType>(cls: Class<T>, options?: ModelListOptions): AsyncIterable<T[]> {
|
|
423
|
+
for await (const { items } of this.#scanCollection(
|
|
424
|
+
cls,
|
|
425
|
+
(batchSize, lastKey) =>
|
|
426
|
+
this.client.scan({
|
|
427
|
+
TableName: this.#resolveTable(cls),
|
|
428
|
+
ExclusiveStartKey: lastKey,
|
|
429
|
+
Limit: batchSize
|
|
430
|
+
}),
|
|
431
|
+
options
|
|
432
|
+
)) {
|
|
433
|
+
yield items;
|
|
282
434
|
}
|
|
283
435
|
}
|
|
284
436
|
|
|
@@ -288,92 +440,106 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
|
|
|
288
440
|
}
|
|
289
441
|
|
|
290
442
|
// Indexed
|
|
291
|
-
async
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
443
|
+
async getByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
|
|
444
|
+
cls: Class<T>,
|
|
445
|
+
idx: SingleItemIndex<T, K, S>,
|
|
446
|
+
body: FullKeyedIndexBody<T, K, S>
|
|
447
|
+
): Promise<T> {
|
|
448
|
+
return this.get(cls, await this.#getIdByIndex(cls, idx, body));
|
|
449
|
+
}
|
|
297
450
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
451
|
+
async deleteByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
|
|
452
|
+
cls: Class<T>,
|
|
453
|
+
idx: SingleItemIndex<T, K, S>,
|
|
454
|
+
body: FullKeyedIndexBody<T, K, S>
|
|
455
|
+
): Promise<void> {
|
|
456
|
+
return this.delete(cls, await this.#getIdByIndex(cls, idx, body));
|
|
457
|
+
}
|
|
301
458
|
|
|
302
|
-
|
|
459
|
+
upsertByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
|
|
460
|
+
cls: Class<T>,
|
|
461
|
+
idx: SingleItemIndex<T, K, S>,
|
|
462
|
+
body: OptionalId<T>
|
|
463
|
+
): Promise<T> {
|
|
464
|
+
return ModelIndexedUtil.naiveUpsert(this, cls, idx, body);
|
|
465
|
+
}
|
|
303
466
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
ExpressionAttributeValues: {
|
|
312
|
-
[`:${idxName}`]: DynamoDBUtil.toValue(key),
|
|
313
|
-
...(sort ? { [`:${idxName}_sort`]: DynamoDBUtil.toValue(+sort) } : {})
|
|
314
|
-
}
|
|
315
|
-
};
|
|
467
|
+
async updateByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
|
|
468
|
+
cls: Class<T>,
|
|
469
|
+
idx: SingleItemIndex<T, K, S>,
|
|
470
|
+
body: T
|
|
471
|
+
): Promise<T> {
|
|
472
|
+
return ModelIndexedUtil.naiveUpdate(this, cls, idx, body);
|
|
473
|
+
}
|
|
316
474
|
|
|
317
|
-
|
|
475
|
+
async updatePartialByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
|
|
476
|
+
cls: Class<T>,
|
|
477
|
+
idx: SingleItemIndex<T, K, S>,
|
|
478
|
+
body: FullKeyedIndexWithPartialBody<T, K, S>
|
|
479
|
+
): Promise<T> {
|
|
480
|
+
const item = await ModelCrudUtil.naivePartialUpdate(cls, () => this.getByIndex(cls, idx, castTo(body)), castTo(body));
|
|
481
|
+
return this.update(cls, item);
|
|
482
|
+
}
|
|
318
483
|
|
|
319
|
-
|
|
320
|
-
|
|
484
|
+
async pageByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
|
|
485
|
+
cls: Class<T>,
|
|
486
|
+
idx: SortedIndex<T, K, S>,
|
|
487
|
+
body: KeyedIndexBody<T, K>,
|
|
488
|
+
options?: ModelPageOptions
|
|
489
|
+
): Promise<ModelPageResult<T>> {
|
|
490
|
+
const output: T[] = [];
|
|
491
|
+
const offset = options?.offset ? JSONUtil.fromBase64<Record<string, AttributeValue>>(options.offset) : undefined;
|
|
492
|
+
for await (const { items } of this.#scanIndex(cls, idx, body, { limit: 100, ...options, offset })) {
|
|
493
|
+
output.push(...items);
|
|
321
494
|
}
|
|
322
|
-
throw new NotFoundError(`${cls.name} Index=${idx}`, key);
|
|
323
|
-
}
|
|
324
495
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
496
|
+
let nextOffset: string | undefined;
|
|
497
|
+
if (output.length) {
|
|
498
|
+
const last: T = output.at(-1)!;
|
|
499
|
+
const computed = ModelIndexedComputedIndex.get(idx, last).validate();
|
|
500
|
+
const { keyIndexAttribute, sortIndexAttribute } = DynamoDBUtil.indexNames(idx.name);
|
|
501
|
+
nextOffset = JSONUtil.toBase64({
|
|
502
|
+
[keyIndexAttribute]: getKey(computed),
|
|
503
|
+
[sortIndexAttribute]: getSort(computed),
|
|
504
|
+
id: DynamoDBUtil.toValue(last.id)
|
|
505
|
+
});
|
|
506
|
+
}
|
|
329
507
|
|
|
330
|
-
|
|
331
|
-
return this.delete(cls, await this.#getIdByIndex(cls, idx, body));
|
|
508
|
+
return { items: output, nextOffset };
|
|
332
509
|
}
|
|
333
510
|
|
|
334
|
-
|
|
335
|
-
|
|
511
|
+
async *listByIndex<T extends ModelType, K extends KeyedIndexSelection<T>, S extends SortedIndexSelection<T>>(
|
|
512
|
+
cls: Class<T>,
|
|
513
|
+
idx: SortedIndex<T, K, S>,
|
|
514
|
+
body: KeyedIndexBody<T, K>,
|
|
515
|
+
options?: ModelListOptions
|
|
516
|
+
): AsyncIterable<T[]> {
|
|
517
|
+
for await (const { items } of this.#scanIndex(cls, idx, body, options)) {
|
|
518
|
+
yield items;
|
|
519
|
+
}
|
|
336
520
|
}
|
|
337
521
|
|
|
338
|
-
async
|
|
339
|
-
|
|
522
|
+
async suggestByIndex<
|
|
523
|
+
T extends ModelType,
|
|
524
|
+
S extends SortedIndexSelection<T>,
|
|
525
|
+
K extends KeyedIndexSelection<T>,
|
|
526
|
+
B extends SortedIndexSelectionType<T, S> & string
|
|
527
|
+
>(cls: Class<T>, idx: SortedIndex<T, K, S>, body: KeyedIndexBody<T, K>, prefix: B, options?: ModelIndexedSearchOptions): Promise<T[]> {
|
|
528
|
+
const results: T[] = [];
|
|
340
529
|
|
|
341
|
-
const
|
|
342
|
-
const { key } = ModelIndexedUtil.computeIndexKey(cls, config, body, { emptySortValue: null });
|
|
343
|
-
|
|
344
|
-
const idxName = DynamoDBUtil.simpleName(idx);
|
|
345
|
-
|
|
346
|
-
let done = false;
|
|
347
|
-
let token: Record<string, AttributeValue> | undefined;
|
|
348
|
-
while (!done) {
|
|
349
|
-
const batch = await this.client.query({
|
|
350
|
-
TableName: this.#resolveTable(cls),
|
|
351
|
-
IndexName: idxName,
|
|
352
|
-
ProjectionExpression: 'body',
|
|
353
|
-
KeyConditionExpression: `${idxName}__ = :${idxName}`,
|
|
354
|
-
ExpressionAttributeValues: {
|
|
355
|
-
[`:${idxName}`]: DynamoDBUtil.toValue(key)
|
|
356
|
-
},
|
|
357
|
-
ExclusiveStartKey: token
|
|
358
|
-
});
|
|
530
|
+
const { sortIndexAttribute } = DynamoDBUtil.indexNames(idx.name);
|
|
359
531
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
throw error;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (!batch.Count || !batch.LastEvaluatedKey) {
|
|
373
|
-
done = true;
|
|
374
|
-
} else {
|
|
375
|
-
token = batch.LastEvaluatedKey;
|
|
532
|
+
for await (const { items } of this.#scanIndex(cls, idx, body, { limit: 10, ...options }, query => ({
|
|
533
|
+
...query,
|
|
534
|
+
KeyConditionExpression: [query.KeyConditionExpression, `begins_with(${sortIndexAttribute}, :prefix)`].filter(Boolean).join(' AND '),
|
|
535
|
+
ExpressionAttributeValues: {
|
|
536
|
+
...query.ExpressionAttributeValues,
|
|
537
|
+
':prefix': { S: prefix }
|
|
376
538
|
}
|
|
539
|
+
}))) {
|
|
540
|
+
results.push(...items);
|
|
377
541
|
}
|
|
542
|
+
|
|
543
|
+
return results;
|
|
378
544
|
}
|
|
379
|
-
}
|
|
545
|
+
}
|
package/src/util.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
AttributeDefinition,
|
|
3
|
-
|
|
2
|
+
AttributeDefinition,
|
|
3
|
+
AttributeValue,
|
|
4
|
+
GlobalSecondaryIndex,
|
|
5
|
+
GlobalSecondaryIndexDescription,
|
|
6
|
+
GlobalSecondaryIndexUpdate,
|
|
7
|
+
KeySchemaElement
|
|
4
8
|
} from '@aws-sdk/client-dynamodb';
|
|
5
9
|
|
|
6
|
-
import type
|
|
7
|
-
import {
|
|
10
|
+
import { ModelCrudUtil, ModelExpiryUtil, ModelRegistryIndex, type ModelType, NotFoundError } from '@travetto/model';
|
|
11
|
+
import { isModelIndexedIndex, warnIfIndexedUniqueIndex, warnIfNonIndexedIndex } from '@travetto/model-indexed';
|
|
12
|
+
import { type Class, castTo } from '@travetto/runtime';
|
|
13
|
+
import { SchemaRegistryIndex } from '@travetto/schema';
|
|
8
14
|
|
|
9
15
|
/**
|
|
10
16
|
* Configuration for DynamoDB indices
|
|
@@ -18,13 +24,17 @@ type DynamoIndexConfig = {
|
|
|
18
24
|
* Utility class for DynamoDB operations and transformations.
|
|
19
25
|
*/
|
|
20
26
|
export class DynamoDBUtil {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
static indexNames = (
|
|
28
|
+
name: string
|
|
29
|
+
): { keyIndexName: string; sortIndexName: string; keyIndexAttribute: string; sortIndexAttribute: string } => {
|
|
30
|
+
const base = name.toLowerCase().replace(/[^A-Za-z0-9]+/g, '_');
|
|
31
|
+
return {
|
|
32
|
+
keyIndexName: base,
|
|
33
|
+
sortIndexName: `${base}_sort`,
|
|
34
|
+
keyIndexAttribute: `${base}__`,
|
|
35
|
+
sortIndexAttribute: `${base}_sort__`
|
|
36
|
+
};
|
|
37
|
+
};
|
|
28
38
|
|
|
29
39
|
/**
|
|
30
40
|
* Converts a JavaScript value to a DynamoDB AttributeValue format
|
|
@@ -51,29 +61,50 @@ export class DynamoDBUtil {
|
|
|
51
61
|
* Generates global secondary indices and attribute definitions based on the model's index configuration.
|
|
52
62
|
*/
|
|
53
63
|
static computeIndexConfig<T extends ModelType>(cls: Class<T>): DynamoIndexConfig {
|
|
54
|
-
const
|
|
64
|
+
const indexes = ModelRegistryIndex.getIndices(cls);
|
|
55
65
|
const attributes: AttributeDefinition[] = [];
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
const toCreate: GlobalSecondaryIndex[] = [];
|
|
67
|
+
|
|
68
|
+
const filtered = indexes
|
|
69
|
+
.filter(idx => !warnIfIndexedUniqueIndex(this, cls, [idx]))
|
|
70
|
+
.filter(idx => !warnIfNonIndexedIndex(this, cls, [idx]))
|
|
71
|
+
.filter(isModelIndexedIndex);
|
|
72
|
+
|
|
73
|
+
for (const idx of filtered) {
|
|
74
|
+
const keys: KeySchemaElement[] = [];
|
|
75
|
+
|
|
76
|
+
const { keyIndexName, keyIndexAttribute, sortIndexAttribute } = this.indexNames(idx.name);
|
|
77
|
+
|
|
78
|
+
switch (idx.type) {
|
|
79
|
+
case 'indexed:sorted': {
|
|
80
|
+
const path = idx.sortTemplate[0].path;
|
|
81
|
+
let fieldType = cls;
|
|
82
|
+
for (const field of path) {
|
|
83
|
+
if (SchemaRegistryIndex.has(fieldType)) {
|
|
84
|
+
const schema = SchemaRegistryIndex.getConfig(fieldType);
|
|
85
|
+
if (field in schema.fields) {
|
|
86
|
+
fieldType = schema.fields[field].type;
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
keys.push({ AttributeName: keyIndexAttribute, KeyType: 'HASH' });
|
|
94
|
+
keys.push({ AttributeName: sortIndexAttribute, KeyType: 'RANGE' });
|
|
95
|
+
attributes.push({ AttributeName: keyIndexAttribute, AttributeType: 'S' });
|
|
96
|
+
attributes.push({ AttributeName: sortIndexAttribute, AttributeType: castTo(fieldType) === String ? 'S' : 'N' });
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case 'indexed:keyed': {
|
|
100
|
+
keys.push({ AttributeName: keyIndexAttribute, KeyType: 'HASH' });
|
|
101
|
+
attributes.push({ AttributeName: keyIndexAttribute, AttributeType: 'S' });
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
73
104
|
}
|
|
74
105
|
|
|
75
|
-
|
|
76
|
-
IndexName:
|
|
106
|
+
toCreate.push({
|
|
107
|
+
IndexName: keyIndexName,
|
|
77
108
|
// ProvisionedThroughput: '',
|
|
78
109
|
Projection: {
|
|
79
110
|
ProjectionType: 'INCLUDE',
|
|
@@ -83,14 +114,17 @@ export class DynamoDBUtil {
|
|
|
83
114
|
});
|
|
84
115
|
}
|
|
85
116
|
|
|
86
|
-
return { indices:
|
|
117
|
+
return { indices: toCreate.length ? toCreate : undefined, attributes };
|
|
87
118
|
}
|
|
88
119
|
|
|
89
120
|
/**
|
|
90
121
|
* Identifies attribute definitions that have changed between current and requested configurations.
|
|
91
122
|
* Compares attribute names and types to determine additions, removals, or modifications.
|
|
92
123
|
*/
|
|
93
|
-
static findChangedAttributes(
|
|
124
|
+
static findChangedAttributes(
|
|
125
|
+
current: AttributeDefinition[] | undefined,
|
|
126
|
+
requested: AttributeDefinition[] | undefined
|
|
127
|
+
): AttributeDefinition[] {
|
|
94
128
|
const currentMap = Object.fromEntries((current ?? []).map(attr => [attr.AttributeName, attr]));
|
|
95
129
|
const pendingMap = Object.fromEntries((requested ?? []).map(attr => [attr.AttributeName, attr]));
|
|
96
130
|
|
|
@@ -119,7 +153,10 @@ export class DynamoDBUtil {
|
|
|
119
153
|
* Identifies global secondary indices that have changed between current and requested configurations.
|
|
120
154
|
* Generates update operations for creating new indices or deleting removed indices.
|
|
121
155
|
*/
|
|
122
|
-
static findChangedGlobalIndexes(
|
|
156
|
+
static findChangedGlobalIndexes(
|
|
157
|
+
current: GlobalSecondaryIndexDescription[] | undefined,
|
|
158
|
+
requested: GlobalSecondaryIndex[] | undefined
|
|
159
|
+
): GlobalSecondaryIndexUpdate[] {
|
|
123
160
|
const existingMap = Object.fromEntries((current ?? []).map(index => [index.IndexName, index]));
|
|
124
161
|
const pendingMap = Object.fromEntries((requested ?? []).map(index => [index.IndexName, index]));
|
|
125
162
|
|