@travetto/model-redis 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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/package.json +6 -5
  3. package/src/service.ts +228 -112
package/README.md CHANGED
@@ -18,7 +18,7 @@ This module provides an [redis](https://redis.io)-based implementation for the [
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-redis",
3
- "version": "8.0.0-alpha.2",
3
+ "version": "8.0.0-alpha.21",
4
4
  "type": "module",
5
5
  "description": "Redis backing for the travetto model module.",
6
6
  "keywords": [
@@ -26,12 +26,13 @@
26
26
  "directory": "module/model-redis"
27
27
  },
28
28
  "dependencies": {
29
- "@redis/client": "^5.11.0",
30
- "@travetto/config": "^8.0.0-alpha.2",
31
- "@travetto/model": "^8.0.0-alpha.2"
29
+ "@redis/client": "^6.0.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,29 @@
1
1
  import { createClient } from '@redis/client';
2
2
 
3
- import { JSONUtil, ShutdownManager, type Class, type DeepPartial } from '@travetto/runtime';
3
+ import { castTo, JSONUtil, ShutdownManager, type Class } from '@travetto/runtime';
4
4
  import {
5
- type ModelCrudSupport, type ModelExpirySupport, ModelRegistryIndex, type ModelType, type ModelStorageSupport,
6
- NotFoundError, ExistsError, type ModelIndexedSupport, type OptionalId,
7
- ModelCrudUtil, ModelExpiryUtil, ModelIndexedUtil, ModelStorageUtil,
5
+ type ModelCrudSupport, type ModelExpirySupport, ModelRegistryIndex, type ModelType, type ModelStorageSupport, NotFoundError,
6
+ ExistsError, type OptionalId, ModelCrudUtil, ModelExpiryUtil, ModelStorageUtil,
7
+ type ModelListOptions,
8
8
  } from '@travetto/model';
9
+ import {
10
+ type ModelIndexedSupport, type KeyedIndexSelection, type KeyedIndexBody, type ModelPageOptions, ModelIndexedUtil,
11
+ type SingleItemIndex, type SortedIndexSelection, type ModelPageResult, type SortedIndex, isModelIndexedIndex,
12
+ type FullKeyedIndexWithPartialBody, type FullKeyedIndexBody, ModelIndexedComputedIndex,
13
+ warnIfIndexedUniqueIndex, warnIfNonIndexedIndex, type ModelIndexedSearchOptions, type SortedIndexSelectionType,
14
+ } from '@travetto/model-indexed';
15
+
9
16
  import { Injectable, PostConstruct } from '@travetto/di';
10
17
 
11
18
  import type { RedisModelConfig } from './config.ts';
12
19
 
13
- type RedisScan = { key: string } | { match: string };
20
+ const TERMINATOR = '\xff';
21
+
22
+ type RedisScan = ({ key: string } | { match: string } | { prefix: string }) & { reverse?: boolean };
14
23
  type RedisClient = ReturnType<typeof createClient>;
15
24
  type RedisMulti = ReturnType<RedisClient['multi']>;
25
+ type ScanState = { cursor?: string, ids: string[] };
26
+ type ScanOp = 'scan' | 'sScan' | 'zRange';
16
27
 
17
28
  /**
18
29
  * A model service backed by redis
@@ -40,55 +51,114 @@ export class RedisModelService implements ModelCrudSupport, ModelExpirySupport,
40
51
  return key;
41
52
  }
42
53
 
43
- async * #streamValues(operation: 'scan' | 'sScan' | 'zScan', search: RedisScan, count = 100): AsyncIterable<string[]> {
44
- let previousCursor = '0';
45
- let done = false;
46
-
47
- const flags = { COUNT: count, ...('match' in search ? { MATCH: search.match } : {}) };
54
+ async #scan(operation: ScanOp, cursor: string, search: RedisScan, count = 100): Promise<ScanState> {
48
55
  const key = 'key' in search ? search.key : '';
56
+ const flags = { COUNT: count, ...('match' in search ? { MATCH: search.match } : {}) };
49
57
 
50
- while (!done) {
51
- const [cursor, results] = await (
52
- operation === 'scan' ?
53
- this.client.scan(previousCursor, flags).then(result => [result.cursor, result.keys] as const) :
54
- operation === 'sScan' ?
55
- this.client.sScan(key, previousCursor, flags).then(result => [result.cursor, result.members] as const) :
56
- this.client.zScan(key, previousCursor, flags).then(result => [result.cursor, result.members.map(item => item.value)] as const)
57
- );
58
+ let output: ScanState;
59
+ switch (operation) {
60
+ case 'scan': output = await this.client.scan(cursor ?? '0', flags).then(result => ({ cursor: result.cursor, ids: result.keys })); break;
61
+ case 'sScan': output = await this.client.sScan(key!, cursor ?? '0', flags).then(result => ({ cursor: result.cursor, ids: result.members })); break;
62
+ case 'zRange': {
63
+ const offset = cursor ? +cursor : 0;
64
+ const prefix = 'prefix' in search ? search.prefix : undefined;
58
65
 
59
- previousCursor = cursor;
66
+ let bounds: [number | string, number | string];
60
67
 
61
- yield results;
68
+ if (prefix !== undefined) {
69
+ bounds = [prefix ? `[${prefix}` : '-', `(${prefix}${TERMINATOR}`];
70
+ } else {
71
+ bounds = [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY];
72
+ }
73
+ if (search.reverse) {
74
+ bounds = [bounds[1], bounds[0]];
75
+ }
62
76
 
63
- if (cursor === '0') {
64
- done = true;
77
+ const result = await this.client.zRange(key!, ...bounds, { BY: prefix ? 'LEX' : 'SCORE', REV: search.reverse, LIMIT: { offset, count } });
78
+ output = { cursor: result.length ? (offset + result.length).toString() : undefined, ids: result };
79
+ break;
65
80
  }
66
81
  }
82
+ return { ...output, cursor: output.cursor === '0' ? undefined : output.cursor };
83
+ }
84
+
85
+ async * #streamValues(
86
+ operation: ScanOp,
87
+ search: RedisScan,
88
+ options?: ModelPageOptions & ModelListOptions
89
+ ): AsyncIterable<ScanState> {
90
+ const limit = options?.limit ?? Number.MAX_SAFE_INTEGER;
91
+ let matched: ScanState = { cursor: options?.offset, ids: [] };
92
+ let produced = 0;
93
+ const batchSize = options?.batchSizeHint ?? 100;
94
+
95
+ do {
96
+ const remaining = limit - produced;
97
+ matched = await this.#scan(operation, matched.cursor!, search, Math.min(remaining, batchSize));
98
+ if (matched.ids.length) {
99
+ yield matched;
100
+ produced += matched.ids.length;
101
+ }
102
+ } while (matched.cursor && produced < limit && !(options?.abort?.aborted));
67
103
  }
68
104
 
69
- #iterate(prefix: Class | string): AsyncIterable<string[]> {
70
- return this.#streamValues('scan', { match: `${this.#resolveKey(prefix)}*` });
105
+ #scanIndex<T extends ModelType>(
106
+ cls: Class<T>,
107
+ idx: SortedIndex<T>,
108
+ body: KeyedIndexBody<T>,
109
+ options?: ModelPageOptions & { prefix?: string }
110
+ ): AsyncIterable<ScanState> {
111
+ ModelCrudUtil.ensureNotSubType(cls);
112
+ const computed = ModelIndexedComputedIndex.get(idx, body).validate();
113
+ const fullKey = this.#resolveKey(cls, idx.name, computed.getKey());
114
+ switch (idx.type) {
115
+ // case 'indexed:keyed': return this.#streamValues('sScan', { key: fullKey }, options);
116
+ case 'indexed:sorted': {
117
+ return this.#streamValues('zRange', { key: fullKey, prefix: options?.prefix }, options);
118
+ }
119
+ }
120
+ }
121
+
122
+ async #getBodies<T extends ModelType>(cls: Class<T>, ids: string[], transform: (id: string) => string): Promise<T[]> {
123
+ if (ids.length === 0) {
124
+ return [];
125
+ }
126
+ const bodies = (await this.client.mGet(ids.map(transform)))
127
+ .filter((result): result is string => !!result);
128
+ return ModelCrudUtil.filterOutNotFound(bodies.map(body => ModelCrudUtil.load(cls, body)));
71
129
  }
72
130
 
73
131
  #removeIndices<T extends ModelType>(cls: Class, item: T, multi: RedisMulti): void {
74
- for (const idx of ModelRegistryIndex.getIndices(cls, ['sorted', 'unsorted'])) {
75
- const { key } = ModelIndexedUtil.computeIndexKey(cls, idx, item);
76
- const fullKey = this.#resolveKey(cls, idx.name, key);
77
- switch (idx.type) {
78
- case 'unsorted': multi.sRem(fullKey, item.id); break;
79
- case 'sorted': multi.zRem(fullKey, item.id); break;
132
+ for (const idx of ModelRegistryIndex.getIndices(cls)) {
133
+ if (isModelIndexedIndex(idx)) {
134
+ const computed = ModelIndexedComputedIndex.get(idx, item).validate({ sort: true });
135
+ const resolvedKey = this.#resolveKey(cls, idx.name, computed.getKey());
136
+ switch (idx.type) {
137
+ case 'indexed:keyed': multi.sRem(resolvedKey, item.id); break;
138
+ case 'indexed:sorted': multi.zRem(resolvedKey, item.id); break;
139
+ }
80
140
  }
81
141
  }
82
142
  }
83
143
 
84
144
  #addIndices<T extends ModelType>(cls: Class, item: T, multi: RedisMulti): void {
85
- for (const idx of ModelRegistryIndex.getIndices(cls, ['sorted', 'unsorted'])) {
86
- const { key, sort } = ModelIndexedUtil.computeIndexKey(cls, idx, item);
87
- const fullKey = this.#resolveKey(cls, idx.name, key);
145
+ for (const idx of ModelRegistryIndex.getIndices(cls)) {
146
+ if (isModelIndexedIndex(idx)) {
147
+ const computed = ModelIndexedComputedIndex.get(idx, item).validate({ sort: true });
148
+ const resolvedKey = this.#resolveKey(cls, idx.name, computed.getKey());
88
149
 
89
- switch (idx.type) {
90
- case 'unsorted': multi.sAdd(fullKey, item.id); break;
91
- case 'sorted': multi.zAdd(fullKey, { score: +sort!, value: item.id }); break;
150
+ switch (idx.type) {
151
+ case 'indexed:keyed': multi.sAdd(resolvedKey, item.id); break;
152
+ case 'indexed:sorted': {
153
+ const value = computed.getSort();
154
+ if (typeof value === 'string') {
155
+ multi.zAdd(resolvedKey, { score: 0, value: `${value}${TERMINATOR}${item.id}` });
156
+ } else {
157
+ multi.zAdd(resolvedKey, { score: computed.getSort(), value: item.id });
158
+ }
159
+ break;
160
+ }
161
+ }
92
162
  }
93
163
  }
94
164
  }
@@ -96,10 +166,11 @@ export class RedisModelService implements ModelCrudSupport, ModelExpirySupport,
96
166
  async #store<T extends ModelType>(cls: Class<T>, item: T, action: 'write' | 'delete'): Promise<void> {
97
167
  const key = this.#resolveKey(cls, item.id);
98
168
  const config = ModelRegistryIndex.getConfig(cls);
169
+ const indices = ModelRegistryIndex.getIndices(cls);
99
170
  const existing = await this.get(cls, item.id).catch(() => undefined);
100
171
 
101
172
  // Store with indices
102
- if (config.indices?.length) {
173
+ if (indices.length) {
103
174
  const multi = this.client.multi();
104
175
  if (existing) {
105
176
  this.#removeIndices(cls, existing, multi);
@@ -146,25 +217,47 @@ export class RedisModelService implements ModelCrudSupport, ModelExpirySupport,
146
217
  }
147
218
  }
148
219
 
149
- async #getIdByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<string> {
220
+ async #getIdByIndex<
221
+ T extends ModelType,
222
+ K extends KeyedIndexSelection<T>,
223
+ S extends SortedIndexSelection<T>
224
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexBody<T, K, S>): Promise<string> {
150
225
  ModelCrudUtil.ensureNotSubType(cls);
151
226
 
152
- const idxConfig = ModelRegistryIndex.getIndex(cls, idx, ['sorted', 'unsorted']);
153
- const { key, sort } = ModelIndexedUtil.computeIndexKey(cls, idxConfig, body);
154
- const fullKey = this.#resolveKey(cls, idxConfig.name, key);
227
+ const computed = ModelIndexedComputedIndex.get(idx, body).validate({ sort: true });
228
+ const resolvedKey = this.#resolveKey(cls, idx.name, computed.getKey());
155
229
  let id: string | undefined;
156
- if (idxConfig.type === 'unsorted') {
157
- id = (await this.client.sRandMember(fullKey))!;
158
- } else {
159
- const result = await this.client.zRangeByScore(
160
- fullKey, +sort!, +sort!
161
- );
162
- id = result[0];
230
+ switch (idx.type) {
231
+ case 'indexed:keyed': {
232
+ if (computed.idPart) {
233
+ const all = await this.client.sMembers(resolvedKey);
234
+ if (!all.find(item => item === computed.idPart!.value)) {
235
+ throw new NotFoundError(`${cls.name} Index=${idx}`, computed.getKey());
236
+ }
237
+ id = computed.idPart!.value;
238
+ } else {
239
+ id = await this.client.sRandMember(resolvedKey) ?? undefined;
240
+ }
241
+ break;
242
+ }
243
+ case 'indexed:sorted': {
244
+ const sort = computed.getSort();
245
+ const result = await this.client.zRangeByScore(resolvedKey, sort, sort);
246
+ if (computed.idPart) {
247
+ if (!result.find(item => item === computed.idPart!.value)) {
248
+ throw new NotFoundError(`${cls.name} Index=${idx}`, computed.getKey());
249
+ }
250
+ id = computed.idPart!.value;
251
+ } else {
252
+ id = result[0];
253
+ }
254
+ break;
255
+ }
163
256
  }
164
257
  if (id) {
165
258
  return id;
166
259
  }
167
- throw new NotFoundError(`${cls.name}: ${idx}`, key);
260
+ throw new NotFoundError(`${cls.name}: ${idx}`, computed.getKey({ sort: true }));
168
261
  }
169
262
 
170
263
  @PostConstruct()
@@ -174,14 +267,8 @@ export class RedisModelService implements ModelCrudSupport, ModelExpirySupport,
174
267
  await ModelStorageUtil.storageInitialization(this);
175
268
  ShutdownManager.signal.addEventListener('abort', () => this.client.close());
176
269
  for (const cls of ModelRegistryIndex.getClasses()) {
177
- for (const idx of ModelRegistryIndex.getConfig(cls).indices ?? []) {
178
- switch (idx.type) {
179
- case 'unique': {
180
- console.error('Unique indices are not supported in redis for', { cls: cls.Ⲑid, idx: idx.name });
181
- break;
182
- }
183
- }
184
- }
270
+ warnIfIndexedUniqueIndex(this, cls, ModelRegistryIndex.getIndices(cls));
271
+ warnIfNonIndexedIndex(this, cls, ModelRegistryIndex.getIndices(cls));
185
272
  }
186
273
  }
187
274
 
@@ -241,25 +328,9 @@ export class RedisModelService implements ModelCrudSupport, ModelExpirySupport,
241
328
  await this.#store(cls, where, 'delete');
242
329
  }
243
330
 
244
- async * list<T extends ModelType>(cls: Class<T>): AsyncIterable<T> {
245
- for await (const ids of this.#iterate(cls)) {
246
-
247
- if (!ids.length) {
248
- return;
249
- }
250
-
251
- const bodies = (await this.client.mGet(ids))
252
- .filter((result): result is string => !!result);
253
-
254
- for (const body of bodies) {
255
- try {
256
- yield await ModelCrudUtil.load(cls, body);
257
- } catch (error) {
258
- if (!(error instanceof NotFoundError)) {
259
- throw error;
260
- }
261
- }
262
- }
331
+ async * list<T extends ModelType>(cls: Class<T>, options?: ModelListOptions): AsyncIterable<T[]> {
332
+ for await (const { ids } of this.#streamValues('scan', { match: `${this.#resolveKey(cls)}:*` }, options)) {
333
+ yield await this.#getBodies(cls, ids, id => id);
263
334
  }
264
335
  }
265
336
 
@@ -271,14 +342,17 @@ export class RedisModelService implements ModelCrudSupport, ModelExpirySupport,
271
342
 
272
343
  // Storage
273
344
  async createStorage(): Promise<void> {
274
- // Do nothing
345
+ for (const cls of ModelRegistryIndex.getClasses()) {
346
+ warnIfIndexedUniqueIndex(this, cls, ModelRegistryIndex.getIndices(cls));
347
+ warnIfNonIndexedIndex(this, cls, ModelRegistryIndex.getIndices(cls));
348
+ }
275
349
  }
276
350
 
277
351
  async deleteStorage(): Promise<void> {
278
352
  if (!this.config.namespace) {
279
353
  await this.client.flushDb();
280
354
  } else {
281
- for await (const ids of this.#iterate('')) {
355
+ for await (const { ids } of this.#streamValues('scan', { match: `${this.#resolveKey('')}*` }, { limit: Number.MAX_SAFE_INTEGER })) {
282
356
  if (ids.length) {
283
357
  await this.client.del(ids);
284
358
  }
@@ -287,7 +361,7 @@ export class RedisModelService implements ModelCrudSupport, ModelExpirySupport,
287
361
  }
288
362
 
289
363
  async truncateModel<T extends ModelType>(model: Class<T>): Promise<void> {
290
- for await (const ids of this.#iterate(model)) {
364
+ for await (const { ids } of this.#streamValues('scan', { match: `${this.#resolveKey(model)}:*` }, { limit: Number.MAX_SAFE_INTEGER })) {
291
365
  if (ids.length) {
292
366
  await this.client.del(ids);
293
367
  }
@@ -295,53 +369,95 @@ export class RedisModelService implements ModelCrudSupport, ModelExpirySupport,
295
369
  }
296
370
 
297
371
  // Indexed
298
- async getByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<T> {
372
+
373
+ async getByIndex<
374
+ T extends ModelType,
375
+ K extends KeyedIndexSelection<T>,
376
+ S extends SortedIndexSelection<T>
377
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexBody<T, K, S>): Promise<T> {
299
378
  return this.get(cls, await this.#getIdByIndex(cls, idx, body));
300
379
  }
301
380
 
302
- async deleteByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: DeepPartial<T>): Promise<void> {
381
+ async deleteByIndex<
382
+ T extends ModelType,
383
+ K extends KeyedIndexSelection<T>,
384
+ S extends SortedIndexSelection<T>
385
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexBody<T, K, S>): Promise<void> {
303
386
  return this.delete(cls, await this.#getIdByIndex(cls, idx, body));
304
387
  }
305
388
 
306
- upsertByIndex<T extends ModelType>(cls: Class<T>, idx: string, body: OptionalId<T>): Promise<T> {
389
+ upsertByIndex<
390
+ T extends ModelType,
391
+ K extends KeyedIndexSelection<T>,
392
+ S extends SortedIndexSelection<T>
393
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: OptionalId<T>): Promise<T> {
307
394
  return ModelIndexedUtil.naiveUpsert(this, cls, idx, body);
308
395
  }
309
396
 
310
- async * listByIndex<T extends ModelType>(cls: Class<T>, idx: string, body?: DeepPartial<T>): AsyncIterable<T> {
311
- ModelCrudUtil.ensureNotSubType(cls);
312
-
313
- const idxConfig = ModelRegistryIndex.getIndex(cls, idx, ['sorted', 'unsorted']);
314
-
315
- let stream: AsyncIterable<string[]>;
397
+ updateByIndex<
398
+ T extends ModelType,
399
+ K extends KeyedIndexSelection<T>,
400
+ S extends SortedIndexSelection<T>
401
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: T): Promise<T> {
402
+ return ModelIndexedUtil.naiveUpdate(this, cls, idx, body);
403
+ }
316
404
 
317
- const { key } = ModelIndexedUtil.computeIndexKey(cls, idxConfig, body, { emptySortValue: null });
318
- const fullKey = this.#resolveKey(cls, idx, key);
405
+ async updatePartialByIndex<
406
+ T extends ModelType,
407
+ K extends KeyedIndexSelection<T>,
408
+ S extends SortedIndexSelection<T>
409
+ >(cls: Class<T>, idx: SingleItemIndex<T, K, S>, body: FullKeyedIndexWithPartialBody<T, K, S>): Promise<T> {
410
+ const item = await ModelCrudUtil.naivePartialUpdate(cls, () => this.getByIndex(cls, idx, castTo(body)), castTo(body));
411
+ return this.update(cls, item);
412
+ }
319
413
 
320
- if (idxConfig.type === 'unsorted') {
321
- stream = this.#streamValues('sScan', { key: fullKey });
322
- } else {
323
- stream = this.#streamValues('zScan', { key: fullKey });
414
+ async pageByIndex<
415
+ T extends ModelType,
416
+ K extends KeyedIndexSelection<T>,
417
+ S extends SortedIndexSelection<T>
418
+ >(
419
+ cls: Class<T>,
420
+ idx: SortedIndex<T, K, S>,
421
+ body: KeyedIndexBody<T, K>,
422
+ options?: ModelPageOptions
423
+ ): Promise<ModelPageResult<T>> {
424
+ const items: T[] = [];
425
+ let lastCursor: string | undefined;
426
+ for await (const { ids, cursor } of this.#scanIndex(cls, idx, body, { limit: 100, ...options })) {
427
+ items.push(...await this.#getBodies(cls, ids, id => this.#resolveKey(cls, id)));
428
+ lastCursor = cursor;
324
429
  }
430
+ return { items, nextOffset: lastCursor };
431
+ }
325
432
 
326
- for await (const ids of stream) {
327
- if (!ids.length) {
328
- return;
329
- }
433
+ async * listByIndex<
434
+ T extends ModelType,
435
+ K extends KeyedIndexSelection<T>,
436
+ S extends SortedIndexSelection<T>
437
+ >(
438
+ cls: Class<T>,
439
+ idx: SortedIndex<T, K, S>,
440
+ body: KeyedIndexBody<T, K>,
441
+ options?: ModelListOptions
442
+ ): AsyncIterable<T[]> {
443
+ for await (const { ids } of this.#scanIndex(cls, idx, body, options)) {
444
+ yield await this.#getBodies(cls, ids, id => this.#resolveKey(cls, id));
445
+ }
446
+ }
330
447
 
331
- const bodies = (await this.client.mGet(
332
- ids.map(id => this.#resolveKey(cls, id))
333
- ))
334
- .filter((result): result is string => !!result);
335
-
336
- for (const full of bodies) {
337
- try {
338
- yield await ModelCrudUtil.load(cls, full);
339
- } catch (error) {
340
- if (!(error instanceof NotFoundError)) {
341
- throw error;
342
- }
343
- }
344
- }
448
+ async suggestByIndex<
449
+ T extends ModelType,
450
+ S extends SortedIndexSelection<T>,
451
+ K extends KeyedIndexSelection<T>,
452
+ B extends SortedIndexSelectionType<T, S> & string
453
+ >(cls: Class<T>, idx: SortedIndex<T, K, S>, body: KeyedIndexBody<T, K>, prefix: B, options?: ModelIndexedSearchOptions): Promise<T[]> {
454
+
455
+ const items: T[] = [];
456
+ for await (const { ids } of this.#scanIndex(cls, idx, body, { limit: 10, ...options, prefix })) {
457
+ const cleaned = ids.map(id => id.split(TERMINATOR).at(-1)!);
458
+ items.push(...await this.#getBodies(cls, cleaned, id => this.#resolveKey(cls, id)));
345
459
  }
460
+
461
+ return items;
346
462
  }
347
463
  }