/** * 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(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( sql: string, vars?: Record ): Promise { 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 { 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>; 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>; expect(ledgerRows[0] ?? []).toHaveLength(0); }, 60000); } );