Unverified Commit 629f4eec authored by An Nyeong's avatar An Nyeong
Browse files

Refactor SqliteKvstore for multi-runtime support

Changes:
- Use virtual imports to resolve runtime dependencies.
- Integrate divided tests into single test, `kv.test.ts`, using
  `node:test`.
parent cf15b324
Loading
Loading
Loading
Loading
+3 −7
Original line number Diff line number Diff line
@@ -3,12 +3,12 @@
 *
 * An abstract interface for SQLite database for different runtime environments.
 */
export interface SQLiteDatabase {
export interface SqliteDatabaseAdapter {
  /**
   * Prepares a SQL statement.
   * @param sql - The SQL statement to prepare.
   */
  prepare(sql: string): SQLiteStatement;
  prepare(sql: string): SqliteStatementAdapter;

  /**
   * Executes a SQL statement.
@@ -22,7 +22,7 @@ export interface SQLiteDatabase {
  close(): void;
}

export interface SQLiteStatement {
export interface SqliteStatementAdapter {
  /**
   * Executes a SQL statement and returns the number of changes made to the database.
   * @param params - The parameters to bind to the SQL statement.
@@ -42,7 +42,3 @@ export interface SQLiteStatement {
   */
  all(...params: unknown[]): unknown[];
}

export function createSQLiteDatabase(db: unknown): SQLiteDatabase {
  throw new Error("Unsupported database type");
}

sqlite/bun.test.ts

deleted100644 → 0
+0 −288
Original line number Diff line number Diff line
import * as temporal from "@js-temporal/polyfill";
import { delay } from "@std/async/delay";
import { Database } from "bun:sqlite";
import { expect, test } from "bun:test";
import { SqliteKvStore } from "./bun.ts";

let Temporal: typeof temporal.Temporal;
if ("Temporal" in globalThis) {
  Temporal = globalThis.Temporal;
} else {
  Temporal = temporal.Temporal;
}

function getStore(): {
  db: Database;
  tableName: string;
  store: SqliteKvStore;
} {
  const db = new Database(":memory:");
  const tableName = `fedify_kv_test_${Math.random().toString(36).slice(5)}`;
  return {
    db,
    tableName,
    store: new SqliteKvStore(db, { tableName }),
  };
}

test("SqliteKvStore.initialize()", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.initialize();
    const result = db.query(`
      SELECT name FROM sqlite_master 
      WHERE type='table' AND name=?
    `).get(tableName);
    expect(result).toBeDefined();
  } finally {
    await store.drop();
    db.close();
  }
});

test("SqliteKvStore.get()", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.initialize();
    const now = Temporal.Now.instant().epochMilliseconds;
    db.prepare(`
      INSERT INTO ${tableName} (key, value, created)
      VALUES (?, ?, ?)
    `).run(JSON.stringify(["foo", "bar"]), JSON.stringify(["foobar"]), now);
    expect(await store.get(["foo", "bar"])).toEqual(["foobar"]);

    db.prepare(`
      INSERT INTO ${tableName} (key, value, expires, created)
      VALUES (?, ?, ?, ?)
    `).run(
      JSON.stringify(["foo", "bar", "ttl"]),
      JSON.stringify(["foobar"]),
      now + 500,
      Temporal.Now.instant().epochMilliseconds,
    );
    await delay(500);
    expect(await store.get(["foo", "bar", "ttl"])).toBeUndefined();
  } finally {
    await store.drop();
    db.close();
  }
});

test("SqliteKvStore.set()", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.set(["foo", "baz"], "baz");

    const result = db.prepare(`
      SELECT * FROM ${tableName}
      WHERE key = ?
    `).all(JSON.stringify(["foo", "baz"]));

    expect(result.length).toBe(1);
    expect(JSON.parse(result[0].key as string)).toEqual(["foo", "baz"]);
    expect(JSON.parse(result[0].value as string)).toBe("baz");
    expect(result[0].expires).toBeNull();

    await store.set(["foo", "qux"], "qux", {
      ttl: Temporal.Duration.from({ days: 1 }),
    });
    const result2 = db.prepare(`
      SELECT * FROM ${tableName}
      WHERE key = ?
    `).all(JSON.stringify(["foo", "qux"]));
    expect(result2.length).toBe(1);
    expect(JSON.parse(result2[0].key as string)).toEqual(["foo", "qux"]);
    expect(JSON.parse(result2[0].value as string)).toBe("qux");
    expect(
      (result2[0].expires as number) >=
        (result2[0].created as number) + 86400000,
    ).toBe(true);

    await store.set(["foo", "quux"], true);
    const result3 = db.prepare(`
      SELECT * FROM ${tableName}
      WHERE key = ?
    `).all(JSON.stringify(["foo", "quux"]));
    expect(result3.length).toBe(1);
    expect(JSON.parse(result3[0].key as string)).toEqual(["foo", "quux"]);
    expect(JSON.parse(result3[0].value as string)).toBe(true);
    expect(result3[0].expires).toBeNull();
  } finally {
    await store.drop();
    db.close();
  }
});

test("SqliteKvStore.set() - upsert functionality", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.set(["upsert"], "initial");
    expect(await store.get(["upsert"])).toBe("initial");
    await store.set(["upsert"], "updated");
    expect(await store.get(["upsert"])).toBe("updated");
    const result = db.prepare(`
      SELECT COUNT(*) as count FROM ${tableName} WHERE key = ?
    `).get(JSON.stringify(["upsert"]));
    expect((result as { count: number }).count).toBe(1);
  } finally {
    await store.drop();
    db.close();
  }
});

test("SqliteKvStore.delete()", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.initialize();
    await store.delete(["foo", "qux"]);
    const result = db.prepare(`
      SELECT * FROM ${tableName}
      WHERE key = ?
    `).all(JSON.stringify(["foo", "qux"]));
    expect(result.length).toBe(0);

    db.prepare(`
      INSERT INTO ${tableName} (key, value, created)
      VALUES (?, ?, ?)
    `).run(
      JSON.stringify(["foo", "qux"]),
      JSON.stringify(["qux"]),
      Temporal.Now.instant().epochMilliseconds,
    );
    await store.delete(["foo", "qux"]);
    const result2 = db.prepare(`
      SELECT * FROM ${tableName}
      WHERE key = ?
    `).all(JSON.stringify(["foo", "qux"]));
    expect(result2.length).toBe(0);
  } finally {
    await store.drop();
    db.close();
  }
});

test("SqliteKvStore.drop()", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.drop();
    const result = db.query(`
      SELECT name FROM sqlite_master 
      WHERE type='table' AND name=?
    `).get(tableName);
    expect(result).toBeNull();
  } finally {
    db.close();
  }
});

test("SqliteKvStore.cas()", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.set(["foo", "bar"], "foobar");
    expect(await store.cas(["foo", "bar"], "bar", "baz")).toBe(false);
    expect(await store.get(["foo", "bar"])).toBe("foobar");
    expect(await store.cas(["foo", "bar"], "foobar", "baz")).toBe(true);
    expect(await store.get(["foo", "bar"])).toBe("baz");
    await store.delete(["foo", "bar"]);
    expect(await store.cas(["foo", "bar"], "foobar", "baz")).toBe(false);
    expect(await store.get(["foo", "bar"])).toBeUndefined();
    expect(await store.cas(["foo", "bar"], undefined, "baz")).toBe(true);
    expect(await store.get(["foo", "bar"])).toBe("baz");
    expect(await store.cas(["foo", "bar"], "baz", undefined)).toBe(true);
    expect(await store.get(["foo", "bar"])).toBeUndefined();
  } finally {
    await store.drop();
    db.close();
  }
});

test("SqliteKvStore - complex values", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.set(["complex"], {
      nested: {
        value: "test",
      },
    });
    expect(await store.get(["complex"])).toEqual({
      nested: {
        value: "test",
      },
    });

    await store.set(["undefined"], undefined);
    expect(await store.get(["undefined"])).toBeUndefined();
    expect(await store.cas(["undefined"], undefined, "baz")).toBe(true);
    expect(await store.get(["undefined"])).toBe("baz");

    await store.set(["null"], null);
    expect(await store.get(["null"])).toBeNull();
    expect(await store.cas(["null"], null, "baz")).toBe(true);
    expect(await store.get(["null"])).toBe("baz");

    await store.set(["empty string"], "");
    expect(await store.get(["empty string"])).toBe("");
    expect(await store.cas(["empty string"], "", "baz")).toBe(true);
    expect(await store.get(["empty string"])).toBe("baz");

    await store.set(["array"], [1, 2, 3]);
    expect(await store.get(["array"])).toEqual([1, 2, 3]);
    expect(
      await store.cas(["array"], [1, 2, 3], [1, 2, 3, 4]),
    ).toBe(true);
    expect(await store.get(["array"])).toEqual([1, 2, 3, 4]);

    await store.set(["object"], { a: 1, b: 2 });
    expect(await store.get(["object"])).toEqual({ a: 1, b: 2 });
    expect(
      await store.cas(["object"], { a: 1, b: 2 }, { a: 1, b: 2, c: 3 }),
    ).toBe(true);
    expect(await store.get(["object"])).toEqual({ a: 1, b: 2, c: 3 });

    await store.set(["falsy", "false"], false);
    expect(await store.get(["falsy", "false"])).toBe(false);
    expect(await store.cas(["falsy", "false"], false, true)).toBe(true);
    expect(await store.get(["falsy", "false"])).toBe(true);

    await store.set(["falsy", "0"], 0);
    expect(await store.get(["falsy", "0"])).toBe(0);
    expect(await store.cas(["falsy", "0"], 0, 1)).toBe(true);
    expect(await store.get(["falsy", "0"])).toBe(1);
  } finally {
    await store.drop();
    db.close();
  }
});

test("SqliteKvStore.set() - preserves created timestamp on update", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.set(["timestamp-test"], "initial");
    const initialResult = db.prepare(`
      SELECT created, expires FROM ${tableName}
      WHERE key = ?
    `).get(JSON.stringify(["timestamp-test"]));

    const initialCreated = (initialResult as { created: number }).created;
    expect(initialCreated).toBeDefined();
    expect((initialResult as { expires: number | null }).expires).toBeNull();

    await delay(100);

    const ttl = Temporal.Duration.from({ seconds: 30 });
    await store.set(["timestamp-test"], "updated", { ttl });

    const updatedResult = db.prepare(`
      SELECT created, expires FROM ${tableName}
      WHERE key = ?
    `).get(JSON.stringify(["timestamp-test"]));

    expect((updatedResult as { created: number }).created).toBe(
      initialCreated,
      "Created timestamp should remain unchanged after update",
    );
  } finally {
    await store.drop();
    db.close();
  }
});
+2 −1
Original line number Diff line number Diff line
@@ -7,7 +7,8 @@
  },
  "imports": {
    "@fedify/sqlite": "./mod.ts",
    "@fedify/sqlite/": "./"
    "@fedify/sqlite/": "./",
    "#sqlite": "./dist/sqlite.node.js"
  },
  "nodeModulesDir": "none",
  "unstable": [

sqlite/deno.test.ts

deleted100644 → 0
+0 −340
Original line number Diff line number Diff line
import { SqliteKvStore } from "@fedify/sqlite";
import * as temporal from "@js-temporal/polyfill";
import {
  assertEquals,
  assertStrictEquals,
} from "https://deno.land/std@0.218.0/assert/mod.ts";
import { delay } from "https://deno.land/std@0.218.0/async/delay.ts";
import { DB } from "https://deno.land/x/sqlite@v3.7.0/mod.ts";

let Temporal: typeof temporal.Temporal;
if ("Temporal" in globalThis) {
  Temporal = globalThis.Temporal;
} else {
  Temporal = temporal.Temporal;
}

function getStore(): {
  db: DB;
  tableName: string;
  store: SqliteKvStore;
} {
  const db = new DB(":memory:");
  const tableName = `fedify_kv_test_${Math.random().toString(36).slice(5)}`;
  return {
    db,
    tableName,
    store: new SqliteKvStore(db, { tableName }),
  };
}

Deno.test("SqliteKvStore.initialize()", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.initialize();
    const result = db.queryEntries(
      `
      SELECT name FROM sqlite_master 
      WHERE type='table' AND name=?
    `,
      [tableName],
    );
    assertEquals(result.length > 0, true);
  } finally {
    await store.drop();
    db.close();
  }
});

Deno.test("SqliteKvStore.get()", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.initialize();
    const now = Temporal.Now.instant().epochMilliseconds;
    db.query(
      `
      INSERT INTO ${tableName} (key, value, created)
      VALUES (?, ?, ?)
    `,
      [JSON.stringify(["foo", "bar"]), JSON.stringify(["foobar"]), now],
    );
    assertEquals(await store.get(["foo", "bar"]), ["foobar"]);

    db.query(
      `
      INSERT INTO ${tableName} (key, value, expires, created)
      VALUES (?, ?, ?, ?)
    `,
      [
        JSON.stringify(["foo", "bar", "ttl"]),
        JSON.stringify(["foobar"]),
        now + 500,
        Temporal.Now.instant().epochMilliseconds,
      ],
    );
    await delay(500);
    assertStrictEquals(await store.get(["foo", "bar", "ttl"]), undefined);
  } finally {
    await store.drop();
    db.close();
  }
});

Deno.test("SqliteKvStore.set()", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.set(["foo", "baz"], "baz");

    const result = db.queryEntries(
      `
      SELECT * FROM ${tableName}
      WHERE key = ?
    `,
      [JSON.stringify(["foo", "baz"])],
    );

    assertEquals(result.length, 1);
    assertEquals(JSON.parse(result[0].key as string), ["foo", "baz"]);
    assertEquals(JSON.parse(result[0].value as string), "baz");
    assertEquals(result[0].expires, null);

    await store.set(["foo", "qux"], "qux", {
      ttl: Temporal.Duration.from({ days: 1 }),
    });
    const result2 = db.queryEntries(
      `
      SELECT * FROM ${tableName}
      WHERE key = ?
    `,
      [JSON.stringify(["foo", "qux"])],
    );
    assertEquals(result2.length, 1);
    assertEquals(JSON.parse(result2[0].key as string), ["foo", "qux"]);
    assertEquals(JSON.parse(result2[0].value as string), "qux");
    assertEquals(
      (result2[0].expires as number) >=
        (result2[0].created as number) + 86400000,
      true,
    );

    await store.set(["foo", "quux"], true);
    const result3 = db.queryEntries(
      `
      SELECT * FROM ${tableName}
      WHERE key = ?
    `,
      [JSON.stringify(["foo", "quux"])],
    );
    assertEquals(result3.length, 1);
    assertEquals(JSON.parse(result3[0].key as string), ["foo", "quux"]);
    assertEquals(JSON.parse(result3[0].value as string), true);
    assertEquals(result3[0].expires, null);
  } finally {
    await store.drop();
    db.close();
  }
});

Deno.test("SqliteKvStore.set() - upsert functionality", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.set(["upsert"], "initial");
    assertStrictEquals(await store.get(["upsert"]), "initial");
    await store.set(["upsert"], "updated");
    assertStrictEquals(await store.get(["upsert"]), "updated");
    const result = db.queryEntries(
      `
      SELECT COUNT(*) as count FROM ${tableName} WHERE key = ?
    `,
      [JSON.stringify(["upsert"])],
    );
    assertEquals(result[0].count, 1);
  } finally {
    await store.drop();
    db.close();
  }
});

Deno.test("SqliteKvStore.delete()", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.delete(["foo", "qux"]);
    const result = db.queryEntries(
      `
      SELECT * FROM ${tableName}
      WHERE key = ?
    `,
      [JSON.stringify(["foo", "qux"])],
    );
    assertEquals(result.length, 0);

    db.query(
      `
      INSERT INTO ${tableName} (key, value, created)
      VALUES (?, ?, ?)
    `,
      [
        JSON.stringify(["foo", "qux"]),
        JSON.stringify(["qux"]),
        Temporal.Now.instant().epochMilliseconds,
      ],
    );
    await store.delete(["foo", "qux"]);
    const result2 = db.queryEntries(
      `
      SELECT * FROM ${tableName}
      WHERE key = ?
    `,
      [JSON.stringify(["foo", "qux"])],
    );
    assertEquals(result2.length, 0);
  } finally {
    await store.drop();
    db.close();
  }
});

Deno.test("SqliteKvStore.drop()", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.drop();
    const result = db.queryEntries(
      `
      SELECT name FROM sqlite_master 
      WHERE type='table' AND name=?
    `,
      [tableName],
    );
    assertEquals(result.length, 0);
  } finally {
    db.close();
  }
});

Deno.test("SqliteKvStore.cas()", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.set(["foo", "bar"], "foobar");
    assertEquals(await store.cas(["foo", "bar"], "bar", "baz"), false);
    assertStrictEquals(await store.get(["foo", "bar"]), "foobar");
    assertEquals(await store.cas(["foo", "bar"], "foobar", "baz"), true);
    assertStrictEquals(await store.get(["foo", "bar"]), "baz");
    await store.delete(["foo", "bar"]);
    assertEquals(await store.cas(["foo", "bar"], "foobar", "baz"), false);
    assertStrictEquals(await store.get(["foo", "bar"]), undefined);
    assertEquals(await store.cas(["foo", "bar"], undefined, "baz"), true);
    assertStrictEquals(await store.get(["foo", "bar"]), "baz");
    assertEquals(await store.cas(["foo", "bar"], "baz", undefined), true);
    assertStrictEquals(await store.get(["foo", "bar"]), undefined);
  } finally {
    await store.drop();
    db.close();
  }
});

Deno.test("SqliteKvStore - complex values", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.set(["complex"], {
      nested: {
        value: "test",
      },
    });
    assertEquals(await store.get(["complex"]), {
      nested: {
        value: "test",
      },
    });

    await store.set(["undefined"], undefined);
    assertStrictEquals(await store.get(["undefined"]), undefined);
    assertEquals(await store.cas(["undefined"], undefined, "baz"), true);
    assertStrictEquals(await store.get(["undefined"]), "baz");

    await store.set(["null"], null);
    assertStrictEquals(await store.get(["null"]), null);
    assertEquals(await store.cas(["null"], null, "baz"), true);
    assertStrictEquals(await store.get(["null"]), "baz");

    await store.set(["empty string"], "");
    assertStrictEquals(await store.get(["empty string"]), "");
    assertEquals(await store.cas(["empty string"], "", "baz"), true);
    assertStrictEquals(await store.get(["empty string"]), "baz");

    await store.set(["array"], [1, 2, 3]);
    assertEquals(await store.get(["array"]), [1, 2, 3]);
    assertEquals(
      await store.cas(["array"], [1, 2, 3], [1, 2, 3, 4]),
      true,
    );
    assertEquals(await store.get(["array"]), [1, 2, 3, 4]);

    await store.set(["object"], { a: 1, b: 2 });
    assertEquals(await store.get(["object"]), { b: 2, a: 1 });
    assertEquals(
      await store.cas(["object"], { a: 1, b: 2 }, { a: 1, b: 2, c: 3 }),
      true,
    );
    assertEquals(await store.get(["object"]), { a: 1, b: 2, c: 3 });

    await store.set(["falsy", "false"], false);
    assertStrictEquals(await store.get(["falsy", "false"]), false);
    assertEquals(await store.cas(["falsy", "false"], false, true), true);
    assertStrictEquals(await store.get(["falsy", "false"]), true);

    await store.set(["falsy", "0"], 0);
    assertStrictEquals(await store.get(["falsy", "0"]), 0);
    assertEquals(await store.cas(["falsy", "0"], 0, 1), true);
    assertStrictEquals(await store.get(["falsy", "0"]), 1);
  } finally {
    await store.drop();
    db.close();
  }
});

Deno.test("SqliteKvStore.set() - preserves created timestamp on update", async () => {
  const { db, tableName, store } = getStore();
  try {
    await store.set(["timestamp-test"], "initial");
    const initialResult = db.queryEntries(
      `
      SELECT created, expires FROM ${tableName}
      WHERE key = ?
    `,
      [JSON.stringify(["timestamp-test"])],
    );

    const initialCreated = initialResult[0].created as number;
    assertEquals(
      initialCreated !== undefined,
      true,
      "Initial created timestamp should be set",
    );
    assertStrictEquals(
      initialResult[0].expires,
      null,
    );

    await delay(100);

    const ttl = Temporal.Duration.from({ seconds: 30 });
    await store.set(["timestamp-test"], "updated", { ttl });

    const updatedResult = db.queryEntries(
      `
      SELECT created, expires FROM ${tableName}
      WHERE key = ?
    `,
      [JSON.stringify(["timestamp-test"])],
    );

    assertEquals(
      updatedResult[0].created as number,
      initialCreated,
      "Created timestamp should remain unchanged after update",
    );
  } finally {
    await store.drop();
    db.close();
  }
});

sqlite/deno.ts

deleted100644 → 0
+0 −91
Original line number Diff line number Diff line
import { DB } from "https://deno.land/x/sqlite@v3.7.0/mod.ts";
import type { SQLiteDatabase, SQLiteStatement } from "./adapter.ts";
import type { SqliteKvStoreOptions } from "./kv.ts";
import { SqliteKvStore as BaseSqliteKvStore } from "./kv.ts";

class DenoSqliteDatabase implements SQLiteDatabase {
  constructor(private readonly db: DB) {}

  prepare(sql: string): SQLiteStatement {
    return new DenoSqliteStatement(this.db, sql);
  }

  exec(sql: string): void {
    this.db.execute(sql);
  }

  close(): void {
    this.db.close(true); // Force close all prepared statements
  }
}

class DenoSqliteStatement implements SQLiteStatement {
  private stmt: ReturnType<DB["prepareQuery"]>;

  constructor(private readonly db: DB, sql: string) {
    this.stmt = this.db.prepareQuery(sql);
  }

  run(...params: unknown[]): { changes: number; lastInsertRowid: number } {
    try {
      const args =
        Array.isArray(params) && params.length === 1 && Array.isArray(params[0])
          ? params[0]
          : params;
      this.stmt.execute(args);
      return {
        changes: this.db.changes,
        lastInsertRowid: this.db.lastInsertRowId,
      };
    } finally {
      this.finalize();
    }
  }

  get(...params: unknown[]): unknown {
    try {
      const args =
        Array.isArray(params) && params.length === 1 && Array.isArray(params[0])
          ? params[0]
          : params;
      const result = this.stmt.first(args);

      // Handle the case when no rows are found
      if (result === undefined) {
        return undefined;
      }

      // Convert the result to the expected format with a value property
      if (typeof result === "object" && result !== null && "value" in result) {
        return result;
      } else {
        // If the query doesn't select a column named 'value', wrap the result
        return { value: result };
      }
    } finally {
      this.finalize();
    }
  }

  all(...params: unknown[]): unknown[] {
    try {
      const args =
        Array.isArray(params) && params.length === 1 && Array.isArray(params[0])
          ? params[0]
          : params;
      return this.stmt.all(args);
    } finally {
      this.finalize();
    }
  }

  private finalize(): void {
    this.stmt.finalize();
  }
}

export class SqliteKvStore extends BaseSqliteKvStore {
  constructor(db: DB, options: SqliteKvStoreOptions = {}) {
    super(new DenoSqliteDatabase(db), options);
  }
}
Loading