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); }), );});
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);
}),
);
});