Skip to content

research: cross-database Drizzle DX abstraction (sqlite/postgres/mysql) #562

@vivek7405

Description

@vivek7405

Resolution status (see the "Resolutions to the 5 open questions" comment for detail): Q1 (column surface + escape hatch), Q2 (portable insertOne), Q3 (scaffold --db flag), Q4 (str length / text) are RESOLVED and verified on rc.3. Q5 (ship the abstraction by default vs SQLite-default + opt-in) is the one OPEN decision, pending the owner. Recommendation on record: SQLite-default with minimal helpers (pk/createdAt/index) as the default, the full portable abstraction as a documented opt-in.

Question

Can webjs offer a single, portable Drizzle DX (schema + queries + mutations + inferred types) across SQLite, Postgres, and MySQL, via a thin abstraction, while keeping the buildless thesis? If so, finalize that DX and apply it to the final Drizzle implementation (#551).

Motivation: Prisma's schema is largely DB-agnostic (switch provider + URL). Drizzle's column builders are dialect-specific, so a naive Drizzle adoption loses that portability. This record investigates whether a thin abstraction recovers Prisma-grade portability on Drizzle's buildless base.

Status: OPEN, research in progress. Pinned to drizzle-orm + drizzle-kit 1.0.0-rc.3 (the newest clean prerelease; relations v2 defineRelations only exists on the 1.0 line). All findings below verified by real tsc --noEmit (strict + erasableSyntaxOnly) and, where noted, drizzle-kit generate and a better-sqlite3 round-trip.

Verified findings (drizzle-orm 1.0.0-rc.3)

1. Schema portability via per-dialect column modules: FEASIBLE

A unified column API (table, pk, uuidPk, str, int, bool, createdAt, updatedAt, index) implemented once per dialect (columns.sqlite, columns.pg, columns.mysql), each mapping to that dialect's builders. The SAME schema.server.ts body, written against the unified API, compiled clean against ALL THREE dialect modules.

2. Inferred types are identical across dialects

A bidirectional type-equality assertion on $inferSelect passed on all three: User resolves to exactly { id: number; email: string; name: string | null; active: boolean; createdAt: Date } regardless of dialect. So active (sqlite integer boolean-mode vs pg/mysql native boolean) and createdAt (sqlite integer timestamp vs pg/mysql native timestamp) all infer the same boolean / Date. App code that consumes these types is portable.

3. Reads, updates, deletes: portable as-is

The same code compiled against the three real db types (better-sqlite3, node-postgres, mysql2): db.select().from().where(eq()).orderBy(desc()), db.update().set().where(), db.delete().where(), and the relational db.query.* surface. No per-dialect change.

4. The ONE break: MySQL has no .returning()

db.insert(t).values(...).returning() typechecks on SQLite and Postgres but FAILS on MySQL (Property 'returning' does not exist on type 'MySqlInsertBase...'. Did you mean '$returningId'?). MySQL only exposes $returningId(). So the create-then-return-the-row pattern is the single non-portable mutation. A portable insertOne(db, table, values) helper closes the gap (.returning() on sqlite/pg, $returningId() + re-read on mysql).

5. Primary key: int OR string, both infer correctly

Verified tsc exact-equality: integer PK infers id: number, uuid/text PK infers id: string. Per dialect: int = integer().primaryKey({autoIncrement}) / serial().primaryKey() / int().autoincrement().primaryKey(); string = text().primaryKey().$defaultFn(() => crypto.randomUUID()) (sqlite/mysql) / uuid().primaryKey().defaultRandom() (pg). Expose two helpers (pk() int, uuidPk() string) rather than one overloaded fn (a single fn returning a union wrecks inference).

6. rc.3 DX specifics (apply to all dialects)

  • Casing lives on the table creator: sqliteTableCreator((n) => n, 'snake_case') (and pg/mysql equivalents). The connection-config casing was removed in rc.3.
  • many relations need explicit { from, to } to typecheck; bare r.many.x() errors.
  • index() needs a name to typecheck (runtime auto-names). A column-deriving index(...cols) helper recovers the anonymous call site and emits drizzle-kit's own table-qualified names.
  • createdAt default: integer({ mode: 'timestamp_ms' }).notNull().defaultNow(). Footgun: defaultNow() emits milliseconds, so it MUST pair with timestamp_ms, never timestamp (seconds), or dates land in year 58429.
  • updatedAt: defaultNow().$onUpdate(() => new Date()) (sqlite/pg, app-level) vs MySQL native defaultNow().onUpdateNow() (DB-enforced). The helper hides the difference.

Proposed DX (to finalize)

db/
  columns.server.ts     # re-exports ONE dialect's helpers (scaffold writes per chosen DB)
  columns.sqlite.ts     # { table, pk, uuidPk, str, int, bool, createdAt, updatedAt, index }
  columns.pg.ts
  columns.mysql.ts
  write.server.ts       # insertOne() portable create-and-return helper
  schema.server.ts      # written ONCE against columns.server.ts, identical for any DB
  connection.server.ts  # per-dialect driver (bun:sqlite / better-sqlite3 / pg / mysql2)

Schema, relations, queries, actions, and inferred types stay portable. Switch DB = swap the active columns module + the connection driver, regenerate migrations.

Open questions to finalize

  1. Unified API surface: which column types are in scope (text/varchar length handling, numeric, boolean, json, blob)? What is the escape-hatch convention for dialect-only types (pg arrays/jsonb ops, mysql fulltext)?
  2. insertOne ergonomics + typing across the three db types (generics vs per-dialect wrapper).
  3. How the scaffold selects a dialect (a --db flag) and what it writes (which columns.* becomes columns.server.ts, which connection, which drizzle.config).
  4. str() length: pg/mysql varchar wants a length, sqlite text does not. Default length, or str({ length })?
  5. Is the maintenance cost (owning 3 column modules + a write helper + 3-dialect tests) worth the portability for webjs's audience, or is SQLite-only the right default with the abstraction as opt-in?

Relationship to #551

#551 is scoped to "default ORM = Drizzle, SQLite default." This cross-DB abstraction is a larger, separate design. The plan: finalize the DX in THIS record, then implement. The final Drizzle implementation (#551 and/or a dedicated feature issue filed from this record) adopts the finalized DX. Keep #551 lean unless we decide to fold the abstraction in.

Metadata

Metadata

Assignees

No one assigned

    Labels

    researchResearch/design/decision record (no code); filter these to read design history

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions