@travetto/model-dynamodb 8.0.0-alpha.3 → 8.0.0-alpha.30

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 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."). 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.
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#L11)
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/indexed.ts#L11)
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
@@ -1,3 +1,3 @@
1
+ export * from './src/config.ts';
1
2
  export * from './src/service.ts';
2
3
  export * from './src/util.ts';
3
- export * from './src/config.ts';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/model-dynamodb",
3
- "version": "8.0.0-alpha.3",
3
+ "version": "8.0.0-alpha.30",
4
4
  "type": "module",
5
5
  "description": "DynamoDB backing for the travetto model module.",
6
6
  "keywords": [
@@ -26,12 +26,13 @@
26
26
  "directory": "module/model-dynamodb"
27
27
  },
28
28
  "dependencies": {
29
- "@aws-sdk/client-dynamodb": "^3.1006.0",
30
- "@travetto/config": "^8.0.0-alpha.3",
31
- "@travetto/model": "^8.0.0-alpha.3"
29
+ "@aws-sdk/client-dynamodb": "^3.1116.0",
30
+ "@travetto/config": "^8.0.0-alpha.26",
31
+ "@travetto/model": "^8.0.0-alpha.27",
32
+ "@travetto/model-indexed": "^8.0.0-alpha.29"
32
33
  },
33
34
  "peerDependencies": {
34
- "@travetto/cli": "^8.0.0-alpha.4"
35
+ "@travetto/cli": "^8.0.0-alpha.32"
35
36
  },
36
37
  "peerDependenciesMeta": {
37
38
  "@travetto/cli": {
package/src/config.ts CHANGED
@@ -18,4 +18,4 @@ export class DynamoDBModelConfig {
18
18
  this.client.endpoint ??= 'http://localhost:8000'; // From docker
19
19
  }
20
20
  }
21
- }
21
+ }
package/src/service.ts CHANGED
@@ -1,30 +1,67 @@
1
- import { type AttributeValue, DynamoDB, type PutItemCommandInput, type PutItemCommandOutput } from '@aws-sdk/client-dynamodb';
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
- type ModelCrudSupport, type ModelExpirySupport, ModelRegistryIndex, type ModelStorageSupport,
7
- type ModelIndexedSupport, type ModelType, NotFoundError, ExistsError,
8
- IndexNotSupported, type OptionalId,
9
- ModelCrudUtil, ModelExpiryUtil, ModelIndexedUtil, ModelStorageUtil
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 EXP_ATTR = 'expires_at__';
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) { this.config = config; }
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 #putItem<T extends ModelType>(cls: Class<T>, id: string, item: T, mode: 'create' | 'update' | 'upsert'): Promise<PutItemCommandOutput> {
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 config.indices ?? []) {
52
- const { key, sort } = ModelIndexedUtil.computeIndexKey(cls, idx, item);
53
- const property = DynamoDBUtil.simpleName(idx.name);
54
- indices[`${property}__`] = DynamoDBUtil.toValue(key);
55
- if (sort) {
56
- indices[`${property}_sort__`] = DynamoDBUtil.toValue(+sort);
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 ? { [EXP_ATTR]: DynamoDBUtil.toValue(expiry) } : {}),
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
- for (const idx of config.indices ?? []) {
75
- const { key, sort } = ModelIndexedUtil.computeIndexKey(cls, idx, item);
76
- const property = DynamoDBUtil.simpleName(idx.name);
77
- indices[`:${property}`] = DynamoDBUtil.toValue(key);
78
- expr.push(`${property}__ = :${property}`);
79
- if (sort) {
80
- indices[`:${property}_sort`] = DynamoDBUtil.toValue(+sort);
81
- expr.push(`${property}_sort__ = :${property}_sort`);
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
- 'body=:body',
91
- expiry !== undefined ? `${EXP_ATTR}=:expr` : undefined,
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 ? EXP_ATTR : undefined, Enabled: 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 } = (await this.client.describeTable({ TableName: table }).catch(() => ({ Table: undefined })));
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.deleteTable({
195
- TableName: this.#resolveTable(model)
196
- }).catch(() => { });
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 && result.Item && result.Item.body) {
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 * list<T extends ModelType>(cls: Class<T>): AsyncIterable<T> {
257
- let done = false;
258
- let token: Record<string, AttributeValue> | undefined;
259
- while (!done) {
260
- const batch = await this.client.scan({
261
- TableName: this.#resolveTable(cls),
262
- ExclusiveStartKey: token
263
- });
264
-
265
- if (batch.Count && batch.Items) {
266
- for (const item of batch.Items) {
267
- try {
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 #getIdByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<string> {
292
- ModelCrudUtil.ensureNotSubType(cls);
293
-
294
- const idxConfig = ModelRegistryIndex.getIndex(cls, idx, ['sorted', 'unsorted']);
295
-
296
- const { key, sort } = ModelIndexedUtil.computeIndexKey(cls, idxConfig, body);
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
- if (idxConfig.type === 'sorted' && sort === undefined) {
299
- throw new IndexNotSupported(cls, idxConfig, 'Sorted indices require the sort field');
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
- const idxName = DynamoDBUtil.simpleName(idx);
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
- const query = {
305
- TableName: this.#resolveTable(cls),
306
- IndexName: idxName,
307
- ProjectionExpression: 'id',
308
- KeyConditionExpression: [sort ? `${idxName}_sort__ = :${idxName}_sort` : '', `${idxName}__ = :${idxName}`]
309
- .filter(expr => !!expr)
310
- .join(' and '),
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
- const result = await this.client.query(query);
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
- if (result.Count && result.Items && result.Items[0]) {
320
- return result.Items[0].id.S!;
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
- // Indexed
326
- async getByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<T> {
327
- return this.get(cls, await this.#getIdByIndex(cls, idx, body));
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
- async deleteByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<void> {
331
- return this.delete(cls, await this.#getIdByIndex(cls, idx, body));
508
+ return { items: output, nextOffset };
332
509
  }
333
510
 
334
- upsertByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: OptionalId<T>): Promise<T> {
335
- return ModelIndexedUtil.naiveUpsert(this, cls, idx, body);
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 * listByIndex<T extends ModelType>(cls: Class<T>, idx: string, body?: DeepPartial<T>): AsyncIterable<T> {
339
- ModelCrudUtil.ensureNotSubType(cls);
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 config = ModelRegistryIndex.getIndex(cls, idx, ['sorted', 'unsorted']);
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
- if (batch.Count && batch.Items) {
361
- for (const item of batch.Items) {
362
- try {
363
- yield await DynamoDBUtil.loadAndCheckExpiry(cls, item.body.S!);
364
- } catch (error) {
365
- if (!(error instanceof NotFoundError)) {
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, AttributeValue, GlobalSecondaryIndex, GlobalSecondaryIndexDescription,
3
- GlobalSecondaryIndexUpdate, KeySchemaElement
2
+ AttributeDefinition,
3
+ AttributeValue,
4
+ GlobalSecondaryIndex,
5
+ GlobalSecondaryIndexDescription,
6
+ GlobalSecondaryIndexUpdate,
7
+ KeySchemaElement
4
8
  } from '@aws-sdk/client-dynamodb';
5
9
 
6
- import type { Class } from '@travetto/runtime';
7
- import { ModelCrudUtil, ModelExpiryUtil, ModelRegistryIndex, NotFoundError, type ModelType } from '@travetto/model';
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
- * 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
- }
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 config = ModelRegistryIndex.getConfig(cls);
64
+ const indexes = ModelRegistryIndex.getIndices(cls);
55
65
  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' });
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
- indices.push({
76
- IndexName: idxName,
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: indices.length ? indices : undefined, attributes };
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(current: AttributeDefinition[] | undefined, requested: AttributeDefinition[] | undefined): AttributeDefinition[] {
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(current: GlobalSecondaryIndexDescription[] | undefined, requested: GlobalSecondaryIndex[] | undefined): GlobalSecondaryIndexUpdate[] {
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
 
@@ -7,4 +7,4 @@ export const service: ServiceDescriptor = {
7
7
  version,
8
8
  port: 8000,
9
9
  image: `amazon/dynamodb-local:${version}`
10
- };
10
+ };