From 0cfe07dccd5046308b4e957603cbd6d0918dca2c Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Wed, 14 Oct 2026 08:50:41 +0100 Subject: [PATCH] fix(drizzle-adapter): support dates in string columns (#11731) Co-authored-by: ping-maxwell --- .changeset/drizzle-string-date-columns.md | 7 + docs/content/docs/adapters/drizzle.mdx | 6 + e2e/adapter/package.json | 1 + .../adapter.drizzle.string-dates.test.ts | 166 ++++++++++++++++++ .../adapter.drizzle.string-dates.test.ts | 93 ++++++++++ .../drizzle-adapter/src/date-columns.test.ts | 44 +++++ packages/drizzle-adapter/src/date-columns.ts | 44 +++++ .../drizzle-adapter/src/drizzle-adapter.ts | 8 +- .../drizzle-adapter/src/relations-v2/index.ts | 13 ++ pnpm-lock.yaml | 3 + 10 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 .changeset/drizzle-string-date-columns.md create mode 100644 e2e/adapter/test/drizzle-adapter/adapter.drizzle.string-dates.test.ts create mode 100644 e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.string-dates.test.ts create mode 100644 packages/drizzle-adapter/src/date-columns.test.ts create mode 100644 packages/drizzle-adapter/src/date-columns.ts diff --git a/.changeset/drizzle-string-date-columns.md b/.changeset/drizzle-string-date-columns.md new file mode 100644 index 0000000000..2c169dd603 --- /dev/null +++ b/.changeset/drizzle-string-date-columns.md @@ -0,0 +1,7 @@ +--- +"@better-auth/drizzle-adapter": patch +--- + +Drizzle schemas that store dates in string columns now work: `timestamp({ mode: "string" })`, MySQL `datetime({ mode: "string" })`, and SQLite `text()`, including on Cloudflare D1. Before, the adapter passed a JavaScript `Date` to those columns. Drivers such as postgres-js, better-sqlite3 and D1 rejected it, so email sign-up, social sign-in and session refresh failed. Filtering on those columns, for example when listing sessions, failed the same way. Better Auth now writes these dates as UTC text and reads a value without a time zone as UTC. + +If your server's time zone is not UTC and you use node-postgres or mysql2 with a string column that has no time zone, existing rows were written in the server's local time. They now read back shifted by the server's UTC offset, so sessions and verification codes created before the upgrade can expire early or late. Schemas generated by the CLI use date columns and are unaffected. diff --git a/docs/content/docs/adapters/drizzle.mdx b/docs/content/docs/adapters/drizzle.mdx index bfb369c8ff..d3d2acc283 100644 --- a/docs/content/docs/adapters/drizzle.mdx +++ b/docs/content/docs/adapters/drizzle.mdx @@ -198,6 +198,12 @@ export const auth = betterAuth({ }); ``` +## Date Columns + +The schema the CLI generates stores dates with Drizzle's date mode, such as `timestamp()` on PostgreSQL and MySQL, and `integer({ mode: "timestamp_ms" })` on SQLite. Date columns that hold strings also work: `timestamp({ mode: "string" })`, MySQL `datetime({ mode: "string" })`, and SQLite `text()`. + +For a string column, Better Auth writes the date in UTC: ISO 8601 (`2026-01-16T16:50:54.888Z`), or `2026-01-16 16:50:54.888` on MySQL. It reads a value without a time zone as UTC. On PostgreSQL, `timestamp({ mode: "string", withTimezone: true })` stores an absolute instant, and the values it returns carry an offset, so reading them does not depend on that rule. + ## Using Plural Table Names If all your tables are using plural form, you can just pass the `usePlural` option: diff --git a/e2e/adapter/package.json b/e2e/adapter/package.json index 8ddbeceffb..56081feb70 100644 --- a/e2e/adapter/package.json +++ b/e2e/adapter/package.json @@ -11,6 +11,7 @@ "mongodb": "^7.1.0", "mysql2": "^3.24.2", "pg": "^8.21.0", + "postgres": "^3.4.8", "tarn": "^3.0.2", "tedious": "^19.2.1" }, diff --git a/e2e/adapter/test/drizzle-adapter/adapter.drizzle.string-dates.test.ts b/e2e/adapter/test/drizzle-adapter/adapter.drizzle.string-dates.test.ts new file mode 100644 index 0000000000..abe039ba38 --- /dev/null +++ b/e2e/adapter/test/drizzle-adapter/adapter.drizzle.string-dates.test.ts @@ -0,0 +1,166 @@ +/** + * Drizzle passes the value of a string-mode timestamp or a SQLite `text()` + * column to the driver unchanged, so the adapter's `Date` reached + * postgres-js and better-sqlite3, which reject it. A date read back from a + * column without a time zone is only wrong when the server is not on UTC, so + * these tests run in America/Los_Angeles. + */ +import type { User } from "@better-auth/core/db"; +import { drizzleAdapter } from "@better-auth/drizzle-adapter"; +import Database from "better-sqlite3"; +import { drizzle as drizzleSqlite } from "drizzle-orm/better-sqlite3"; +import { + boolean, + pgTable, + text as pgText, + timestamp, +} from "drizzle-orm/pg-core"; +import { drizzle as drizzlePostgresJs } from "drizzle-orm/postgres-js"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import postgres from "postgres"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const originalTZ = process.env.TZ; +beforeAll(() => { + process.env.TZ = "America/Los_Angeles"; +}); +afterAll(() => { + // Assigning undefined would store the string "undefined". + if (originalTZ === undefined) delete process.env.TZ; + else process.env.TZ = originalTZ; +}); + +const createdAt = new Date("2026-01-16T16:50:54.888Z"); + +function stringDateSuite( + name: string, + setup: () => Promise<{ + adapter: ReturnType>; + storedCreatedAt: () => Promise; + close: () => Promise; + }>, + expectedStoredCreatedAt: string, +) { + describe(name, async () => { + const { adapter, storedCreatedAt, close } = await setup(); + afterAll(close); + + it("writes, filters on, updates and reads back a Date", async () => { + await adapter.create({ + model: "user", + data: { + name: "String Dates", + email: "string-dates@example.com", + emailVerified: false, + createdAt, + updatedAt: createdAt, + }, + }); + expect(await storedCreatedAt()).toBe(expectedStoredCreatedAt); + + const found = await adapter.findMany({ + model: "user", + where: [ + { + field: "createdAt", + operator: "lt", + value: new Date("2026-01-17T00:00:00.000Z"), + }, + ], + }); + expect(found).toHaveLength(1); + expect(found[0]!.createdAt).toEqual(createdAt); + + const updatedAt = new Date("2026-02-01T00:00:00.000Z"); + const updated = await adapter.update({ + model: "user", + where: [{ field: "email", value: "string-dates@example.com" }], + update: { updatedAt }, + }); + expect(updated?.updatedAt).toEqual(updatedAt); + }); + }); +} + +/** + * @see https://github.com/better-auth/better-auth/issues/7419 + */ +stringDateSuite( + 'PostgreSQL timestamp({ mode: "string" }) with postgres-js', + async () => { + const url = "postgres://user:password@localhost:5432/better_auth"; + const admin = postgres(url); + await admin.unsafe("DROP SCHEMA IF EXISTS string_dates CASCADE"); + await admin.unsafe("CREATE SCHEMA string_dates"); + const sql = postgres(url, { + connection: { search_path: "string_dates" }, + }); + await sql.unsafe( + 'CREATE TABLE "user" (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, "emailVerified" BOOLEAN NOT NULL, image TEXT, "createdAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL)', + ); + const date = (name: string) => + timestamp(name, { mode: "string", precision: 3 }); + const user = pgTable("user", { + id: pgText("id").primaryKey(), + name: pgText("name").notNull(), + email: pgText("email").notNull(), + emailVerified: boolean("emailVerified").notNull(), + image: pgText("image"), + createdAt: date("createdAt").notNull(), + updatedAt: date("updatedAt").notNull(), + }); + return { + adapter: drizzleAdapter(drizzlePostgresJs(sql, { schema: { user } }), { + provider: "pg", + schema: { user }, + })({}), + storedCreatedAt: async () => + (await sql.unsafe('SELECT "createdAt"::text AS value FROM "user"'))[0]! + .value, + close: async () => { + await sql.end(); + await admin.unsafe("DROP SCHEMA string_dates CASCADE"); + await admin.end(); + }, + }; + }, + "2026-01-16 16:50:54.888", +); + +/** + * @see https://github.com/better-auth/better-auth/issues/10816 + */ +stringDateSuite( + "SQLite text() dates with better-sqlite3", + async () => { + const sqlite = new Database(":memory:"); + sqlite.exec( + "CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, emailVerified INTEGER NOT NULL, image TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL)", + ); + const user = sqliteTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull(), + emailVerified: integer("emailVerified", { mode: "boolean" }).notNull(), + image: text("image"), + createdAt: text("createdAt").notNull(), + updatedAt: text("updatedAt").notNull(), + }); + return { + adapter: drizzleAdapter(drizzleSqlite(sqlite, { schema: { user } }), { + provider: "sqlite", + schema: { user }, + })({}), + storedCreatedAt: async () => + ( + sqlite.prepare("SELECT createdAt AS value FROM user").get() as { + value: string; + } + ).value, + close: async () => { + sqlite.close(); + }, + }; + }, + "2026-01-16T16:50:54.888Z", +); diff --git a/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.string-dates.test.ts b/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.string-dates.test.ts new file mode 100644 index 0000000000..5a913689ce --- /dev/null +++ b/e2e/adapter/test/drizzle-v2-relations-adapter/adapter.drizzle.string-dates.test.ts @@ -0,0 +1,93 @@ +/** + * SQLite has no date column, and Drizzle's `text()` passes the value to the + * driver unchanged, so a `Date` reached better-sqlite3 and the write failed. + * + * @see https://github.com/better-auth/better-auth/issues/10816 + */ +import type { User } from "@better-auth/core/db"; +import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2"; +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +// A date read back without a time zone is only wrong off UTC. +const originalTZ = process.env.TZ; +beforeAll(() => { + process.env.TZ = "America/Los_Angeles"; +}); +afterAll(() => { + // Assigning undefined would store the string "undefined". + if (originalTZ === undefined) delete process.env.TZ; + else process.env.TZ = originalTZ; +}); + +const users = sqliteTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull(), + emailVerified: integer("emailVerified", { mode: "boolean" }).notNull(), + image: text("image"), + createdAt: text("createdAt").notNull(), + updatedAt: text("updatedAt").notNull(), +}); + +describe("drizzle relations-v2 adapter: SQLite text() date columns", () => { + const sqliteDb = new Database(":memory:"); + sqliteDb.exec( + "CREATE TABLE user (id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, emailVerified INTEGER NOT NULL, image TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL)", + ); + const adapter = drizzleAdapter( + drizzle({ client: sqliteDb, schema: { users } }), + { schema: { user: users }, provider: "sqlite" }, + )({}); + const createdAt = new Date("2026-01-16T16:50:54.888Z"); + + it("writes, filters on, and reads back a Date", async () => { + await adapter.create({ + model: "user", + data: { + name: "Text Dates", + email: "text-dates@example.com", + emailVerified: false, + createdAt, + updatedAt: createdAt, + }, + }); + expect(sqliteDb.prepare("SELECT createdAt FROM user").get()).toEqual({ + createdAt: "2026-01-16T16:50:54.888Z", + }); + + const found = await adapter.findMany({ + model: "user", + where: [ + { + field: "createdAt", + operator: "lt", + value: new Date("2026-01-17T00:00:00.000Z"), + }, + ], + }); + expect(found).toHaveLength(1); + expect(found[0]!.createdAt).toEqual(createdAt); + + const updatedAt = new Date("2026-02-01T00:00:00.000Z"); + const updated = await adapter.update({ + model: "user", + where: [{ field: "email", value: "text-dates@example.com" }], + update: { updatedAt }, + }); + expect(updated?.updatedAt).toEqual(updatedAt); + }); + + it("reads a date-time without a time zone as UTC", async () => { + sqliteDb.exec( + "INSERT INTO user (id, name, email, emailVerified, createdAt, updatedAt) VALUES ('naive', 'Naive', 'naive@example.com', 0, '2026-01-16 16:50:54.888', '2026-01-16 16:50:54.888')", + ); + const user = await adapter.findOne({ + model: "user", + where: [{ field: "id", value: "naive" }], + }); + expect(user?.createdAt).toEqual(createdAt); + }); +}); diff --git a/packages/drizzle-adapter/src/date-columns.test.ts b/packages/drizzle-adapter/src/date-columns.test.ts new file mode 100644 index 0000000000..f8a850e743 --- /dev/null +++ b/packages/drizzle-adapter/src/date-columns.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { fromDriverDate, toDriverDate } from "./date-columns"; + +// The e2e suites cover ISO writes and reads on PostgreSQL and SQLite; these +// cover the inputs they can't reach. +const date = new Date("2026-01-16T16:50:54.888Z"); + +describe("toDriverDate", () => { + it("recognizes Drizzle 1.x string data types", () => { + expect(toDriverDate(date, { dataType: "string timestamp" }, "pg")).toBe( + "2026-01-16T16:50:54.888Z", + ); + }); + + it("writes the MySQL date-time format", () => { + expect(toDriverDate(date, { dataType: "string datetime" }, "mysql")).toBe( + "2026-01-16 16:50:54.888", + ); + }); + + it("converts each Date in an in / not_in list", () => { + expect( + toDriverDate([date, date], { dataType: "string" }, "sqlite"), + ).toEqual(["2026-01-16T16:50:54.888Z", "2026-01-16T16:50:54.888Z"]); + }); +}); + +describe("fromDriverDate", () => { + it("reads microseconds and a T separator without an offset as UTC", () => { + expect(fromDriverDate("2026-01-16 16:50:54.888123")).toEqual(date); + expect(fromDriverDate("2026-01-16T16:50:54.888")).toEqual(date); + }); + + it("keeps an explicit offset", () => { + expect(fromDriverDate("2026-01-16 18:50:54.888+02")).toEqual(date); + }); + + it("passes numbers and date-only strings to Date", () => { + expect(fromDriverDate(date.getTime())).toEqual(date); + expect(fromDriverDate("2026-01-16")).toEqual( + new Date("2026-01-16T00:00:00.000Z"), + ); + }); +}); diff --git a/packages/drizzle-adapter/src/date-columns.ts b/packages/drizzle-adapter/src/date-columns.ts new file mode 100644 index 0000000000..a255072d92 --- /dev/null +++ b/packages/drizzle-adapter/src/date-columns.ts @@ -0,0 +1,44 @@ +/** + * Whether a Drizzle column stores its value as a string and passes it to the + * driver unchanged, such as `timestamp({ mode: "string" })` or SQLite `text()`. + * Drizzle 0.x names that data type `"string"`, and 1.x names it `"string ..."`, + * for example `"string timestamp"`. + */ +function isStringColumn(column: unknown): boolean { + const dataType = (column as { dataType?: unknown } | undefined)?.dataType; + return typeof dataType === "string" && dataType.split(" ")[0] === "string"; +} + +/** + * Converts a `Date` bound for a string column, or each `Date` in an `in` / + * `not_in` list, into the UTC text Drizzle's own date mode writes: ISO 8601, + * or `YYYY-MM-DD HH:MM:SS.mmm` for MySQL, which rejects the `T` and `Z`. + * Other values are returned unchanged. + */ +export function toDriverDate( + value: unknown, + column: unknown, + provider: "pg" | "mysql" | "sqlite", +): unknown { + if (!isStringColumn(column)) return value; + const format = (item: unknown) => { + if (!(item instanceof Date)) return item; + const iso = item.toISOString(); + return provider === "mysql" ? iso.replace("T", " ").replace("Z", "") : iso; + }; + return Array.isArray(value) ? value.map(format) : format(value); +} + +const DATE_TIME_WITHOUT_OFFSET = + /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/; + +/** + * Reads a date column value. A date-time string without an offset is UTC, + * since that is how `toDriverDate` and Drizzle's date mode write it. + */ +export function fromDriverDate(value: string | number | Date): Date { + if (typeof value === "string" && DATE_TIME_WITHOUT_OFFSET.test(value)) { + return new Date(`${value.replace(" ", "T")}Z`); + } + return new Date(value); +} diff --git a/packages/drizzle-adapter/src/drizzle-adapter.ts b/packages/drizzle-adapter/src/drizzle-adapter.ts index 80faee2f0d..1049eabbcf 100644 --- a/packages/drizzle-adapter/src/drizzle-adapter.ts +++ b/packages/drizzle-adapter/src/drizzle-adapter.ts @@ -36,6 +36,7 @@ import { or, sql, } from "drizzle-orm"; +import { fromDriverDate, toDriverDate } from "./date-columns"; import type { RelationKeysByModel } from "./join-relation-key"; import { buildRelationKeysByModel, @@ -1236,6 +1237,11 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { ? true : false, supportsArrays: config.provider === "pg" ? true : false, + customTransformInput: ({ data, fieldAttributes, model, field }) => { + if (fieldAttributes.type !== "date") return data; + const schema = config.schema || db._.fullSchema; + return toDriverDate(data, schema?.[model]?.[field], config.provider); + }, customTransformOutput: ({ data, fieldAttributes }) => { // not all providers support dates // one such example case is https://github.com/better-auth/better-auth/issues/7819 @@ -1243,7 +1249,7 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { if (data === null || data === undefined) { return data; } - return new Date(data); + return fromDriverDate(data); } return data; }, diff --git a/packages/drizzle-adapter/src/relations-v2/index.ts b/packages/drizzle-adapter/src/relations-v2/index.ts index 7481739048..da65e4b316 100644 --- a/packages/drizzle-adapter/src/relations-v2/index.ts +++ b/packages/drizzle-adapter/src/relations-v2/index.ts @@ -33,6 +33,7 @@ import { or, sql, } from "drizzle-orm"; +import { fromDriverDate, toDriverDate } from "../date-columns"; import type { RelationKeysByModel } from "../join-relation-key"; import { buildRelationKeysByModel, @@ -1177,6 +1178,18 @@ export const drizzleAdapter = (db: DB, config: DrizzleAdapterConfig) => { // cause double-stringification). For PostgreSQL, native arrays are used. // See: https://github.com/better-auth/better-auth/issues/7440 supportsArrays: true, + customTransformInput: ({ data, fieldAttributes, model, field }) => { + if (fieldAttributes.type !== "date") return data; + const table = + config.schema?.[model] ?? + db._?.relations?.[model]?.table ?? + db._?.fullSchema?.[model]; + return toDriverDate(data, table?.[field], config.provider); + }, + customTransformOutput: ({ data, fieldAttributes }) => { + if (fieldAttributes.type !== "date" || data == null) return data; + return fromDriverDate(data); + }, transaction: (config.transaction ?? false) ? (cb) => diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9448cce67..b9fdac69b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -542,6 +542,9 @@ importers: pg: specifier: ^8.21.0 version: 8.22.0 + postgres: + specifier: ^3.4.8 + version: 3.4.8 tarn: specifier: ^3.0.2 version: 3.0.2