Unverified Commit 0e2f6faf authored by Hong Minhee's avatar Hong Minhee
Browse files

Implement list() method in PostgresKvStore

parent d302164b
Loading
Loading
Loading
Loading
+6 −4
Original line number Diff line number Diff line
@@ -36,8 +36,6 @@ To be released.
        key ownership verification, recording actor ID, key ID, verification
        result, and the verification method used.

[#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
@@ -47,6 +45,7 @@ To be released.
     -  Added `KvStoreListEntry` interface.
     -  Implemented in `MemoryKvStore`.

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

### @fedify/nestjs
@@ -54,13 +53,16 @@ To be released.
 -  Allowed Express 5 in the `express` peer dependency range to support NestJS 11.
    [[#492], [#493] by Cho Hasang]

[#492]: https://github.com/fedify-dev/fedify/issues/492
[#493]: https://github.com/fedify-dev/fedify/pull/493

### @fedify/sqlite

 -  Implemented `list()` method in `SqliteKvStore`.  [[#498]]

### @fedify/postgres

[#492]: https://github.com/fedify-dev/fedify/issues/492
[#493]: https://github.com/fedify-dev/fedify/pull/493
 -  Implemented `list()` method in `PostgresKvStore`.  [[#498]]


Version 1.9.2
+82 −0
Original line number Diff line number Diff line
@@ -139,4 +139,86 @@ test("PostgresKvStore.drop()", { skip: dbUrl == null }, async () => {
  }
});

test("PostgresKvStore.list()", { skip: dbUrl == null }, async () => {
  if (dbUrl == null) return; // Bun does not support skip option
  const { sql, store } = getStore();
  try {
    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");

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

    assert.strictEqual(entries.length, 3);
    assert(entries.some((e) => e.key[1] === "a" && e.value === "value-a"));
    assert(entries.some((e) => e.key[1] === "b"));
    assert(entries.some((e) => e.key[1] === "nested"));
  } finally {
    await store.drop();
    await sql.end();
  }
});

test(
  "PostgresKvStore.list() - excludes expired",
  { skip: dbUrl == null },
  async () => {
    if (dbUrl == null) return; // Bun does not support skip option
    const { sql, tableName, store } = getStore();
    try {
      await store.initialize();

      // Insert expired entry directly
      await sql`
      INSERT INTO ${sql(tableName)} (key, value, created, ttl)
      VALUES (
        ${["list-test", "expired"]},
        ${"expired-value"},
        CURRENT_TIMESTAMP - INTERVAL '1 hour',
        ${"30 minutes"}
      )
    `;
      await store.set(["list-test", "valid"], "valid-value");

      const entries: { key: readonly string[]; value: unknown }[] = [];
      for await (const entry of store.list!({ prefix: ["list-test"] })) {
        entries.push({ key: entry.key, value: entry.value });
      }

      assert.strictEqual(entries.length, 1);
      assert.deepStrictEqual(entries[0].key, ["list-test", "valid"]);
    } finally {
      await store.drop();
      await sql.end();
    }
  },
);

test(
  "PostgresKvStore.list() - single element key",
  { skip: dbUrl == null },
  async () => {
    if (dbUrl == null) return; // Bun does not support skip option
    const { sql, store } = getStore();
    try {
      await store.set(["a"], "value-a");
      await store.set(["b"], "value-b");

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

      assert.strictEqual(entries.length, 1);
    } finally {
      await store.drop();
      await sql.end();
    }
  },
);

// cSpell: ignore regclass
+36 −1
Original line number Diff line number Diff line
import type { KvKey, KvStore, KvStoreSetOptions } from "@fedify/fedify";
import type {
  KvKey,
  KvStore,
  KvStoreListEntry,
  KvStoreListOptions,
  KvStoreSetOptions,
} from "@fedify/fedify";
import { getLogger } from "@logtape/logtape";
import type { JSONValue, Parameter, Sql } from "postgres";
import { driverSerializesJson } from "./utils.ts";
@@ -107,6 +113,35 @@ export class PostgresKvStore implements KvStore {
    await this.#expire();
  }

  /**
   * {@inheritDoc KvStore.list}
   * @since 1.10.0
   */
  async *list(
    options: KvStoreListOptions,
  ): AsyncIterable<KvStoreListEntry> {
    await this.initialize();

    const prefix = options.prefix;
    const prefixLength = prefix.length;

    const results = await this.#sql`
      SELECT key, value
      FROM ${this.#sql(this.#tableName)}
      WHERE array_length(key, 1) >= ${prefixLength}
        AND key[1:${prefixLength}] = ${prefix}::text[]
        AND (ttl IS NULL OR created + ttl > CURRENT_TIMESTAMP)
      ORDER BY key
    `;

    for (const row of results) {
      yield {
        key: row.key as KvKey,
        value: row.value,
      };
    }
  }

  /**
   * Creates the table used by the key–value store if it does not already exist.
   * Does nothing if the table already exists.