189 lines
7.3 KiB
TypeScript
189 lines
7.3 KiB
TypeScript
/**
|
|
* SurrealQL adapter — live SurrealDB v3 ledger regression suite.
|
|
*
|
|
* Reproduces the silent-ledger-drop discovered while shipping
|
|
* `@suaveplan/api-plugin-database`: with the pre-fix code, the runner's
|
|
* `CREATE ${table}:$name SET ...` form parses cleanly under SurrealDB v3
|
|
* but records nothing, so the ledger reports zero applied migrations and
|
|
* every boot re-runs the same migration.
|
|
*
|
|
* These tests run against a real `surrealdb:v3.x` testcontainer started by
|
|
* `tests/integration/global-setup.ts`. No mocks.
|
|
*
|
|
* @module
|
|
*/
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
afterEach,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
} from "@suaveplan/testing/runner";
|
|
import { Surreal } from "surrealdb";
|
|
import {
|
|
createSurrealqlAdapter,
|
|
type SurrealqlClient,
|
|
} from "../../src/adapters/surrealql/surrealql.js";
|
|
import { createMigrationRunner } from "../../src/runner/runner.js";
|
|
import { inject, surrealUnavailable } from "./integration-setup.js";
|
|
|
|
const TABLE = "_migrations";
|
|
|
|
/**
|
|
* Thin wrapper that adapts the official `surrealdb` JS SDK to the structural
|
|
* `SurrealqlClient` shape the migration adapter expects. The SDK's
|
|
* `query<R extends unknown[]>(sql, bindings)` returns the raw statement-result
|
|
* array; we flatten the single statement to the rows shape `selectApplied`
|
|
* consumes.
|
|
*/
|
|
function wrapSurreal(client: Surreal): SurrealqlClient {
|
|
return {
|
|
async query<T>(
|
|
sql: string,
|
|
vars?: Record<string, unknown>
|
|
): Promise<T[]> {
|
|
const result = (await client.query(sql, vars)) as unknown[];
|
|
// `query` returns an array of statement results. For multi-
|
|
// statement SQL we flatten everything that's an array of rows;
|
|
// single-row results are tolerated.
|
|
const flat: T[] = [];
|
|
for (const r of result) {
|
|
if (Array.isArray(r)) flat.push(...(r as T[]));
|
|
else if (r !== null && r !== undefined) flat.push(r as T);
|
|
}
|
|
return flat;
|
|
},
|
|
};
|
|
}
|
|
|
|
async function connect(): Promise<Surreal> {
|
|
const cfg = inject("surrealdb");
|
|
const db = new Surreal();
|
|
await db.connect(cfg.url);
|
|
await db.signin({ username: cfg.username, password: cfg.password });
|
|
await db.use({ namespace: cfg.namespace, database: cfg.database });
|
|
return db;
|
|
}
|
|
|
|
describe.skipIf(surrealUnavailable)(
|
|
"createSurrealqlAdapter — live SurrealDB v3 ledger",
|
|
() => {
|
|
let dir: string;
|
|
let db: Surreal;
|
|
|
|
beforeEach(async () => {
|
|
dir = await mkdtemp(join(tmpdir(), "api-migrations-surreal-"));
|
|
db = await connect();
|
|
// Each test gets its own namespace/database for isolation. The
|
|
// fixture's default ns/db are sufficient because we DROP the
|
|
// ledger table in afterEach.
|
|
}, 60000);
|
|
|
|
afterEach(async () => {
|
|
try {
|
|
await db.query(`REMOVE TABLE ${TABLE};`);
|
|
} catch {
|
|
// best-effort cleanup
|
|
}
|
|
await db.close();
|
|
await rm(dir, { recursive: true, force: true });
|
|
}, 60000);
|
|
|
|
it("ledger row is queryable after a migration body succeeds (regression: silent CREATE drop)", async () => {
|
|
// Arrange: write a single migration body. The body creates a
|
|
// table whose presence we can independently observe.
|
|
const fileName = "001_initial.surql";
|
|
const body = "DEFINE TABLE post_init SCHEMAFULL;";
|
|
await writeFile(join(dir, fileName), body, "utf-8");
|
|
|
|
const client = wrapSurreal(db);
|
|
const adapter = createSurrealqlAdapter(client);
|
|
const runner = createMigrationRunner(adapter, {
|
|
tableName: TABLE,
|
|
fileExtension: ".surql",
|
|
});
|
|
|
|
// Act
|
|
const results = await runner.runFromDirectory(dir);
|
|
|
|
// Assert 1: runner reports success.
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]!.success).toBe(true);
|
|
expect(results[0]!.name).toBe("001_initial");
|
|
|
|
// Assert 2: migration body actually applied.
|
|
const bodyApplied = (await db.query("INFO FOR DB;")) as unknown[];
|
|
expect(JSON.stringify(bodyApplied)).toContain("post_init");
|
|
|
|
// Assert 3 (the regression): the ledger row exists in the
|
|
// SAME live connection. With the pre-fix `${table}:$name`
|
|
// form this query returns []; the runner re-applies the
|
|
// migration on every boot.
|
|
const ledgerRows = (await db.query(
|
|
`SELECT name, checksum, duration FROM ${TABLE};`
|
|
)) as Array<
|
|
Array<{ name: string; checksum: string; duration: number }>
|
|
>;
|
|
const rows = ledgerRows[0] ?? [];
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]!.name).toBe("001_initial");
|
|
expect(typeof rows[0]!.checksum).toBe("string");
|
|
expect(rows[0]!.checksum).toMatch(/^[0-9a-f]{64}$/);
|
|
}, 60000);
|
|
|
|
it("second run sees the ledger and skips already-applied migrations", async () => {
|
|
const fileName = "001_idempotent.surql";
|
|
const body = "DEFINE TABLE idempotent_t SCHEMAFULL;";
|
|
await writeFile(join(dir, fileName), body, "utf-8");
|
|
|
|
const adapter = createSurrealqlAdapter(wrapSurreal(db));
|
|
const runner = createMigrationRunner(adapter, {
|
|
tableName: TABLE,
|
|
fileExtension: ".surql",
|
|
});
|
|
|
|
const first = await runner.runFromDirectory(dir);
|
|
expect(first).toHaveLength(1);
|
|
|
|
// Second invocation MUST be a no-op. With the pre-fix bug
|
|
// the ledger was empty so the runner would re-apply the
|
|
// migration — which then fails because `DEFINE TABLE` is
|
|
// idempotent here but in real schemas would crash on
|
|
// duplicate constraints.
|
|
const second = await runner.runFromDirectory(dir);
|
|
expect(second).toEqual([]);
|
|
|
|
// Ledger still has exactly one row.
|
|
const ledgerRows = (await db.query(
|
|
`SELECT name FROM ${TABLE};`
|
|
)) as Array<Array<{ name: string }>>;
|
|
expect(ledgerRows[0] ?? []).toHaveLength(1);
|
|
}, 60000);
|
|
|
|
it("rollback() removes the ledger row written by insertApplied (round-trip)", async () => {
|
|
const fileName = "001_rollback.surql";
|
|
const body = "DEFINE TABLE rollback_t SCHEMAFULL;";
|
|
await writeFile(join(dir, fileName), body, "utf-8");
|
|
|
|
const adapter = createSurrealqlAdapter(wrapSurreal(db));
|
|
const runner = createMigrationRunner(adapter, {
|
|
tableName: TABLE,
|
|
fileExtension: ".surql",
|
|
});
|
|
await runner.runFromDirectory(dir);
|
|
|
|
const result = await runner.rollback();
|
|
expect(result?.success).toBe(true);
|
|
expect(result?.name).toBe("001_rollback");
|
|
|
|
const ledgerRows = (await db.query(
|
|
`SELECT name FROM ${TABLE};`
|
|
)) as Array<Array<{ name: string }>>;
|
|
expect(ledgerRows[0] ?? []).toHaveLength(0);
|
|
}, 60000);
|
|
}
|
|
);
|