--- status: draft last_updated: 2026-04-25 --- # DrizzleBox Architecture: Schema-First Multi-Dialect TypeBox/Drizzle Bridge ## Design Philosophy DrizzleBox bridges TypeBox and Drizzle ORM in both directions: 1. **Drizzle → TypeBox** (current): Given a Drizzle table definition, produce a TypeBox validation schema. This is what drizzlebox does today. 2. **TypeBox → Drizzle** (new): Define a schema once using TypeBox-based custom kinds, then generate Drizzle table definitions for any supported dialect. Plugin authors write schemas; hosts decide storage backend. The key insight is that both directions share the same **DbType IR** — a set of custom TypeBox kinds that carry both validation semantics and database metadata in one schema object. This is the hub-and-spoke pattern: the IR is the hub, dialects are the spokes, and translations go through the IR rather than directly between formats. ``` TypeBox Validation ↑ │ validate / infer types │ ┌─────────────────────────────────────────────────────────────────┐ │ DbType IR (the hub) │ │ Custom TypeBox Kinds: DbType:String, DbType:Integer, ... │ │ Each carries inner validation schema + db metadata │ └────────┬────────────────────────────────┬───────────────────────┘ │ │ │ toDrizzle(schema, 'sqlite') │ toDrizzle(schema, 'postgres') │ │ ▼ ▼ ┌───────────────────────────┐ ┌───────────────────────────┐ │ SQLite Transform Module │ │ PostgreSQL Transform Module│ │ @alkdev/drizzlebox/sqlite │ │ @alkdev/drizzlebox/pg │ │ (peerDep: drizzle-orm │ │ (peerDep: drizzle-orm │ │ sqlite-core only) │ │ pg-core only) │ └───────────────────────────┘ └───────────────────────────┘ ``` ### Principles 1. **Schema is source of truth** — validation and database structure derive from the same definition 2. **Compose, don't replace** — DbType kinds wrap inner TypeBox schemas, they don't reimplement validation 3. **Common options first, overrides only when needed** — `primaryKey`, `notNull`, `unique` are cross-dialect; dialect-specific options only appear when they diverge 4. **Tree-shakeable by default** — import only the dialect you need; don't bundle sqlite transforms if you only use postgres 5. **Extensible** — plugin authors can register custom column types and transform rules 6. **Bidirectional eventually** — the IR enables both Drizzle→TypeBox and TypeBox→Drizzle, but we start with TypeBox→Drizzle ## The DbType IR ### Custom Kind Pattern DbType uses TypeBox's `[Kind]` symbol as the dispatch key, following the established `TypeDef:*` namespace convention. Each DbType kind wraps an inner TypeBox schema and attaches structured database metadata: ```typescript import { Kind, TypeRegistry, TSchema, Static } from '@alkdev/typebox' // The core pattern: compose, don't replace export interface TDbColumn extends TSchema { [Kind]: string // e.g. 'DbType:String', 'DbType:Integer' static: Static // TypeScript infers from inner schema inner: TInner // The TypeBox validation schema columnName: string // Set by DbType.Table, not by individual columns db: DbColumnMeta // Database metadata (cross-dialect + overrides) } ``` All column kinds share the same `TDbColumn` interface. The `[Kind]` value distinguishes them at runtime — `'DbType:String'`, `'DbType:Integer'`, `'DbType:Boolean'`, etc. **Why wrap instead of replace?** TypeBox's built-in types carry rich validation metadata (`format`, `pattern`, `minLength`, `minimum`, `maximum`). DbType preserves all of this in `inner` while layering database semantics in `db`. A `DbType.VarChar(255)` wraps `Type.String({ maxLength: 255 })` — when you call `Value.Check(dbSchema, value)`, validation delegates to the inner schema. ### Metadata Structure ```typescript interface DbColumnMeta { // Cross-dialect options — apply to all dialects unless overridden primaryKey?: boolean notNull?: boolean unique?: boolean references?: DbReferences default?: DbDefault // Dialect-specific overrides — only set when they differ from the cross-dialect default sqlite?: SqliteColumnOpts postgres?: PgColumnOpts mysql?: MySqlColumnOpts } interface DbReferences { table: string column: string onDelete?: 'cascade' | 'set null' | 'restrict' | 'no action' onUpdate?: 'cascade' | 'set null' | 'restrict' | 'no action' } // Symbolic defaults — each dialect translates these to native SQL type DbDefault = | 'now' // SQLite: strftime, PG: now(), MySQL: NOW() | 'uuid' // SQLite: (lower(hex(randomblob(16)))), PG: gen_random_uuid() | 'autoincrement' // SQLite: INTEGER PRIMARY KEY, PG: SERIAL, MySQL: AUTO_INCREMENT | 'current_timestamp' // Alias for 'now' with timezone context | SQL // Raw SQL expression (drizzle-orm's sql tag) ``` The key design choice: **`primaryKey`, `notNull`, `unique`, and `references` are cross-dialect by default**. You only specify `sqlite` or `postgres` overrides when a dialect needs different treatment. This eliminates the duplication problem from the original storage.md design: ```typescript // BEFORE (storage.md — duplicated options) DbType.String({ sqlite: { primaryKey: true }, postgres: { primaryKey: true } }) // AFTER (this design — cross-dialect by default) DbType.String({ primaryKey: true }) ``` When a dialect-specific override is needed, it merges with and can override the cross-dialect defaults: ```typescript // JSON storage: SQLite uses text({ mode: 'json' }), PG uses jsonb() DbType.Array(DbType.String(), { mode: 'json' }) // The transform for 'json' mode knows to use the right dialect-specific type // No manual overrides needed for this case // When you DO need a dialect override: DbType.String({ format: 'uuid', postgres: { type: 'uuid' } }) // Default: text() everywhere, PG override: uuid() ``` ### DbDefault: Symbolic Defaults SQL default expressions are inherently dialect-specific. Rather than requiring users to write both `sql\`(strftime('%s', 'now'))\`` and `sql\`now()\``, we introduce symbolic defaults: | Symbol | SQLite | PostgreSQL | MySQL | |--------|--------|------------|-------| | `'now'` | `strftime('%s', 'now')` (as integer epoch) | `now()` (as timestamptz) | `NOW()` | | `'uuid'` | `lower(hex(randomblob(16)))` | `gen_random_uuid()` | `(UUID())` | | `'autoincrement'` | Implicit on `INTEGER PRIMARY KEY` | `SERIAL` type | `AUTO_INCREMENT` | | `'current_timestamp'` | `CURRENT_TIMESTAMP` | `CURRENT_TIMESTAMP` | `CURRENT_TIMESTAMP` | For cases not covered by symbolic defaults, raw SQL is available via the `sql` tag: ```typescript DbType.String({ default: sql\`(lower(hex(randomblob(4))))` }) ``` ### TDbTable Table definitions group columns and carry table-level options: ```typescript export interface TDbTable extends TSchema { [Kind]: 'DbType:Table' tableName: string columns: Record indexes?: TDbIndex[] constraints?: DbTableConstraints } export interface TDbIndex { name: string columns: string[] unique?: boolean } ``` ### DbTypeBuilder Following TypeBox's `Type` and TypeDef's `TypeDefBuilder` pattern, `DbTypeBuilder` provides factory methods: ```typescript class DbTypeBuilder { protected Create( kind: string, inner: TInner, opts: DbColumnOpts ): TDbColumn { const { sqlite, postgres, mysql, ...common } = opts return { [Kind]: kind, inner, columnName: '', // Set by Table() db: { ...common, ...(sqlite ? { sqlite } : {}), ...(postgres ? { postgres } : {}), ...(mysql ? { mysql } : {}), }, } } String(opts: DbColumnOpts & StringDbOpts = {}): TDbColumn { const { maxLength, format, ...dbOpts } = opts const inner = Type.String({ maxLength, format }) return this.Create('DbType:String', inner, dbOpts) } Integer(opts: DbColumnOpts = {}): TDbColumn { return this.Create('DbType:Integer', Type.Integer(), opts) } Boolean(opts: DbColumnOpts = {}): TDbColumn { return this.Create('DbType:Boolean', Type.Boolean(), opts) } Timestamp(opts: DbColumnOpts & TimestampDbOpts = {}): TDbColumn { // Stored as Unix epoch seconds (number), validated as number return this.Create('DbType:Timestamp', Type.Number(), opts) } Array(items: T, opts: DbColumnOpts & { mode: 'json' } = {}): TDbColumn> { return this.Create('DbType:Array', Type.Array(items), opts) } Object(properties: T, opts: DbColumnOpts & { mode: 'json' } = {}): TDbColumn> { return this.Create('DbType:Object', Type.Object(properties), opts) } Record(values: V, opts: DbColumnOpts & { mode: 'json' } = {}): TDbColumn> { return this.Create('DbType:Record', Type.Record(Type.String(), values), opts) } Any(opts: DbColumnOpts & { mode: 'json' } = {}): TDbColumn { return this.Create('DbType:Any', Type.Unknown(), opts) } Enum(values: [...T], opts: DbColumnOpts = {}): TDbColumn[]>> { const inner = Type.Union(values.map(v => Type.Literal(v))) return this.Create('DbType:Enum', inner, { ...opts, enumValues: values }) } VarChar(maxLength: number, opts: DbColumnOpts = {}): TDbColumn { return this.Create('DbType:VarChar', Type.String({ maxLength }), opts) } Uuid(opts: DbColumnOpts = {}): TDbColumn { return this.Create('DbType:Uuid', Type.String({ format: 'uuid' }), opts) } /** Mark a column as optional (nullable in DB, excluded from insert schema) */ Optional(column: T): T { return { ...column, [TypeBox.Optional]: true } as T } Table(name: string, columns: Record, opts?: DbTableOpts): TDbTable { const namedColumns: Record = {} for (const [key, col] of Object.entries(columns)) { namedColumns[key] = { ...col, columnName: key } } return { [Kind]: 'DbType:Table', tableName: name, columns: namedColumns, indexes: opts?.indexes, constraints: opts?.constraints, } } } export const DbType = new DbTypeBuilder() ``` ### Kind Registration DbType kinds register with TypeBox's TypeRegistry so that `Value.Check()` and `Value.Parse()` work on DbType schemas: ```typescript // Delegate validation to inner schema TypeRegistry.Set('DbType:String', (schema, value) => Value.Check(schema.inner, value)) TypeRegistry.Set('DbType:Integer', (schema, value) => Value.Check(schema.inner, value)) TypeRegistry.Set('DbType:Boolean', (schema, value) => Value.Check(schema.inner, value)) TypeRegistry.Set('DbType:Timestamp', (schema, value) => Value.Check(schema.inner, value)) TypeRegistry.Set('DbType:Array', (schema, value) => Value.Check(schema.inner, value)) TypeRegistry.Set('DbType:Object', (schema, value) => Value.Check(schema.inner, value)) TypeRegistry.Set('DbType:Record', (schema, value) => Value.Check(schema.inner, value)) TypeRegistry.Set('DbType:Any', (schema, value) => Value.Check(schema.inner, value)) TypeRegistry.Set('DbType:Enum', (schema, value) => Value.Check(schema.inner, value)) TypeRegistry.Set('DbType:VarChar', (schema, value) => Value.Check(schema.inner, value)) TypeRegistry.Set('DbType:Uuid', (schema, value) => Value.Check(schema.inner, value)) // TDbTable validates each column TypeRegistry.Set('DbType:Table', (schema, value) => { return Object.entries(schema.columns).every( ([key, col]) => Value.Check(col, value[key]) ) }) ``` ### TypeGuard A `DbGuard` namespace validates the structure of DbType schema objects (not values, but the schemas themselves): ```typescript export namespace DbGuard { export function TDbColumn(schema: unknown): schema is TDbColumn { return IsObject(schema) && Kind in schema && typeof schema[Kind] === 'string' && (schema[Kind] as string).startsWith('DbType:') && IsObject(schema['db']) && TypeGuard.TSchema(schema['inner']) } export function TDbTable(schema: unknown): schema is TDbTable { return IsObject(schema) && schema[Kind] === 'DbType:Table' && typeof schema['tableName'] === 'string' && IsObject(schema['columns']) } // ... specific Kind guards } ``` ## Dialect Transforms ### Module Structure (Tree-Shakeable) ``` @alkdev/drizzlebox/ src/ index.ts # DbType IR, builder, guard, registry dbtype/ types.ts # TDbColumn, TDbTable, DbColumnMeta interfaces builder.ts # DbTypeBuilder class guard.ts # DbGuard namespace registry.ts # Kind registration with TypeRegistry defaults.ts # Symbolic default translations common.ts # Common column definitions (id, createdAt, updatedAt) sqlite/ index.ts # Public API for SQLite dialect transform.ts # Transform registry rules columns.ts # Column mapping functions pg/ index.ts # Public API for PostgreSQL dialect transform.ts # Transform registry rules columns.ts # Column mapping functions mysql/ # Future index.ts transform.ts columns.ts drizzle/ # Future: Drizzle → DbType direction index.ts from-column.ts # Introspect Drizzle columns into DbType IR ``` Package exports for tree-shaking: ```json { "exports": { ".": { "import": "./index.mjs", "require": "./index.cjs" }, "./sqlite": { "import": "./sqlite.mjs", "require": "./sqlite.cjs", "peerDependencies": { "drizzle-orm": ">=0.36.0" } }, "./pg": { "import": "./pg.mjs", "require": "./pg.cjs", "peerDependencies": { "drizzle-orm": ">=0.36.0" } }, "./common": { "import": "./common.mjs", "require": "./common.cjs" } }, "peerDependencies": { "@alkdev/typebox": ">=0.34.49" } } ``` The core package (`@alkdev/drizzlebox`) depends only on `@alkdev/typebox`. The dialect modules (`/sqlite`, `/pg`) have `drizzle-orm` as a peer dependency. Users who only use SQLite never import PG transforms. ### Usage ```typescript import { DbType } from '@alkdev/drizzlebox' import { toSqlite } from '@alkdev/drizzlebox/sqlite' // Only imports sqlite-core from drizzle-orm const UserSchema = DbType.Table('users', { id: DbType.Uuid({ primaryKey: true, default: 'uuid' }), name: DbType.String({ notNull: true }), email: DbType.String({ notNull: true, format: 'email' }), scopes: DbType.Array(DbType.String(), { mode: 'json' }), createdAt: DbType.Timestamp({ notNull: true, default: 'now' }), }) // Generate Drizzle SQLite table const users = toSqlite(UserSchema) // Equivalent to: sqliteTable('users', { id: text('id').primaryKey().$defaultFn(genRandomUUID), ... }) ``` ```typescript import { DbType } from '@alkdev/drizzlebox' import { toPg } from '@alkdev/drizzlebox/pg' // Only imports pg-core from drizzle-orm const users = toPg(UserSchema) // Equivalent to: pgTable('users', { id: uuid('id').primaryKey().defaultRandom(), ... }) ``` ### Transform Registry Each dialect module uses a priority-sorted rule registry to map DbType kinds to Drizzle column builders: ```typescript // sqlite/transform.ts import { TransformRegistry } from '../dbtype/registry.ts' interface TransformContext { dialect: 'sqlite' | 'postgres' | 'mysql' ancestors: TDbColumn[] metadata: Record } type ColumnTransformResult = DrizzleColumnBuilder // from drizzle-orm interface TransformRule { name: string match: (schema: TDbColumn, ctx: TransformContext) => boolean transform: (schema: TDbColumn, ctx: TransformContext) => ColumnTransformResult priority: number // Lower = higher priority } const sqliteTransforms = new TransformRegistry() sqliteTransforms.register({ name: 'sqlite-string', priority: 0, match: (col) => col[Kind] === 'DbType:String', transform: (col, ctx) => { const db = col.db const opts = resolveOpts(db, 'sqlite') let builder = sqliteText(col.columnName) if (opts.primaryKey) builder = builder.primaryKey() if (opts.notNull) builder = builder.notNull() if (opts.unique) builder = builder.unique() if (opts.default !== undefined) builder = applyDefault(builder, opts.default, 'sqlite') return builder }, }) sqliteTransforms.register({ name: 'sqlite-uuid', priority: -1, // Higher priority than generic string match: (col) => col[Kind] === 'DbType:Uuid', transform: (col, ctx) => { const db = col.db const opts = resolveOpts(db, 'sqlite') let builder = sqliteText(col.columnName) if (opts.primaryKey) builder = builder.primaryKey() if (opts.notNull) builder = builder.notNull() if (opts.default === 'uuid') { builder = builder.$defaultFn(() => crypto.randomUUID()) } return builder }, }) sqliteTransforms.register({ name: 'sqlite-boolean', priority: 0, match: (col) => col[Kind] === 'DbType:Boolean', transform: (col, ctx) => { const opts = resolveOpts(col.db, 'sqlite') let builder = sqliteInteger(col.columnName, { mode: 'boolean' }) if (opts.notNull) builder = builder.notNull() if (opts.default !== undefined) builder = applyDefault(builder, opts.default, 'sqlite') return builder }, }) sqliteTransforms.register({ name: 'sqlite-timestamp', priority: 0, match: (col) => col[Kind] === 'DbType:Timestamp', transform: (col, ctx) => { const opts = resolveOpts(col.db, 'sqlite') let builder = sqliteInteger(col.columnName, { mode: 'timestamp' }) if (opts.notNull) builder = builder.notNull() if (opts.default) builder = applyDefault(builder, opts.default, 'sqlite') return builder }, }) sqliteTransforms.register({ name: 'sqlite-json', priority: -1, // Higher priority than string/array/object match: (col) => col[Kind] === 'DbType:Array' || col[Kind] === 'DbType:Object' || col[Kind] === 'DbType:Record' || col[Kind] === 'DbType:Any', transform: (col, ctx) => { const opts = resolveOpts(col.db, 'sqlite') let builder = sqliteText(col.columnName, { mode: 'json' }) if (opts.notNull) builder = builder.notNull() if (opts.default !== undefined) builder = applyDefault(builder, opts.default, 'sqlite') return builder }, }) ``` ```typescript // pg/transform.ts — analogous but using pg-core builders const pgTransforms = new TransformRegistry() pgTransforms.register({ name: 'pg-uuid', priority: -1, match: (col) => col[Kind] === 'DbType:Uuid', transform: (col, ctx) => { const opts = resolveOpts(col.db, 'postgres') let builder = pgUuid(col.columnName) if (opts.primaryKey) builder = builder.primaryKey() if (opts.notNull) builder = builder.notNull() if (opts.default === 'uuid') builder = builder.defaultRandom() return builder }, }) pgTransforms.register({ name: 'pg-jsonb', priority: -1, match: (col) => ['DbType:Array', 'DbType:Object', 'DbType:Record', 'DbType:Any'].includes(col[Kind]), transform: (col, ctx) => { const opts = resolveOpts(col.db, 'postgres') let builder = pgJsonb(col.columnName) if (opts.notNull) builder = builder.notNull() if (opts.default !== undefined) builder = applyDefault(builder, opts.default, 'postgres') return builder }, }) pgTransforms.register({ name: 'pg-timestamp', priority: 0, match: (col) => col[Kind] === 'DbType:Timestamp', transform: (col, ctx) => { const opts = resolveOpts(col.db, 'postgres') let builder = pgTimestamptz(col.columnName, { withTimezone: true }) if (opts.notNull) builder = builder.notNull() if (opts.default === 'now') builder = builder.default(sql`now()`) return builder }, }) ``` ### Option Resolution The `resolveOpts` function merges cross-dialect options with dialect-specific overrides: ```typescript function resolveOpts(db: DbColumnMeta, dialect: 'sqlite' | 'postgres' | 'mysql'): ResolvedColumnOpts { const dialectOpts = db[dialect] ?? {} return { primaryKey: dialectOpts.primaryKey ?? db.primaryKey, notNull: dialectOpts.notNull ?? db.notNull, unique: dialectOpts.unique ?? db.unique, references: dialectOpts.references ?? db.references, default: dialectOpts.default ?? db.default, ...dialectOpts, // Any dialect-specific extras } } ``` ### Symbolic Default Resolution ```typescript function applyDefault( builder: ColumnBuilder, defaultVal: DbDefault | unknown, dialect: 'sqlite' | 'postgres' | 'mysql' ): ColumnBuilder { if (typeof defaultVal === 'string') { switch (defaultVal) { case 'now': return dialect === 'sqlite' ? builder.default(sql`(strftime('%s', 'now'))`) : dialect === 'postgres' ? builder.default(sql`now()`) : builder.default(sql`NOW()`) case 'uuid': return dialect === 'sqlite' ? builder.$defaultFn(() => crypto.randomUUID()) : dialect === 'postgres' ? builder.defaultRandom() : builder.$defaultFn(() => crypto.randomUUID()) case 'autoincrement': // Handled differently per dialect — usually implicit in primaryKey return builder case 'current_timestamp': return builder.default(sql`CURRENT_TIMESTAMP`) } } // SQL expression or literal value if (defaultVal instanceof SQL) return builder.default(defaultVal) return builder.default(defaultVal) } ``` ## Type Mapping Table | DbType Kind | SQLite Column | PG Column | MySQL Column | Inner TypeBox | |-------------|---------------|-----------|--------------|---------------| | `DbType:String` | `text()` | `text()` | `text()` | `Type.String()` | | `DbType:Uuid` | `text()` | `uuid()` | `varchar(36)` | `Type.String({ format: 'uuid' })` | | `DbType:VarChar` | `text()` | `varchar(n)` | `varchar(n)` | `Type.String({ maxLength: n })` | | `DbType:Integer` | `integer()` | `integer()` | `int()` | `Type.Integer()` | | `DbType:Boolean` | `integer({ mode: 'boolean' })` | `boolean()` | `boolean()` | `Type.Boolean()` | | `DbType:Timestamp` | `integer({ mode: 'timestamp' })` | `timestamptz()` | `timestamp()` | `Type.Number()` | | `DbType:Number` | `real()` | `double precision()` | `double()` | `Type.Number()` | | `DbType:Array` (mode: 'json') | `text({ mode: 'json' })` | `jsonb()` | `json()` | `Type.Array(T)` | | `DbType:Object` (mode: 'json') | `text({ mode: 'json' })` | `jsonb()` | `json()` | `Type.Object(T)` | | `DbType:Record` (mode: 'json') | `text({ mode: 'json' })` | `jsonb()` | `json()` | `Type.Record(T)` | | `DbType:Any` (mode: 'json') | `text({ mode: 'json' })` | `jsonb()` | `json()` | `Type.Unknown()` | | `DbType:Enum` | `text({ enum: [...] })` | `pgEnum()()` or `text()` | `mysqlEnum()()` | `Type.Union([...Type.Literal()])` | | `DbType:Real` | `real()` | `real()` | `float()` | `Type.Number()` | ### Notes on Specific Mappings **UUID**: SQLite has no native UUID type. We use `text()` with a JS-side `$defaultFn` for UUID generation. PG gets the native `uuid()` type with `.defaultRandom()`. This is a case where the DbType kind (`DbType:Uuid`) maps to entirely different column types per dialect. **Timestamp**: SQLite stores as integer epoch seconds, PG as `timestamptz`. The symbolic default `'now'` resolves to `strftime('%s', 'now')` for SQLite (returning a Unix epoch integer) and `now()` for PG (returning a timestamptz). This is the other case where dialect divergence is hidden behind a single DbType kind. **JSON**: All compound types (`Array`, `Object`, `Record`, `Any`) with `mode: 'json'` map to `text({ mode: 'json' })` in SQLite, `jsonb()` in PG, and `json()` in MySQL. The transform registry picks the right one based on dialect. **Enum**: This is the most problematic mapping. PG requires `pgEnum()` at module scope (a separate type declaration), while SQLite uses `text({ enum: [...] })` and MySQL uses `mysqlEnum()()`. See Open Question #1. ## Common Columns ```typescript // dbtype/common.ts export const commonCols = { id: DbType.Uuid({ primaryKey: true, default: 'uuid' }), createdAt: DbType.Timestamp({ notNull: true, default: 'now' }), updatedAt: DbType.Timestamp({ notNull: true, default: 'now' }), } // Usage: const UserSchema = DbType.Table('users', { ...commonCols, name: DbType.String({ notNull: true }), email: DbType.String({ notNull: true, format: 'email' }), }) ``` Compare to the storage.md version: ```typescript // BEFORE — repeated dialect config for identical behavior createdAt: DbType.Timestamp({ sqlite: { notNull: true, default: sql`(strftime('%s', 'now'))` }, postgres: { notNull: true, default: sql`now()` } }), // AFTER — cross-dialect defaults + symbolic default createdAt: DbType.Timestamp({ notNull: true, default: 'now' }), ``` ## Validation Schemas from DbType Because DbType schemas carry `inner` TypeBox schemas, extracting validation schemas is straightforward: ```typescript export function createSelectSchema(table: TDbTable): TObject { const properties: Record = {} for (const [name, col] of Object.entries(table.columns)) { properties[name] = col[Kind] === 'DbType:Table' ? col.inner // Unwrap to get the inner TypeBox schema : col.inner } return Type.Object(properties) } export function createInsertSchema(table: TDbTable): TObject { const properties: Record = {} for (const [name, col] of Object.entries(table.columns)) { if (col.db.primaryKey && col.db.default === 'autoincrement') continue // Skip auto-increment PKs let schema = col.inner if (isOptional(col)) schema = Type.Optional(schema) if (!col.db.notNull) schema = Type.Optional(Type.Union([schema, Type.Null()])) properties[name] = schema } return Type.Object(properties) } ``` This means plugins define schemas once and get both validation and Drizzle table generation from the same source. ## Bidirectional Support (Future) The IR design enables both directions: ``` Drizzle Column ──→ fromDrizzle(column) ──→ DbType IR ──→ toDrizzle(schema, dialect) │ └──→ inner ──→ TypeBox validation schema ``` `fromDrizzle()` would introspect a Drizzle column and produce a `TDbColumn` with populated `db` metadata and an inferred `inner` TypeBox schema. This would be a straightforward mapping since Drizzle columns carry all the metadata we need (`dataType`, `columnType`, `notNull`, `hasDefault`, `enumValues`, etc.). The current `columnToSchema()` in drizzlebox already does the column→TypeBox part. The enhancement would be wrapping the result in a `TDbColumn` with the `db` metadata preserved. ## Nullability Convention Following the storage.md convention but simplified: - `DbType.Optional(column)` — nullable in DB, excluded from insert schema - `{ notNull: true }` — required (non-nullable) in DB, included in insert schema - Neither — technically nullable, but prefer explicit `Optional()` or `notNull: true` ## Open Questions ### 1. PostgreSQL Enum Handling **Problem**: PG requires `pgEnum()` at module scope before tables can reference it: ```typescript // PG requires this: const moodEnum = pgEnum('mood', ['happy', 'sad', 'neutral']) const users = pgTable('users', { mood: moodEnum('mood') }) // vs. SQLite: const users = sqliteTable('users', { mood: text('mood', { enum: ['happy', 'sad', 'neutral'] }) }) ``` **Options**: **A. Generate enum declarations separately**: `toPg(schema)` returns both enum declarations and table definitions: ```typescript const { enums, tables } = toPg(UserSchema) // enums: { mood: pgEnum('mood', ['happy', 'sad', 'neutral']) } // tables: { users: pgTable('users', { mood: moodEnum('mood') }) } ``` **B. Start with `text()` for all dialects**: Don't generate native PG enums initially. Use `text()` with a check constraint or validation-only enum. Add `pgEnum` support as an explicit opt-in later. **C. Per-column opt-in**: `DbType.Enum({ values: [...], postgres: { nativeEnum: true } })` — only generates `pgEnum` when explicitly requested. **Current leaning**: **B** — start simple, add native enum support later as an opt-in feature. This avoids structural differences in the output between dialects. ### 2. Mode Inference vs. Explicit Annotation **Problem**: Should `DbType.Object({...})` without `mode: 'json'` automatically infer `mode: 'json'` for storage? **Options**: **A. Require explicit mode**: All compound types must specify `mode: 'json'`. More verbose but unambiguous. **B. Auto-infer**: `DbType.Array()`, `DbType.Object()`, `DbType.Record()`, `DbType.Any()` automatically infer `mode: 'json'` since there's no other reasonable storage mode for compound types in relational databases. **Current leaning**: **B** — auto-infer `mode: 'json'` for compound types that must be stored as JSON. This matches the storage.md proposal. Scalar columns that happen to store JSON (like a string column holding JSON data) would need explicit annotation. ### 3. The `inner` Schema: Who Constructs It? **Problem**: Should the DbTypeBuilder auto-infer the TypeBox inner schema from the column type, or should users provide it explicitly? **Options**: **A. Auto-infer**: `DbType.String({ notNull: true })` automatically creates `Type.String()` as inner. Users can override with explicit `inner` if they want validation constraints: ```typescript DbType.String({ notNull: true }) // inner = Type.String() DbType.String({ notNull: true, inner: Type.String({ format: 'email', maxLength: 255 }) }) ``` **B. Always explicit**: Users always provide the inner schema: ```typescript DbType.String(Type.String({ format: 'email' }), { notNull: true }) ``` **C. Builder methods with validation sugar**: Builder methods that set both inner and db metadata: ```typescript DbType.Email() // inner = Type.String({ format: 'email' }), [Kind] = 'DbType:String' DbType.Uuid() // inner = Type.String({ format: 'uuid' }), [Kind] = 'DbType:Uuid' ``` **Current leaning**: **A+C** — auto-infer by default, with convenience builder methods for common patterns. The `inner` field is an escape hatch for custom validation constraints. ### 4. Dialect-Specific Types That Don't Map Cleanly **Problem**: Some PG types have no SQLite equivalent (geometric types, `inet`, `cidr`, `macaddr`, array types). Some SQLite modes have no PG equivalent (`blob({ mode: 'bigint' })`). **Options**: **A. Dialect-specific Kind escapes**: `DbType.PgGeometry()`, `DbType.SqliteBlob()` — kinds that only work in one dialect, fail in others. **B. Common abstraction where possible, escape hatches otherwise**: `DbType.Array(inner, { mode: 'json' })` works everywhere (stores as JSON). PG-native arrays via `{ postgres: { nativeArray: true } }` override. **C. Only support the common subset**: Don't generate dialect-specific types from DbType at all. Users write raw Drizzle for those columns. **Current leaning**: **B** — start with the common subset, provide dialect-specific overrides for escape hatches. The `postgres` and `sqlite` options bags exist for this reason. ### 5. Should `toDrizzle` Return Table Objects or Builder Callbacks? **Problem**: Drizzle tables are typically created with a callback that receives column builders: ```typescript sqliteTable('users', (t) => ({ id: t.integer().primaryKey(), name: t.text() })) ``` But our transform produces column-by-column. Should we produce: **A. Table object directly**: Return the result of `sqliteTable(name, columns)` — simpler, but the callback pattern gives access to `t` for dialect-specific features not expressible in DbType. **B. Column definitions only**: Return just the columns record, let users call `sqliteTable(name, columns)` themselves — more flexible but more verbose. **C. Table object with extra config callback**: Return the table but accept an `extraConfig` callback for indexes, unique constraints, etc. **Current leaning**: **C** — return the table object, handle indexes from `TDbTable.indexes`. For extra config not expressible in DbType, users can use the table extra config pattern separately. The `toDrizzle` function should handle the common 90%. ### 6. Default Value Types: Symbolic vs Raw **Problem**: The `DbDefault` type currently supports a fixed set of symbolic strings plus raw SQL. What about: - Literal defaults: `{ default: 0 }` or `{ default: '' }` - JS-side defaults: `{ default: () => crypto.randomUUID() }` - The difference between SQL defaults and JS-side defaults (Drizzle's `.default()` vs `.$defaultFn()`) **Current approach**: Symbolic strings for common patterns, `sql` tagged template for SQL expressions, literal values for simple cases. The transform layer decides `.default()` vs `.$defaultFn()` based on the dialect and symbol. **Open question**: Should we also support a function form for JS-side defaults? ```typescript DbType.String({ default: 'uuid', // Symbolic — transform decides implementation // vs. default: () => crypto.randomUUID(), // JS-side — always uses $defaultFn }) ``` **Current leaning**: Support both. Symbolic defaults are translated to the appropriate mechanism per dialect. JS function defaults always use `$defaultFn`. Raw SQL uses `.default(sql\`...\`)`. ### 7. Relation Definitions **Problem**: Foreign keys work via column config `references`, but complex relations (many-to-many, join tables) need explicit relation definitions. Should these be part of `DbType.Table` or separate? **Status**: Deferred, same as storage.md. `references` on column config covers the common case. Complex relations can be added later via `TDbRelation` or through Drizzle's relation API directly. ### 8. Migration Generation **Problem**: When should migrations be generated — at build time or at runtime? **Status**: Same as storage.md — build-time for now via `drizzle-kit`. The DbType schema → Drizzle table → `drizzle-kit generate` pipeline. Dynamic plugin registration is a future concern. ### 9. Current drizzlebox Direction: Keep, Evolve, or Replace? **Problem**: The current `drizzlebox` does Drizzle → TypeBox (generating validation schemas from existing Drizzle tables). The new DbType IR does TypeBox → Drizzle (generating Drizzle tables from TypeBox-based schemas). These are opposite directions. **Options**: **A. Keep both directions in one package**: The current `columnToSchema()` becomes `fromDrizzle()`, the new transform registry becomes `toDrizzle()`. Both use the same DbType IR as intermediate. Package exports both directions. **B. Keep current direction, add new as separate sub-package**: The current `@alkdev/drizzlebox` continues as-is. The new TypeBox→Drizzle direction lives in `@alkdev/drizzlebox/schema` or a separate package. **C. Replace current direction eventually**: Phase out the current Drizzle→TypeBox in favor of the schema-first direction. Users define DbType schemas, get both validation and Drizzle for free. No need to reverse-engineer schemas from Drizzle. **Current leaning**: **A** — keep both. The Drizzle→TypeBox direction is useful for existing Drizzle users who want validation without rewriting their schemas. The TypeBox→Drizzle direction is for the schema-first use case. They coexist using the same IR as a bridge. The `fromDrizzle(column)` function introspects an existing Drizzle column and produces a `TDbColumn` with the inner TypeBox schema and `db` metadata. ### 10. Naming Boundaries **Question**: The current package is `@alkdev/drizzlebox` with a focus on TypeBox↔Drizzle. The new DbType IR is more general — it could be its own package. Should: - The DbType IR live in `@alkdev/drizzlebox` (keeping everything together)? - The DbType IR be a separate `@alkdev/dbtype` package that drizzlebox depends on? - The DbType IR live in `@alkdev/typebox` as an extension (since it uses TypeBox's Kind/TypeRegistry)? **Current leaning**: Keep in `@alkdev/drizzlebox` for now. The DbType IR's sole purpose is bridging TypeBox and Drizzle. If it becomes useful outside that context, we can factor it out later. The package name `drizzlebox` is already ambiguous enough to encompass both directions. ## Example: Full Table Definition ```typescript import { DbType } from '@alkdev/drizzlebox' import { toSqlite } from '@alkdev/drizzlebox/sqlite' import { toPg } from '@alkdev/drizzlebox/pg' const IdentitySchema = DbType.Table('identities', { id: DbType.Uuid({ primaryKey: true, default: 'uuid' }), keyHash: DbType.String({ notNull: true, unique: true }), ownerId: DbType.String({ notNull: true }), type: DbType.Enum(['api_key', 'node_identity'], { notNull: true }), scopes: DbType.Array(DbType.String(), { notNull: true }), roles: DbType.Optional(DbType.Array(DbType.String())), resources: DbType.Optional(DbType.Record(DbType.Array(DbType.String()))), name: DbType.Optional(DbType.String()), enabled: DbType.Boolean({ default: true }), createdAt: DbType.Timestamp({ notNull: true, default: 'now' }), lastUsedAt: DbType.Optional(DbType.Timestamp()), revokedAt: DbType.Optional(DbType.Timestamp()), }, { indexes: [ { name: 'idx_identities_owner', columns: ['ownerId'] }, { name: 'idx_identities_type', columns: ['type'] }, ], }) // Generate for SQLite const sqliteIdentities = toSqlite(IdentitySchema) // Generate for PostgreSQL const pgIdentities = toPg(IdentitySchema) // Validation (extract inner TypeBox schemas) import { createSelectSchema, createInsertSchema } from '@alkdev/drizzlebox' const SelectIdentity = createSelectSchema(IdentitySchema) const InsertIdentity = createInsertSchema(IdentitySchema) ``` Compare with the storage.md version: - No per-dialect config for `primaryKey`, `notNull`, `unique`, `references` — same meaning everywhere - Default values use symbolic `'now'` and `'uuid'` instead of dialect-specific SQL - `mode: 'json'` is auto-inferred for `Array`, `Record`, `Object` - Dialect overrides are only needed when types actually differ (e.g., native UUID in PG) ## Benefits | Benefit | Description | |---------|-------------| | Single source of truth | Schema defined once, used for both validation and DB structure | | No duplication | Cross-dialect options specified once, not per-dialect | | Tree-shakeable | Import only the dialect you need | | Type safety | TypeScript types from same schema as DB | | Validation built-in | TypeBox schemas work for request/response validation | | Extensible | Custom column kinds via TypeRegistry | | Symbolic defaults | Common patterns like `'now'` and `'uuid'` translate automatically | | Bidirectional (future) | Same IR supports Drizzle→TypeBox and TypeBox→Drizzle |