Files
dbtype/docs/architecture/repo-adapter.md
glm-5.1 98764086f4 docs: resolve all open architecture questions with ADRs 009-014
Resolve OQ-01 through OQ-10 with formal ADRs and update all architecture
docs to reference decisions. Add two new tracked questions (OQ-11, OQ-12)
surfaced during review.

ADR-009: Keep FooRelations naming convention for relation entries
ADR-010: One module per database, include derived schemas by default
ADR-011: Support JSX/TSX as ergonomic authoring layer
ADR-012: Always .returning() with graceful fallback per dialect
ADR-013: Adapter generates relations from module entries (no <relation> element)
ADR-014: Leverage drizzle-kit for migrations, no native migration generator

Also upgrades elements.md, hosts.md, repo-adapter.md status to stable,
clarifies OQ-06 as design clarification, and specifies MySQL .returning()
detection mechanism in ADR-012.
2026-05-23 12:47:55 +00:00

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
  schema?: Record<string, TObject>              // Optional: Type.Module entries for select/insert/update
  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: true with default)
  • 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:

  1. Reads relation entries from the module
  2. Generates the Drizzle relations() calls from the rendered table objects
  3. Passes the full { tables, relations } schema to the Drizzle relational query builder
  4. Maps with parameter types from the module to the Drizzle with API

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 uuid column gets different operators than a boolean column
  • The with parameter for relations requires the Drizzle relations object — 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 FooRelations entries from the module and generates Drizzle relations() calls using the rendered table objects. No <relation> element type is needed. See ADR-013.

Open Questions

  1. 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.

  2. Schema bundle or separate schemas? Should the adapter accept the Type.Module compiled bundle, or individual schemas? The module is convenient (everything in one place) but couples the adapter to TypeBox's module format. (See OQ-12)

  3. 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/operationsOperationSpec, OperationRegistry, OperationHandler
  • Drizzle-GraphQL reference: /workspace/drizzle-graphql — CRUD generator pattern
  • Research: docs/research/architecture.md (validation schemas section)