diff --git a/e2e/smoke/test/fixtures/cloudflare/package.json b/e2e/smoke/test/fixtures/cloudflare/package.json index ecde131590..e4ffab5e78 100644 --- a/e2e/smoke/test/fixtures/cloudflare/package.json +++ b/e2e/smoke/test/fixtures/cloudflare/package.json @@ -3,11 +3,13 @@ "private": true, "dependencies": { "@better-auth/core": "workspace:*", + "@better-auth/kysely-adapter": "workspace:*", "@better-auth/sso": "workspace:*", "better-auth": "workspace:*", "hono": "^4.13.7" }, "devDependencies": { + "kysely": "catalog:", "vitest": "catalog:vitest", "wrangler": "4.123.0" }, diff --git a/e2e/smoke/test/fixtures/cloudflare/src/index.ts b/e2e/smoke/test/fixtures/cloudflare/src/index.ts index ee93bf90cd..5b88920bb0 100644 --- a/e2e/smoke/test/fixtures/cloudflare/src/index.ts +++ b/e2e/smoke/test/fixtures/cloudflare/src/index.ts @@ -1,3 +1,4 @@ +import { env } from "cloudflare:workers"; import type { AuthEndpointContext } from "@better-auth/core/context"; import { getCurrentAdapter, @@ -9,8 +10,11 @@ import type { DBAdapter, DBTransactionAdapter, } from "@better-auth/core/db/adapter"; +import { createKyselyAdapter } from "@better-auth/kysely-adapter"; import { getMigrations } from "better-auth/db/migration"; import { Hono } from "hono"; +import type { Kysely } from "kysely"; +import { Migrator } from "kysely"; import { auth } from "./auth"; const app = new Hono(); @@ -30,6 +34,74 @@ app.post("/_test/migrate", async (c) => { return c.body(null, 204); }); +app.get("/_test/d1-migration-lock", async (c) => { + const { kysely } = await createKyselyAdapter({ database: env.DB }); + if (!kysely) throw new Error("D1 test database was not created"); + + const suffix = crypto.randomUUID().replaceAll("-", ""); + const migrationTableName = `migration_state_${suffix}`; + const migrationLockTableName = `migration_lock_${suffix}`; + const targetTableName = `migration_target_${suffix}`; + const migrationOptions = { + db: kysely, + migrationTableName, + migrationLockTableName, + }; + + try { + const initialization = await new Migrator({ + ...migrationOptions, + provider: { getMigrations: async () => ({}) }, + }).migrateToLatest(); + if (initialization.error) throw initialization.error; + + let migrationStarts = 0; + let releaseBarrier = () => {}; + const barrier = new Promise((resolve) => { + releaseBarrier = resolve; + }); + const timeout = setTimeout(releaseBarrier, 1500); + try { + const migrator = new Migrator({ + ...migrationOptions, + provider: { + getMigrations: async () => ({ + "001_create_table": { + async up(database: Kysely) { + migrationStarts++; + if (migrationStarts === 2) releaseBarrier(); + await barrier; + await database.schema + .createTable(targetTableName) + .addColumn("id", "integer") + .execute(); + }, + }, + }), + }, + }); + const results = await Promise.all([ + migrator.migrateToLatest(), + migrator.migrateToLatest(), + ]); + const recorded = await env.DB.prepare( + `select count(*) as count from ${migrationTableName}`, + ).first<{ count: number }>(); + return c.json({ + migrationStarts, + recorded: recorded?.count, + errors: results.map((result) => + result.error ? String(result.error) : null, + ), + }); + } finally { + clearTimeout(timeout); + } + } finally { + await kysely.destroy(); + } +}); + app.get("/_test/async-context/concurrency", async (c) => { const contexts = Array.from( { length: 32 }, diff --git a/e2e/smoke/test/fixtures/cloudflare/test/d1-migration-lock.test.ts b/e2e/smoke/test/fixtures/cloudflare/test/d1-migration-lock.test.ts new file mode 100644 index 0000000000..4673e667d7 --- /dev/null +++ b/e2e/smoke/test/fixtures/cloudflare/test/d1-migration-lock.test.ts @@ -0,0 +1,34 @@ +import { afterAll, afterEach, beforeAll, expect, it } from "vitest"; +import { createTestHarness } from "wrangler"; + +const server = createTestHarness({ + workers: [{ configPath: "./wrangler.json" }], +}); + +beforeAll(async () => { + await server.listen(); +}); + +afterEach(({ task }) => { + if (task.result?.state === "fail") server.debug(); +}); + +afterAll(async () => { + await server.close(); +}); + +/** + * @see https://kysely-org.github.io/kysely-apidoc/classes/SqliteAdapter.html#acquiremigrationlock + */ +it("runs a D1 migration only once when two Kysely migrators start together", async () => { + const response = await server.fetch( + "http://localhost:8787/_test/d1-migration-lock", + ); + expect(response.status).toBe(200); + const result: unknown = await response.json(); + expect(result).toEqual({ + migrationStarts: 1, + recorded: 1, + errors: [null, null], + }); +}, 15_000); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6df4fb7b8..e968385c0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -887,6 +887,9 @@ importers: '@better-auth/core': specifier: workspace:* version: link:../../../../../packages/core + '@better-auth/kysely-adapter': + specifier: workspace:* + version: link:../../../../../packages/kysely-adapter '@better-auth/sso': specifier: workspace:* version: link:../../../../../packages/sso @@ -897,6 +900,9 @@ importers: specifier: ^4.13.7 version: 4.13.7 devDependencies: + kysely: + specifier: 0.28.17 + version: 0.28.17 vitest: specifier: catalog:vitest version: 5.0.0(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(@vitest/coverage-istanbul@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.8.9)(msw@2.14.6(@types/node@25.9.4)(typescript@6.0.3))(vite@8.2.0(@types/node@25.9.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.9.0))