Skip to content

Commit 5c4726d

Browse files
authored
feat: support KVS.listKeys() prefix and collection parameters (#3001)
Adds `prefix` and `collection` parameters to the `KVS.forEachKey()` interface and implements the `prefix` filter in the `MemoryStorage` implementation of KVS. Note that `MemoryStorage` doesn't currently support the `collection` parameter (it's noop). Closes #2974
1 parent f357979 commit 5c4726d

File tree

4 files changed

+68
-15
lines changed

4 files changed

+68
-15
lines changed

packages/core/src/storages/key_value_store.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -460,22 +460,24 @@ export class KeyValueStore {
460460
options: KeyValueStoreIteratorOptions = {},
461461
index = 0,
462462
): Promise<void> {
463-
const { exclusiveStartKey } = options;
463+
const { exclusiveStartKey, prefix, collection } = options;
464464
ow(iteratee, ow.function);
465465
ow(
466466
options,
467467
ow.object.exactShape({
468468
exclusiveStartKey: ow.optional.string,
469+
prefix: ow.optional.string,
470+
collection: ow.optional.string,
469471
}),
470472
);
471473

472-
const response = await this.client.listKeys({ exclusiveStartKey });
474+
const response = await this.client.listKeys({ exclusiveStartKey, prefix, collection });
473475
const { nextExclusiveStartKey, isTruncated, items } = response;
474476
for (const item of items) {
475477
await iteratee(item.key, index++, { size: item.size });
476478
}
477479
return isTruncated
478-
? this._forEachKey(iteratee, { exclusiveStartKey: nextExclusiveStartKey }, index)
480+
? this._forEachKey(iteratee, { exclusiveStartKey: nextExclusiveStartKey, prefix, collection }, index)
479481
: undefined; // [].forEach() returns undefined.
480482
}
481483

@@ -758,4 +760,12 @@ export interface KeyValueStoreIteratorOptions {
758760
* All keys up to this one (including) are skipped from the result.
759761
*/
760762
exclusiveStartKey?: string;
763+
/**
764+
* If set, only keys that start with this prefix are returned.
765+
*/
766+
prefix?: string;
767+
/**
768+
* Collection name to use for listing keys.
769+
*/
770+
collection?: string;
761771
}

packages/memory-storage/src/resource-clients/key-value-store.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,16 @@ export class KeyValueStoreClient extends BaseClient {
119119
}
120120

121121
async listKeys(options: storage.KeyValueStoreClientListOptions = {}): Promise<storage.KeyValueStoreClientListData> {
122-
const { limit = DEFAULT_API_PARAM_LIMIT, exclusiveStartKey } = s
122+
const {
123+
limit = DEFAULT_API_PARAM_LIMIT,
124+
exclusiveStartKey,
125+
prefix,
126+
} = s
123127
.object({
124128
limit: s.number.greaterThan(0).optional,
125129
exclusiveStartKey: s.string.optional,
130+
collection: s.string.optional, // This is ignored, but kept for validation consistency with API client.
131+
prefix: s.string.optional,
126132
})
127133
.parse(options);
128134

@@ -151,23 +157,25 @@ export class KeyValueStoreClient extends BaseClient {
151157
return a.key.localeCompare(b.key);
152158
});
153159

154-
let truncatedItems = items;
160+
const filteredItems = items.filter((item) => !prefix || item.key.startsWith(prefix));
161+
162+
let truncatedItems = filteredItems;
155163
if (exclusiveStartKey) {
156-
const keyPos = items.findIndex((item) => item.key === exclusiveStartKey);
157-
if (keyPos !== -1) truncatedItems = items.slice(keyPos + 1);
164+
const keyPos = filteredItems.findIndex((item) => item.key === exclusiveStartKey);
165+
if (keyPos !== -1) truncatedItems = filteredItems.slice(keyPos + 1);
158166
}
159167

160168
const limitedItems = truncatedItems.slice(0, limit);
161169

162-
const lastItemInStore = items[items.length - 1];
163-
const lastSelectedItem = limitedItems[limitedItems.length - 1];
170+
const lastItemInStore = filteredItems.at(-1);
171+
const lastSelectedItem = limitedItems.at(-1);
164172
const isLastSelectedItemAbsolutelyLast = lastItemInStore === lastSelectedItem;
165-
const nextExclusiveStartKey = isLastSelectedItemAbsolutelyLast ? undefined : lastSelectedItem.key;
173+
const nextExclusiveStartKey = isLastSelectedItemAbsolutelyLast ? undefined : lastSelectedItem?.key;
166174

167175
existingStoreById.updateTimestamps(false);
168176

169177
return {
170-
count: items.length,
178+
count: limitedItems.length,
171179
limit,
172180
exclusiveStartKey,
173181
isTruncated: !isLastSelectedItemAbsolutelyLast,

packages/types/src/storages.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ export interface KeyValueStoreClientUpdateOptions {
136136
export interface KeyValueStoreClientListOptions {
137137
limit?: number;
138138
exclusiveStartKey?: string;
139+
collection?: string;
140+
prefix?: string;
139141
}
140142

141143
export interface KeyValueStoreItemData {

test/core/storages/key_value_store.test.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,39 @@ describe('KeyValueStore', () => {
509509
});
510510

511511
describe('forEachKey', () => {
512+
test('should work with prefixes', async () => {
513+
const store = await KeyValueStore.open();
514+
515+
for (const [key, value] of Object.entries({
516+
'img-key1': 'PAYLOAD',
517+
'img-key2': 'PAYLOAD',
518+
'txt-key1': 'PAYLOAD',
519+
'txt-key2': 'PAYLOAD',
520+
})) {
521+
await store.setValue(key, value);
522+
}
523+
524+
const imgKeys: string[] = [];
525+
const txtKeys: string[] = [];
526+
527+
await store.forEachKey(
528+
(key) => {
529+
imgKeys.push(key);
530+
},
531+
{ prefix: 'img-' },
532+
);
533+
534+
await store.forEachKey(
535+
(key) => {
536+
txtKeys.push(key);
537+
},
538+
{ prefix: 'txt-' },
539+
);
540+
541+
expect(imgKeys).toEqual(['img-key1', 'img-key2']);
542+
expect(txtKeys).toEqual(['txt-key1', 'txt-key2']);
543+
});
544+
512545
test('should work remotely', async () => {
513546
const store = new KeyValueStore({
514547
id: 'my-store-id-1',
@@ -555,13 +588,13 @@ describe('KeyValueStore', () => {
555588
async (key, index, info) => {
556589
results.push([key, index, info]);
557590
},
558-
{ exclusiveStartKey: 'key0' },
591+
{ exclusiveStartKey: 'key0', prefix: 'img/' },
559592
);
560593

561594
expect(mockListKeys).toBeCalledTimes(3);
562-
expect(mockListKeys).toHaveBeenNthCalledWith(1, { exclusiveStartKey: 'key0' });
563-
expect(mockListKeys).toHaveBeenNthCalledWith(2, { exclusiveStartKey: 'key2' });
564-
expect(mockListKeys).toHaveBeenNthCalledWith(3, { exclusiveStartKey: 'key4' });
595+
expect(mockListKeys).toHaveBeenNthCalledWith(1, { exclusiveStartKey: 'key0', prefix: 'img/' });
596+
expect(mockListKeys).toHaveBeenNthCalledWith(2, { exclusiveStartKey: 'key2', prefix: 'img/' });
597+
expect(mockListKeys).toHaveBeenNthCalledWith(3, { exclusiveStartKey: 'key4', prefix: 'img/' });
565598

566599
expect(results).toHaveLength(5);
567600
results.forEach((r, i) => {

0 commit comments

Comments
 (0)