ADR-015: <table> accepts extraConfig prop mirroring Drizzle's third callback argument for composite unique constraints and other column-reference-based features. ADR-016: Adapter accepts Type.Module compiled bundle (not individual schemas). M.Import() handles ref resolution automatically, eliminating potential complications with separate schema wiring. Update FromDbTypeConfig interface to use module instead of schema, update element types table with extraConfig prop.
7.2 KiB
status, last_updated
| status | last_updated |
|---|---|
| stable | 2026-05-23 |
Repo Adapter: from-dbtype for @alkdev/operations
How dbtype schemas produce CRUD OperationSpecs for the operations registry.
Overview
The from-dbtype adapter consumes the same element tree and Type.Module bundle that produces Drizzle tables and validation schemas, and generates a complete set of CRUD OperationSpecs: findMany, findFirst, insertOne, insert, update, delete.
This is phase 2 of the project. Phase 1 delivers the core schema, module, and hosts. This document defines the interface and design so phase 1 doesn't paint us into a corner.
Interface
interface FromDbTypeConfig {
namespace: string // Operation namespace (e.g., 'db')
db: AnyDrizzleDB // Drizzle database instance
tables: Record<string, UElement> // UJSX table elements
host: HostConfig<...> // Rendered dialect host
module: TModule // Compiled Type.Module bundle (accessed via M.Import())
operations?: Record<string, OperationSelection> // Per-table operation selection
accessControl?: Record<string, AccessControlMap> // Per-table access control
}
type OperationSelection = true | ('findMany' | 'findFirst' | 'insertOne' | 'insert' | 'update' | 'delete')[]
type AccessControlMap = Record<string, AccessControl> // operation name -> access control
Returns OperationSpec[] ready to register with OperationRegistry.
Schema Derivation
Select Schema
The module entry as-is, with nullable columns wrapped in Type.Union([inner, Type.Null()]).
Insert Schema
Derived from column metadata:
- Remove auto-generated primary keys (
primaryKey: truewithdefault) - Make columns with defaults
Type.Optional - Make nullable columns
Type.Optional(Type.Union([inner, Type.Null()])) - Keep required not-null columns mandatory
Update Schema
Type.Partial(Type.Ref('TableName')) — all columns optional.
Filter Schema
Per-column comparison operators derived from DbColumnType:
| Column Type | Available Operators |
|---|---|
uuid, string |
eq, ne, like, notLike, ilike, inArray, notInArray, isNull, isNotNull |
integer, timestamp |
eq, ne, lt, lte, gt, gte, inArray, notInArray, isNull, isNotNull |
boolean |
eq, ne, isNull, isNotNull |
json, array, object |
eq, isNull, isNotNull |
function generateFilterSchema(tableMeta: TableMeta): TObject {
const filterProps: Record<string, any> = {}
for (const [name, col] of Object.entries(tableMeta.columns)) {
filterProps[name] = Type.Optional(generateColumnFilter(col))
}
return Type.Object(filterProps)
}
Handler Generation
Each operation's handler uses the rendered Drizzle table and the Drizzle query builder:
| Operation | Handler Pattern |
|---|---|
findMany |
db.query[tableName].findMany({ where, orderBy, limit, offset, with }) |
findFirst |
db.query[tableName].findFirst({ where, orderBy, offset, with }) |
insertOne |
db.insert(table).values(input).returning() |
insert |
db.insert(table).values(input.values).returning() |
update |
db.update(table).set(input.set).where(filterToWhere(input.where)).returning() |
delete |
db.delete(table).where(filterToWhere(input.where)).returning() |
filterToWhere() translates the TypeBox-validated filter object into Drizzle SQL operators (eq(), and(), or(), like(), etc.).
Access Control
The adapter accepts per-table, per-operation AccessControl specs that map directly to OperationSpec.accessControl:
accessControl: {
users: {
findMany: { requiredScopes: ['users:read'] },
insertOne: { requiredScopes: ['users:write'] },
update: { requiredScopes: ['users:write'] },
delete: { requiredScopes: ['users:admin'] },
}
}
Operations called through buildEnv() are trusted: true — internal composition skips access control.
Relations
The module's relation entries (UsersRelations, TasksRelations) drive the with parameter in findMany and findFirst. The adapter:
- Reads relation entries from the module
- Generates the Drizzle
relations()calls from the rendered table objects - Passes the full
{ tables, relations }schema to the Drizzle relational query builder - Maps
withparameter types from the module to the DrizzlewithAPI
Overrides
Individual operations can be overridden:
overrides: {
users: {
delete: async (input, ctx) => {
// Soft delete instead of hard delete
return ctx.env.db.update(usersTable)
.set({ deletedAt: new Date() })
.where(eq(usersTable.id, input.where.id))
}
}
}
Overrides replace the auto-generated handler but keep the auto-generated OperationSpec (input/output schemas, access control).
Constraints
- Phase 2 concern — this adapter is not part of phase 1. Phase 1 delivers the schema, module, and hosts.
- The adapter depends on both the element tree and the rendered Drizzle objects — it needs the tree for schema derivation and the rendered tables for query execution
- Filter operators are column-type-dependent — a
uuidcolumn gets different operators than abooleancolumn - The
withparameter for relations requires the Drizzlerelationsobject — the adapter generates relations from module entries and rendered table objects (ADR-013) - Returning clauses use graceful fallback — all mutations call
.returning(). The adapter checks the dialect string from the host config (root.ctx.dialect) to determine whether to include.returning(). Dialects that support it (PG, SQLite) return full result rows. Dialects that don't (MySQL) fall back to validated input (inserts) or affected row count (updates/deletes). This keeps the operations API uniform. See ADR-012. - Relations are generated by the adapter, not the host — the adapter reads
FooRelationsentries from the module and generates Drizzlerelations()calls using the rendered table objects. No<relation>element type is needed. See ADR-013.
Open Questions
-
Per-dialect handler differences?Resolved — ADR-012. Always use.returning()with graceful fallback. Dialects that support it return data; dialects that don't fall back to validated input or affected row count. -
Schema bundle or separate schemas?Resolved — ADR-016. The adapter accepts theType.Modulecompiled bundle.M.Import()handles ref resolution automatically, which eliminates potential complications with separate schema wiring. Consistent with ADR-010 (derived schemas in the module). -
Relation rendering responsibility?Resolved — ADR-013. The adapter generates relations from module entries and rendered table objects. No new<relation>element type is needed.
References
- Operations registry:
@alkdev/operations—OperationSpec,OperationRegistry,OperationHandler - Drizzle-GraphQL reference:
/workspace/drizzle-graphql— CRUD generator pattern - Research:
docs/research/architecture.md(validation schemas section)