Unverified Commit 150355d2 authored by Hong Minhee's avatar Hong Minhee
Browse files

Add optional list() method to KvStore interface



Add an optional list() method to the KvStore interface for enumerating
entries by key prefix.  This enables efficient prefix scanning which is
useful for implementing features like distributed trace storage, cache
invalidation by prefix, and listing related entries.

- Add KvStoreListOptions interface
- Add KvStoreListEntry interface
- Add list() method to KvStore interface
- Implement list() in MemoryKvStore

Resolves #498

Co-Authored-By: default avatarClaude Opus 4.5 <noreply@anthropic.com>
parent 037e57bb
Loading
Loading
Loading
Loading
+11 −0
Original line number Diff line number Diff line
@@ -38,6 +38,17 @@ To be released.

[#323]: https://github.com/fedify-dev/fedify/issues/323

 -  Added optional `list()` method to the `KvStore` interface for enumerating
    entries by key prefix.  This enables efficient prefix scanning which is
    useful for implementing features like distributed trace storage, cache
    invalidation by prefix, and listing related entries.  [[#498]]

     -  Added `KvStoreListOptions` interface.
     -  Added `KvStoreListEntry` interface.
     -  Implemented in `MemoryKvStore`.

[#498]: https://github.com/fedify-dev/fedify/issues/498

### @fedify/nestjs

 -  Allowed Express 5 in the `express` peer dependency range to support NestJS 11.
+54 −0
Original line number Diff line number Diff line
import { assertEquals } from "@std/assert";
import { test } from "../testing/mod.ts";
import type { KvKey } from "./kv.ts";
import { MemoryKvStore } from "./kv.ts";

test("MemoryKvStore", async (t) => {
@@ -34,4 +35,57 @@ test("MemoryKvStore", async (t) => {
    assertEquals(await store.cas(["foo", "bar"], undefined, "baz"), true);
    assertEquals(await store.get(["foo", "bar"]), "baz");
  });

  await t.step("list()", async () => {
    // Setup
    await store.set(["prefix", "a"], "value-a");
    await store.set(["prefix", "b"], "value-b");
    await store.set(["prefix", "nested", "c"], "value-c");
    await store.set(["other", "x"], "value-x");
    await store.set(["prefix"], "exact-match");

    // Test: list with prefix
    const entries: { key: KvKey; value: unknown }[] = [];
    for await (const entry of store.list!({ prefix: ["prefix"] })) {
      entries.push(entry);
    }
    assertEquals(entries.length, 4); // prefix, prefix/a, prefix/b, prefix/nested/c

    // Test: verify a value
    const entryA = entries.find((e) => e.key.length === 2 && e.key[1] === "a");
    assertEquals(entryA?.value, "value-a");

    // Test: non-matching prefix returns empty
    const noMatch: { key: KvKey; value: unknown }[] = [];
    for await (const entry of store.list!({ prefix: ["nonexistent"] })) {
      noMatch.push(entry);
    }
    assertEquals(noMatch.length, 0);

    // Cleanup
    await store.delete(["prefix", "a"]);
    await store.delete(["prefix", "b"]);
    await store.delete(["prefix", "nested", "c"]);
    await store.delete(["other", "x"]);
    await store.delete(["prefix"]);
  });

  await t.step("list() filters expired entries", async () => {
    await store.set(["expired", "old"], "old-value", {
      ttl: Temporal.Duration.from({ milliseconds: 1 }),
    });
    await store.set(["expired", "valid"], "valid-value");

    await new Promise((r) => setTimeout(r, 10));

    const entries: { key: KvKey; value: unknown }[] = [];
    for await (const entry of store.list!({ prefix: ["expired"] })) {
      entries.push(entry);
    }

    assertEquals(entries.length, 1);
    assertEquals(entries[0].value, "valid-value");

    await store.delete(["expired", "valid"]);
  });
});
+66 −0
Original line number Diff line number Diff line
@@ -19,6 +19,36 @@ export interface KvStoreSetOptions {
  ttl?: Temporal.Duration;
}

/**
 * Options for listing entries in a key–value store.
 *
 * @since 1.10.0
 */
export interface KvStoreListOptions {
  /**
   * The prefix to filter keys by.  Only keys that start with this prefix
   * will be returned.
   */
  prefix: KvKey;
}

/**
 * An entry returned by the {@link KvStore.list} method.
 *
 * @since 1.10.0
 */
export interface KvStoreListEntry {
  /**
   * The key of the entry.
   */
  key: KvKey;

  /**
   * The value of the entry.
   */
  value: unknown;
}

/**
 * An abstract interface for a key–value store.
 *
@@ -62,6 +92,14 @@ export interface KvStore {
    newValue: unknown,
    options?: KvStoreSetOptions,
  ) => Promise<boolean>;

  /**
   * Lists all entries in the store that match the given prefix.
   * @param options The options for listing entries.
   * @returns An async iterable of entries matching the prefix.
   * @since 1.10.0
   */
  list?: (options: KvStoreListOptions) => AsyncIterable<KvStoreListEntry>;
}

/**
@@ -148,4 +186,32 @@ export class MemoryKvStore implements KvStore {
    this.#values[encodedKey] = [newValue, expiration];
    return Promise.resolve(true);
  }

  /**
   * {@inheritDoc KvStore.list}
   */
  async *list(options: KvStoreListOptions): AsyncIterable<KvStoreListEntry> {
    const prefix = options.prefix;
    const now = Temporal.Now.instant();
    for (const [encodedKey, entry] of Object.entries(this.#values)) {
      const key = JSON.parse(encodedKey) as KvKey;
      // Check prefix match
      if (key.length < prefix.length) continue;
      let matches = true;
      for (let i = 0; i < prefix.length; i++) {
        if (key[i] !== prefix[i]) {
          matches = false;
          break;
        }
      }
      if (!matches) continue;

      const [value, expiration] = entry;
      if (expiration != null && now.until(expiration).sign < 0) {
        delete this.#values[encodedKey];
        continue;
      }
      yield { key, value };
    }
  }
}