191 lines · 7.1 KB · TypeScript
RawDownload
import { describe, expect, it } from "@effect/vitest";import { createClient } from "@libsql/client";import { Effect, Predicate } from "effect"; import {  DataMigrationError,  runSqliteDataMigrations,  sqliteDataMigration,  type SqliteDataMigration,  type SqliteDataMigrationClient,} from "./sqlite-data-migrations"; // A tiny scripted fake standing in for a libSQL client: tracks statements// and simulates the ledger table.const makeFakeClient = (stampedNames: string[]) => {  const log: unknown[] = [];  const stamps = [...stampedNames];  const client: SqliteDataMigrationClient = {    execute: (stmt) => {      log.push(stmt);      if (typeof stmt === "string" && stmt.startsWith("SELECT name FROM data_migration")) {        return Promise.resolve({ rows: stamps.map((name) => ({ name })) });      }      if (typeof stmt === "object" && stmt.sql.startsWith("INSERT INTO data_migration")) {        stamps.push(String(stmt.args[0]));      }      return Promise.resolve({ rows: [] });    },  };  return { client, log, stamps };}; const migrationSpy = (name: string) => {  const calls: number[] = [];  const migration: SqliteDataMigration = {    name,    run: () => Effect.sync(() => void calls.push(calls.length)),  };  return { migration, calls };}; describe("runSqliteDataMigrations", () => {  it.effect("runs pending migrations in order and stamps them", () =>    Effect.gen(function* () {      const { client, log, stamps } = makeFakeClient([]);      const a = migrationSpy("2026-06-05-a");      const b = migrationSpy("2026-06-11-b");      const applied = yield* runSqliteDataMigrations(client, [a.migration, b.migration]);      expect(applied).toEqual(["2026-06-05-a", "2026-06-11-b"]);      expect(a.calls.length).toBe(1);      expect(b.calls.length).toBe(1);      expect(stamps).toEqual(["2026-06-05-a", "2026-06-11-b"]);      expect(String(log[0])).toContain("CREATE TABLE IF NOT EXISTS data_migration");    }),  );   it.effect("skips stamped migrations without running them", () =>    Effect.gen(function* () {      const { client } = makeFakeClient(["2026-06-05-a"]);      const a = migrationSpy("2026-06-05-a");      const b = migrationSpy("2026-06-11-b");      const applied = yield* runSqliteDataMigrations(client, [a.migration, b.migration]);      expect(applied).toEqual(["2026-06-11-b"]);      expect(a.calls.length).toBe(0);      expect(b.calls.length).toBe(1);    }),  );   it.effect("is a no-op once everything is stamped", () =>    Effect.gen(function* () {      const { client } = makeFakeClient([]);      const a = migrationSpy("2026-06-05-a");      yield* runSqliteDataMigrations(client, [a.migration]);      const applied = yield* runSqliteDataMigrations(client, [a.migration]);      expect(applied).toEqual([]);      expect(a.calls.length).toBe(1);    }),  );   it.effect("converges when concurrent runners stamp the same migration", () =>    Effect.gen(function* () {      const client = createClient({ url: ":memory:" });      let bodiesStarted = 0;      let releaseBodies: (() => void) | undefined;      // Hold both bodies until both runners have read the ledger as empty.      const bothBodiesStarted = new Promise<void>((resolve) => {        releaseBodies = resolve;      });      const migration: SqliteDataMigration = {        name: "2026-06-05-concurrent",        run: () =>          Effect.promise(() => {            bodiesStarted++;            if (bodiesStarted === 2) releaseBodies?.();            return bothBodiesStarted;          }),      };       const results = yield* Effect.all(        [          runSqliteDataMigrations(client, [migration]),          runSqliteDataMigrations(client, [migration]),        ],        { concurrency: "unbounded" },      );       expect(results).toEqual([["2026-06-05-concurrent"], ["2026-06-05-concurrent"]]);      expect(bodiesStarted).toBe(2);      const stamps = yield* Effect.promise(() =>        client.execute("SELECT name FROM data_migration ORDER BY name"),      );      expect(stamps.rows.map((row) => row.name)).toEqual(["2026-06-05-concurrent"]);      client.close();    }),  );   it.effect("surfaces non-conflict stamp failures", () =>    Effect.gen(function* () {      const client: SqliteDataMigrationClient = {        execute: (stmt) => {          const sql = typeof stmt === "string" ? stmt : stmt.sql;          if (typeof stmt === "object" && sql.startsWith("INSERT INTO data_migration")) {            // oxlint-disable-next-line executor/no-promise-reject -- simulates a storage-driver rejection at the adapter boundary under test            return Promise.reject("disk full");          }          return Promise.resolve({ rows: [] });        },      };      const migration = migrationSpy("2026-06-05-unstamped");       const failure = yield* runSqliteDataMigrations(client, [migration.migration]).pipe(        Effect.flip,      );       expect(Predicate.isTagged(failure, "DataMigrationError")).toBe(true);      expect((failure as DataMigrationError).cause).toBe("disk full");    }),  );   it.effect("a failing migration leaves no stamp and surfaces the failure", () =>    Effect.gen(function* () {      const { client, stamps } = makeFakeClient([]);      const boom: SqliteDataMigration = {        name: "2026-06-11-boom",        run: () =>          Effect.fail(new DataMigrationError({ migration: "2026-06-11-boom", cause: "nope" })),      };      const after = migrationSpy("2026-06-12-after");      const failure = yield* runSqliteDataMigrations(client, [boom, after.migration]).pipe(        Effect.flip,      );      expect(Predicate.isTagged(failure, "DataMigrationError")).toBe(true);      expect(stamps).toEqual([]);      expect(after.calls.length).toBe(0);    }),  );   it.effect("rejects duplicate names before touching the database", () =>    Effect.gen(function* () {      const { client, log } = makeFakeClient([]);      const a1 = migrationSpy("2026-06-05-a");      const a2 = migrationSpy("2026-06-05-a");      const failure = yield* runSqliteDataMigrations(client, [a1.migration, a2.migration]).pipe(        Effect.flip,      );      expect(Predicate.isTagged(failure, "DuplicateDataMigrationError")).toBe(true);      expect(log).toEqual([]);    }),  );   it.effect("sqliteDataMigration adapts promise-shaped bodies with typed failures", () =>    Effect.gen(function* () {      const { client, stamps } = makeFakeClient([]);      let ran = 0;      const ok = sqliteDataMigration("2026-06-05-promise", () => {        ran++;        return Promise.resolve(42);      });      yield* runSqliteDataMigrations(client, [ok]);      expect(ran).toBe(1);      expect(stamps).toEqual(["2026-06-05-promise"]);       // oxlint-disable-next-line executor/no-promise-reject -- simulates a raw driver rejection at the adapter boundary under test      const bad = sqliteDataMigration("2026-06-12-bad", () => Promise.reject("disk full"));      const failure = yield* runSqliteDataMigrations(client, [ok, bad]).pipe(Effect.flip);      expect(Predicate.isTagged(failure, "DataMigrationError")).toBe(true);      expect((failure as DataMigrationError).migration).toBe("2026-06-12-bad");      expect(ran).toBe(1);    }),  );});