@travetto/model-dynamodb 8.0.0-alpha.2 → 8.0.0-alpha.21

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
@@ -18,7 +18,7 @@ This module provides an [DynamoDB](https://aws.amazon.com/dynamodb/)-based imple
18
18
  Supported features:
19
19
  * [CRUD](https://github.com/travetto/travetto/tree/main/module/model/src/types/crud.ts#L11)
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#L16)
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@travetto/model-dynamodb",
3
- "version": "8.0.0-alpha.2",
3
+ "version": "8.0.0-alpha.21",
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.1004.0",
30
- "@travetto/config": "^8.0.0-alpha.2",
31
- "@travetto/model": "^8.0.0-alpha.2"
29
+ "@aws-sdk/client-dynamodb": "^3.1063.0",
30
+ "@travetto/config": "^8.0.0-alpha.18",
31
+ "@travetto/model": "^8.0.0-alpha.19",
32
+ "@travetto/model-indexed": "^8.0.0-alpha.21"
32
33
  },
33
34
  "peerDependencies": {
34
- "@travetto/cli": "^8.0.0-alpha.3"
35
+ "@travetto/cli": "^8.0.0-alpha.24"
35
36
  },
36
37
  "peerDependenciesMeta": {
37
38
  "@travetto/cli": {
package/src/service.ts CHANGED
@@ -1,18 +1,27 @@
1
- import { type AttributeValue, DynamoDB, type PutItemCommandInput, type PutItemCommandOutput } from '@aws-sdk/client-dynamodb';
1
+ import { type AttributeValue, DynamoDB, type PutItemCommandInput, type PutItemCommandOutput, type QueryCommandInput, type QueryCommandOutput } from '@aws-sdk/client-dynamodb';
2
2
 
3
- import { JSONUtil, ShutdownManager, TimeUtil, type Class, type DeepPartial } from '@travetto/runtime';
3
+ import { castTo, JSONUtil, ShutdownManager, TimeUtil, type Class } from '@travetto/runtime';
4
4
  import { Injectable, PostConstruct } from '@travetto/di';
5
5
  import {
6
6
  type ModelCrudSupport, type ModelExpirySupport, ModelRegistryIndex, type ModelStorageSupport,
7
- type ModelIndexedSupport, type ModelType, NotFoundError, ExistsError,
8
- IndexNotSupported, type OptionalId,
9
- ModelCrudUtil, ModelExpiryUtil, ModelIndexedUtil, ModelStorageUtil
7
+ type ModelType, NotFoundError, ExistsError, type OptionalId, ModelCrudUtil,
8
+ ModelExpiryUtil, ModelStorageUtil,
9
+ type ModelListOptions,
10
10
  } from '@travetto/model';
11
+ import {
12
+ isModelIndexedIndex, ModelIndexedUtil, type KeyedIndexBody, type KeyedIndexSelection,
13
+ type ModelPageOptions, type ModelPageResult, type ModelIndexedSupport, type SingleItemIndex,
14
+ type FullKeyedIndexBody, type FullKeyedIndexWithPartialBody, type SortedIndex, type SortedIndexSelection,
15
+ ModelIndexedComputedIndex, type ModelIndexedSearchOptions, type SortedIndexSelectionType
16
+ } from '@travetto/model-indexed';
11
17
 
12
18
  import type { DynamoDBModelConfig } from './config.ts';
13
19
  import { DynamoDBUtil } from './util.ts';
14
20
 
15
- const EXP_ATTR = 'expires_at__';
21
+ const EXPIRES_ATTRIBUTE = 'expires_at__';
22
+
23
+ const getKey = <T extends ModelType>(computed: ModelIndexedComputedIndex<T>): AttributeValue => DynamoDBUtil.toValue(computed.getKey() || 'NULL');
24
+ const getSort = <T extends ModelType>(computed: ModelIndexedComputedIndex<T>): AttributeValue => DynamoDBUtil.toValue(computed.getSort());
16
25
 
17
26
  /**
18
27
  * A model service backed by DynamoDB
@@ -34,6 +43,109 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
34
43
  return table;
35
44
  }
36
45
 
46
+ async * #scanCollection<T extends ModelType>(
47
+ cls: Class<T>,
48
+ query: (batchSize: number, lastKey: Record<string, AttributeValue> | undefined) => Promise<QueryCommandOutput>,
49
+ options?: ModelListOptions & ModelPageOptions<Record<string, AttributeValue>>,
50
+ ): AsyncIterable<{ items: T[], lastKey?: Record<string, AttributeValue> }> {
51
+ const batchSize = options?.batchSizeHint ?? 100;
52
+ const limit = options?.limit ?? Number.MAX_SAFE_INTEGER;
53
+ let startKey = options?.offset ?? undefined;
54
+ let produced = 0;
55
+ do {
56
+ const remaining = limit - produced;
57
+ const batch = await query(Math.min(remaining, batchSize), startKey);
58
+
59
+ if (batch.Count && batch.Items) {
60
+ produced += batch.Count;
61
+
62
+ const items = (produced > limit) ? batch.Items.slice(0, remaining) : batch.Items;
63
+ startKey = batch.LastEvaluatedKey;
64
+ yield {
65
+ items: await ModelCrudUtil.filterOutNotFound(
66
+ items.map(item => DynamoDBUtil.loadAndCheckExpiry(cls, item.body.S!))),
67
+ lastKey: startKey
68
+ };
69
+ } else {
70
+ startKey = undefined;
71
+ }
72
+ } while (startKey && produced < limit && !(options?.abort?.aborted));
73
+ }
74
+
75
+ async * #scanIndex<T extends ModelType>(
76
+ cls: Class<T>,
77
+ idx: SortedIndex<T>,
78
+ body: KeyedIndexBody<T>,
79
+ options?: ModelPageOptions<Record<string, AttributeValue>> & ModelListOptions,
80
+ transform?: (query: QueryCommandInput) => QueryCommandInput
81
+ ): AsyncIterable<{ items: T[], lastKey?: Record<string, AttributeValue> }> {
82
+ ModelCrudUtil.ensureNotSubType(cls);
83
+ const computed = ModelIndexedComputedIndex.get(idx, body).validate();
84
+ const { keyIndexName, keyIndexAttribute } = DynamoDBUtil.indexNames(idx.name);
85
+ const expression = { [`:${keyIndexName}`]: getKey(computed) };
86
+
87
+ const finalTransform = transform ?? ((query): QueryCommandInput => query);
88
+
89
+ yield* this.#scanCollection(cls, (batchSize, lastKey) => {
90
+ const finalized = finalTransform({
91
+ TableName: this.#resolveTable(cls),
92
+ IndexName: keyIndexName,
93
+ ProjectionExpression: 'body',
94
+ KeyConditionExpression: `${keyIndexAttribute} = :${keyIndexName}`,
95
+ ExpressionAttributeValues: expression,
96
+ Limit: batchSize,
97
+ ExclusiveStartKey: lastKey,
98
+ });
99
+ return this.client.query(finalized);
100
+ }, options);
101
+ }
102
+
103
+ async #getIdByIndex<
104
+ T extends ModelType,
105
+ K extends KeyedIndexSelection<T>,
106
+ S extends SortedIndexSelection<T>
107
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexBody<T, K, S>): Promise<string> {
108
+ ModelCrudUtil.ensureNotSubType(cls);
109
+
110
+ const computed = ModelIndexedComputedIndex.get(idx, body).validate({ sort: true });
111
+
112
+ const { keyIndexName, keyIndexAttribute, sortIndexAttribute, sortIndexName } = DynamoDBUtil.indexNames(idx.name);
113
+ const sorted = idx.type === 'indexed:sorted';
114
+
115
+ const query: QueryCommandInput = {
116
+ TableName: this.#resolveTable(cls),
117
+ IndexName: keyIndexName,
118
+ ProjectionExpression: 'id',
119
+ KeyConditionExpression: [
120
+ ...(sorted ? [`${sortIndexAttribute} = :${sortIndexName}`] : []),
121
+ `${keyIndexAttribute} = :${keyIndexName}`
122
+ ]
123
+ .join(' and '),
124
+ ...(computed.idPart ? {
125
+ FilterExpression: 'id = :id'
126
+ } : {}),
127
+ ExpressionAttributeValues: {
128
+ [`:${keyIndexName}`]: getKey(computed),
129
+ ...(sorted ? { [`:${sortIndexName}`]: getSort(computed) } : {}),
130
+ ...(computed.idPart ? { ':id': DynamoDBUtil.toValue(computed.idPart.value) } : {})
131
+ }
132
+ };
133
+
134
+ try {
135
+ const result = await this.client.query(query);
136
+
137
+ if (result.Count && result.Items && result.Items[0]) {
138
+ return result.Items[0].id.S!;
139
+ }
140
+ throw new NotFoundError(`${cls.name} Index=${idx}`, computed.getKey({ sort: true }));
141
+ } catch (error) {
142
+ if (error instanceof Error && error.message.includes('The table does not have the specified index')) {
143
+ throw new NotFoundError(`${cls.name} Index=${idx}`, computed.getKey({ sort: true }));
144
+ }
145
+ throw error;
146
+ }
147
+ }
148
+
37
149
  async #putItem<T extends ModelType>(cls: Class<T>, id: string, item: T, mode: 'create' | 'update' | 'upsert'): Promise<PutItemCommandOutput> {
38
150
  const config = ModelRegistryIndex.getConfig(cls);
39
151
  let expiry: number | undefined;
@@ -48,12 +160,20 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
48
160
  try {
49
161
  if (mode === 'create') {
50
162
  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);
163
+ for (const idx of ModelRegistryIndex.getIndices(cls)) {
164
+ if (isModelIndexedIndex(idx)) {
165
+ const { keyIndexAttribute, sortIndexAttribute } = DynamoDBUtil.indexNames(idx.name);
166
+ const computed = ModelIndexedComputedIndex.get(idx, item).validate({ sort: true });
167
+ switch (idx.type) {
168
+ case 'indexed:keyed': indices[keyIndexAttribute] = getKey(computed); break;
169
+ case 'indexed:sorted': {
170
+ indices[keyIndexAttribute] = getKey(computed);
171
+ indices[sortIndexAttribute] = getSort(computed);
172
+ break;
173
+ }
174
+ }
175
+ } else {
176
+ console.warn('Unsupported index type on update', { cls: cls.name, idx });
57
177
  }
58
178
  }
59
179
  const query: PutItemCommandInput = {
@@ -62,7 +182,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
62
182
  Item: {
63
183
  id: DynamoDBUtil.toValue(item.id),
64
184
  body: DynamoDBUtil.toValue(JSONUtil.toUTF8(item)),
65
- ...(expiry !== undefined ? { [EXP_ATTR]: DynamoDBUtil.toValue(expiry) } : {}),
185
+ ...(expiry !== undefined ? { [EXPIRES_ATTRIBUTE]: DynamoDBUtil.toValue(expiry) } : {}),
66
186
  ...indices
67
187
  },
68
188
  ReturnValues: 'NONE'
@@ -71,14 +191,27 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
71
191
  } else {
72
192
  const indices: Record<string, unknown> = {};
73
193
  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`);
194
+
195
+ for (const idx of ModelRegistryIndex.getIndices(cls)) {
196
+ if (isModelIndexedIndex(idx)) {
197
+ const { keyIndexAttribute, sortIndexAttribute, keyIndexName, sortIndexName } = DynamoDBUtil.indexNames(idx.name);
198
+ const computed = ModelIndexedComputedIndex.get(idx, item).validate({ sort: true });
199
+ switch (idx.type) {
200
+ case 'indexed:keyed': {
201
+ indices[`:${keyIndexName}`] = getKey(computed);
202
+ expr.push(`${keyIndexAttribute} = :${keyIndexName}`);
203
+ break;
204
+ }
205
+ case 'indexed:sorted': {
206
+ indices[`:${keyIndexName}`] = getKey(computed);
207
+ indices[`:${sortIndexName}`] = getSort(computed);
208
+ expr.push(`${keyIndexAttribute} = :${keyIndexName}`);
209
+ expr.push(`${sortIndexAttribute} = :${sortIndexName}`);
210
+ break;
211
+ }
212
+ }
213
+ } else {
214
+ console.warn('Unsupported index type on update', { cls: cls.name, idx });
82
215
  }
83
216
  }
84
217
 
@@ -88,7 +221,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
88
221
  Key: { id: { S: id } },
89
222
  UpdateExpression: `SET ${[
90
223
  'body=:body',
91
- expiry !== undefined ? `${EXP_ATTR}=:expr` : undefined,
224
+ expiry !== undefined ? `${EXPIRES_ATTRIBUTE}=:expr` : undefined,
92
225
  ...expr
93
226
  ].filter(part => !!part).join(', ')}`,
94
227
  ExpressionAttributeValues: {
@@ -168,7 +301,7 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
168
301
  if (ttlEnabled !== ttlRequired) {
169
302
  await this.client.updateTimeToLive({
170
303
  TableName: table,
171
- TimeToLiveSpecification: { AttributeName: ttlRequired ? EXP_ATTR : undefined, Enabled: ttlRequired }
304
+ TimeToLiveSpecification: { AttributeName: ttlRequired ? EXPIRES_ATTRIBUTE : undefined, Enabled: ttlRequired }
172
305
  });
173
306
  }
174
307
  }
@@ -253,32 +386,13 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
253
386
  }
254
387
  }
255
388
 
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
- }
389
+ async * list<T extends ModelType>(cls: Class<T>, options?: ModelListOptions): AsyncIterable<T[]> {
390
+ for await (const { items } of this.#scanCollection(cls, (batchSize, lastKey) => this.client.scan({
391
+ TableName: this.#resolveTable(cls),
392
+ ExclusiveStartKey: lastKey,
393
+ Limit: batchSize
394
+ }), options)) {
395
+ yield items;
282
396
  }
283
397
  }
284
398
 
@@ -288,92 +402,118 @@ export class DynamoDBModelService implements ModelCrudSupport, ModelExpirySuppor
288
402
  }
289
403
 
290
404
  // 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);
297
-
298
- if (idxConfig.type === 'sorted' && sort === undefined) {
299
- throw new IndexNotSupported(cls, idxConfig, 'Sorted indices require the sort field');
300
- }
301
-
302
- const idxName = DynamoDBUtil.simpleName(idx);
303
-
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
- };
316
-
317
- const result = await this.client.query(query);
318
-
319
- if (result.Count && result.Items && result.Items[0]) {
320
- return result.Items[0].id.S!;
321
- }
322
- throw new NotFoundError(`${cls.name} Index=${idx}`, key);
323
- }
324
-
325
- // Indexed
326
- async getByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<T> {
405
+ async getByIndex<
406
+ T extends ModelType,
407
+ K extends KeyedIndexSelection<T>,
408
+ S extends SortedIndexSelection<T>
409
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexBody<T, K, S>): Promise<T> {
327
410
  return this.get(cls, await this.#getIdByIndex(cls, idx, body));
328
411
  }
329
412
 
330
- async deleteByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<void> {
413
+ async deleteByIndex<
414
+ T extends ModelType,
415
+ K extends KeyedIndexSelection<T>,
416
+ S extends SortedIndexSelection<T>
417
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexBody<T, K, S>): Promise<void> {
331
418
  return this.delete(cls, await this.#getIdByIndex(cls, idx, body));
332
419
  }
333
420
 
334
- upsertByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: OptionalId<T>): Promise<T> {
421
+ upsertByIndex<
422
+ T extends ModelType,
423
+ K extends KeyedIndexSelection<T>,
424
+ S extends SortedIndexSelection<T>
425
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: OptionalId<T>): Promise<T> {
335
426
  return ModelIndexedUtil.naiveUpsert(this, cls, idx, body);
336
427
  }
337
428
 
338
- async * listByIndex<T extends ModelType>(cls: Class<T>, idx: string, body?: DeepPartial<T>): AsyncIterable<T> {
339
- ModelCrudUtil.ensureNotSubType(cls);
429
+ async updateByIndex<
430
+ T extends ModelType,
431
+ K extends KeyedIndexSelection<T>,
432
+ S extends SortedIndexSelection<T>
433
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: T): Promise<T> {
434
+ return ModelIndexedUtil.naiveUpdate(this, cls, idx, body);
435
+ }
340
436
 
341
- const config = ModelRegistryIndex.getIndex(cls, idx, ['sorted', 'unsorted']);
342
- const { key } = ModelIndexedUtil.computeIndexKey(cls, config, body, { emptySortValue: null });
437
+ async updatePartialByIndex<
438
+ T extends ModelType,
439
+ K extends KeyedIndexSelection<T>,
440
+ S extends SortedIndexSelection<T>
441
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexWithPartialBody<T, K, S>): Promise<T> {
442
+ const item = await ModelCrudUtil.naivePartialUpdate(cls, () => this.getByIndex(cls, idx, castTo(body)), castTo(body));
443
+ return this.update(cls, item);
444
+ }
343
445
 
344
- const idxName = DynamoDBUtil.simpleName(idx);
446
+ async pageByIndex<
447
+ T extends ModelType,
448
+ K extends KeyedIndexSelection<T>,
449
+ S extends SortedIndexSelection<T>
450
+ >(
451
+ cls: Class<T>,
452
+ idx: SortedIndex<T, K, S>,
453
+ body: KeyedIndexBody<T, K>,
454
+ options?: ModelPageOptions,
455
+ ): Promise<ModelPageResult<T>> {
456
+ const output: T[] = [];
457
+ const offset = options?.offset ? JSONUtil.fromBase64<Record<string, AttributeValue>>(options.offset) : undefined;
458
+ for await (const { items } of this.#scanIndex(cls, idx, body, { limit: 100, ...options, offset })) {
459
+ output.push(...items);
460
+ }
345
461
 
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
462
+ let nextOffset;
463
+ if (output.length) {
464
+ const last: T = output.at(-1)!;
465
+ const computed = ModelIndexedComputedIndex.get(idx, last).validate();
466
+ const { keyIndexAttribute, sortIndexAttribute } = DynamoDBUtil.indexNames(idx.name);
467
+ nextOffset = JSONUtil.toBase64({
468
+ [keyIndexAttribute]: getKey(computed),
469
+ [sortIndexAttribute]: getSort(computed),
470
+ id: DynamoDBUtil.toValue(last.id)
358
471
  });
472
+ }
359
473
 
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
- }
474
+ return { items: output, nextOffset };
475
+ }
371
476
 
372
- if (!batch.Count || !batch.LastEvaluatedKey) {
373
- done = true;
374
- } else {
375
- token = batch.LastEvaluatedKey;
376
- }
477
+ async * listByIndex<
478
+ T extends ModelType,
479
+ K extends KeyedIndexSelection<T>,
480
+ S extends SortedIndexSelection<T>
481
+ >(
482
+ cls: Class<T>,
483
+ idx: SortedIndex<T, K, S>,
484
+ body: KeyedIndexBody<T, K>,
485
+ options?: ModelListOptions
486
+ ): AsyncIterable<T[]> {
487
+ for await (const { items } of this.#scanIndex(cls, idx, body, options)) {
488
+ yield items;
377
489
  }
378
490
  }
491
+
492
+ async suggestByIndex<
493
+ T extends ModelType,
494
+ S extends SortedIndexSelection<T>,
495
+ K extends KeyedIndexSelection<T>,
496
+ B extends SortedIndexSelectionType<T, S> & string
497
+ >(cls: Class<T>, idx: SortedIndex<T, K, S>, body: KeyedIndexBody<T, K>, prefix: B, options?: ModelIndexedSearchOptions): Promise<T[]> {
498
+ const results: T[] = [];
499
+
500
+ const { sortIndexAttribute } = DynamoDBUtil.indexNames(idx.name);
501
+
502
+ for await (const { items } of this.#scanIndex(cls, idx, body, { limit: 10, ...options },
503
+ (query) => ({
504
+ ...query,
505
+ KeyConditionExpression: [query.KeyConditionExpression, `begins_with(${sortIndexAttribute}, :prefix)`]
506
+ .filter(Boolean)
507
+ .join(' AND '),
508
+ ExpressionAttributeValues: {
509
+ ...query.ExpressionAttributeValues,
510
+ ':prefix': { S: prefix }
511
+ }
512
+ })
513
+ )) {
514
+ results.push(...items);
515
+ }
516
+
517
+ return results;
518
+ }
379
519
  }
package/src/util.ts CHANGED
@@ -3,8 +3,10 @@ import type {
3
3
  GlobalSecondaryIndexUpdate, KeySchemaElement
4
4
  } from '@aws-sdk/client-dynamodb';
5
5
 
6
- import type { Class } from '@travetto/runtime';
6
+ import { type Class, castTo } from '@travetto/runtime';
7
7
  import { ModelCrudUtil, ModelExpiryUtil, ModelRegistryIndex, NotFoundError, type ModelType } from '@travetto/model';
8
+ import { isModelIndexedIndex, warnIfIndexedUniqueIndex, warnIfNonIndexedIndex } from '@travetto/model-indexed';
9
+ import { SchemaRegistryIndex } from '@travetto/schema';
8
10
 
9
11
  /**
10
12
  * Configuration for DynamoDB indices
@@ -19,12 +21,15 @@ type DynamoIndexConfig = {
19
21
  */
20
22
  export class DynamoDBUtil {
21
23
 
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
- }
24
+ static indexNames = (name: string): { keyIndexName: string, sortIndexName: string, keyIndexAttribute: string, sortIndexAttribute: string } => {
25
+ const base = name.toLowerCase().replace(/[^A-Za-z0-9]+/g, '_');
26
+ return {
27
+ keyIndexName: base,
28
+ sortIndexName: `${base}_sort`,
29
+ keyIndexAttribute: `${base}__`,
30
+ sortIndexAttribute: `${base}_sort__`
31
+ };
32
+ };
28
33
 
29
34
  /**
30
35
  * Converts a JavaScript value to a DynamoDB AttributeValue format
@@ -51,29 +56,50 @@ export class DynamoDBUtil {
51
56
  * Generates global secondary indices and attribute definitions based on the model's index configuration.
52
57
  */
53
58
  static computeIndexConfig<T extends ModelType>(cls: Class<T>): DynamoIndexConfig {
54
- const config = ModelRegistryIndex.getConfig(cls);
59
+ const indexes = ModelRegistryIndex.getIndices(cls);
55
60
  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' });
61
+ const toCreate: GlobalSecondaryIndex[] = [];
62
+
63
+ const filtered = indexes
64
+ .filter(idx => !warnIfIndexedUniqueIndex(this, cls, [idx]))
65
+ .filter(idx => !warnIfNonIndexedIndex(this, cls, [idx]))
66
+ .filter(isModelIndexedIndex);
67
+
68
+ for (const idx of filtered) {
69
+ const keys: KeySchemaElement[] = [];
70
+
71
+ const { keyIndexName, keyIndexAttribute, sortIndexAttribute } = this.indexNames(idx.name);
72
+
73
+ switch (idx.type) {
74
+ case 'indexed:sorted': {
75
+ const path = idx.sortTemplate[0].path;
76
+ let fieldType = cls;
77
+ for (const field of path) {
78
+ if (SchemaRegistryIndex.has(fieldType)) {
79
+ const schema = SchemaRegistryIndex.getConfig(fieldType);
80
+ if (field in schema.fields) {
81
+ fieldType = schema.fields[field].type;
82
+ }
83
+ } else {
84
+ break;
85
+ }
86
+ }
87
+
88
+ keys.push({ AttributeName: keyIndexAttribute, KeyType: 'HASH' });
89
+ keys.push({ AttributeName: sortIndexAttribute, KeyType: 'RANGE', });
90
+ attributes.push({ AttributeName: keyIndexAttribute, AttributeType: 'S' });
91
+ attributes.push({ AttributeName: sortIndexAttribute, AttributeType: castTo(fieldType) === String ? 'S' : 'N' });
92
+ break;
93
+ }
94
+ case 'indexed:keyed': {
95
+ keys.push({ AttributeName: keyIndexAttribute, KeyType: 'HASH' });
96
+ attributes.push({ AttributeName: keyIndexAttribute, AttributeType: 'S' });
97
+ break;
98
+ }
73
99
  }
74
100
 
75
- indices.push({
76
- IndexName: idxName,
101
+ toCreate.push({
102
+ IndexName: keyIndexName,
77
103
  // ProvisionedThroughput: '',
78
104
  Projection: {
79
105
  ProjectionType: 'INCLUDE',
@@ -83,7 +109,7 @@ export class DynamoDBUtil {
83
109
  });
84
110
  }
85
111
 
86
- return { indices: indices.length ? indices : undefined, attributes };
112
+ return { indices: toCreate.length ? toCreate : undefined, attributes };
87
113
  }
88
114
 
89
115
  /**