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
- 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)?
insertOne ergonomics + typing across the three db types (generics vs per-dialect wrapper).
- How the scaffold selects a dialect (a
--db flag) and what it writes (which columns.* becomes columns.server.ts, which connection, which drizzle.config).
str() length: pg/mysql varchar wants a length, sqlite text does not. Default length, or str({ length })?
- 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.
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-kit1.0.0-rc.3(the newest clean prerelease; relations v2defineRelationsonly exists on the 1.0 line). All findings below verified by realtsc --noEmit(strict + erasableSyntaxOnly) and, where noted,drizzle-kit generateand 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 SAMEschema.server.tsbody, 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
$inferSelectpassed on all three:Userresolves to exactly{ id: number; email: string; name: string | null; active: boolean; createdAt: Date }regardless of dialect. Soactive(sqlite integer boolean-mode vs pg/mysql native boolean) andcreatedAt(sqlite integer timestamp vs pg/mysql native timestamp) all infer the sameboolean/Date. App code that consumes these types is portable.3. Reads, updates, deletes: portable as-is
The same code compiled against the three real
dbtypes (better-sqlite3, node-postgres, mysql2):db.select().from().where(eq()).orderBy(desc()),db.update().set().where(),db.delete().where(), and the relationaldb.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 portableinsertOne(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
tscexact-equality: integer PK infersid: number, uuid/text PK infersid: 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)
sqliteTableCreator((n) => n, 'snake_case')(and pg/mysql equivalents). The connection-configcasingwas removed in rc.3.manyrelations need explicit{ from, to }to typecheck; barer.many.x()errors.index()needs a name to typecheck (runtime auto-names). A column-derivingindex(...cols)helper recovers the anonymous call site and emits drizzle-kit's own table-qualified names.createdAtdefault:integer({ mode: 'timestamp_ms' }).notNull().defaultNow(). Footgun:defaultNow()emits milliseconds, so it MUST pair withtimestamp_ms, nevertimestamp(seconds), or dates land in year 58429.updatedAt:defaultNow().$onUpdate(() => new Date())(sqlite/pg, app-level) vs MySQL nativedefaultNow().onUpdateNow()(DB-enforced). The helper hides the difference.Proposed DX (to finalize)
Schema, relations, queries, actions, and inferred types stay portable. Switch DB = swap the active
columnsmodule + the connection driver, regenerate migrations.Open questions to finalize
insertOneergonomics + typing across the threedbtypes (generics vs per-dialect wrapper).--dbflag) and what it writes (whichcolumns.*becomescolumns.server.ts, which connection, whichdrizzle.config).str()length: pg/mysqlvarcharwants a length, sqlitetextdoes not. Default length, orstr({ length })?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.