Add SDD architecture docs for dbtype

Phase 0 architecture specification following the alkdev documentation
pattern from @alkdev/flowgraph. Documents the validated architecture
(UJSX elements → Type.Module → Drizzle hosts) based on e2e probe results.

Docs added:
- README: Project overview, architecture, current state
- architecture/README: Index, design decisions, relationships
- architecture/schema: Type.Module as bundle, construction, serialization
- architecture/hosts: HostConfig per dialect, column mapping, symbolic defaults
- architecture/elements: UJSX element types, props, function components
- architecture/module: Module mechanics, format registration, diffing
- architecture/repo-adapter: from-dbtype operations adapter (phase 2)
- architecture/build-distribution: Package structure, exports
- architecture/open-questions: 10 open questions across all topics
- ADRs 001-005: UJSX as IR, Type.Module, HostConfig, format, repo adapter
This commit is contained in:
2026-05-22 11:34:58 +00:00
parent 6fe84e1a53
commit dd2ec9df3c
14 changed files with 1447 additions and 48 deletions

147
docs/architecture/README.md Normal file
View File

@@ -0,0 +1,147 @@
---
status: draft
last_updated: 2026-05-22
---
# @alkdev/dbtype Architecture
Schema-first multi-dialect database type system. Define your database schema once as a UJSX element tree, validate it with TypeBox, and render it to any Drizzle dialect (SQLite, PostgreSQL, MySQL) — all from a single source of truth.
## Why This Exists
Every project that uses Drizzle ORM across multiple backends defines schemas repeatedly: once per dialect (SQLite for local dev, PostgreSQL for production), once again for input validation (TypeBox, Zod, etc.), and once more for API contracts (GraphQL types, OpenAPI schemas). The existing `drizzle-typebox` only goes one direction (Drizzle → TypeBox) and requires defining schemas in Drizzle's dialect-specific API first.
The gap: there is no schema-first, dialect-agnostic way to define a database schema that simultaneously produces validation schemas, Drizzle table definitions, and (via the operations layer) CRUD operation specs — all from one source of truth.
## Core Principle
**The element tree is the schema. The module is the bundle. The host is the dialect.**
- The UJSX element tree (`<table>`, `<column>`) is the authoring surface — composable, reusable, and TypeBox-validatable
- The `Type.Module` is the schema bundle — all tables, relations, and derived schemas in one namespace with `Type.Ref` resolving cross-table references
- The `HostConfig` is the dialect adapter — render the same tree to `sqliteTable`, `pgTable`, `mysqlTable`, or any future target
## Relationship to Sibling Packages
| Package | Relationship |
|---------|-------------|
| `@alkdev/typebox` | Type system foundation. `Type.Module`, `Type.Ref`, `Value.Check/Diff/Edit`, `FormatRegistry` |
| `@alkdev/ujsx` | Element authoring. `h()`, `createComponent`, `HostConfig`, `createRoot`, reconciler |
| `@alkdev/operations` | CRUD generation. `OperationSpec`, `OperationRegistry`, `from-dbtype` adapter (future) |
| `@alkdev/pubsub` | Event transport. Used by operations call protocol |
| `drizzle-orm` | Peer dependency. Dialect-specific column builders consumed by hosts |
## Current State
**Phase 0: Exploration** — Architecture probing complete. Probe scripts in `scripts/probe-e2e.ts` validate the core architecture. No implementation yet.
### What Exists
- Fork of `drizzle-typebox` with `@alkdev/typebox` support (current `src/`)
- Research docs in `docs/research/` (architecture, type-map, column diffs, typedef kind pattern)
- E2E probe script validating: UJSX → Type.Module → Drizzle rendering pipeline
### What Doesn't Exist Yet
- Core `dbtype` package (the implementation)
- Host configs for SQLite, PostgreSQL, MySQL
- Schema extraction functions (`createSelectSchema`, `createInsertSchema`, `createUpdateSchema` from element trees)
- Repo/CRUD generation adapter (`from-dbtype` for `@alkdev/operations`)
- `@alkdev/ujsx` as a declared dependency (currently dev-only)
## Architecture Documents
| Document | Content |
|----------|---------|
| [schema.md](schema.md) | Type.Module structure, element tree types, column type vocabulary, schema derivation |
| [hosts.md](hosts.md) | HostConfig implementations per dialect, symbolic defaults, Drizzle rendering |
| [elements.md](elements.md) | UJSX element definitions, function components, props schemas, common components |
| [module.md](module.md) | Type.Module as the bundle, incremental construction, serialization, migration diffing |
| [repo-adapter.md](repo-adapter.md) | `from-dbtype` adapter for `@alkdev/operations`, filter/schema generation, access control |
| [build-distribution.md](build-distribution.md) | Package structure, sub-path exports, dependencies, tree-shaking |
| [open-questions.md](open-questions.md) | Cross-cutting unresolved questions |
### Design Decisions
| ADR | Decision |
|-----|----------|
| [001](decisions/001-ujsx-as-ir.md) | UJSX element tree as the IR, not a separate DbType builder API |
| [002](decisions/002-type-module-as-bundle.md) | Type.Module as the schema bundle, not a custom registry |
| [003](decisions/003-hostconfig-for-dialects.md) | HostConfig for dialect rendering, not a transform registry |
| [004](decisions/004-format-annotation-only.md) | Column formats are annotations, not validators — register explicitly |
| [005](decisions/005-repo-as-adapter.md) | CRUD generation as an operations adapter, not a core feature |
### Open Questions
All unresolved questions tracked in [open-questions.md](open-questions.md).
## Source Structure (Planned)
```
src/
core/
elements.ts # h() wrappers, createComponent for table/column/index/fk
schema.ts # extractTable, createSelectSchema, createInsertSchema, createUpdateSchema
module.ts # buildModule, module construction helpers
column-types.ts # DbColumnType union, colToTypeBox mapping
defaults.ts # Symbolic default resolution ('now', 'uuid', 'autoincrement')
guards.ts # Type guards for element types
hosts/
sqlite.ts # HostConfig for drizzle-orm/sqlite-core
pg.ts # HostConfig for drizzle-orm/pg-core
mysql.ts # HostConfig for drizzle-orm/mysql-core
repo/
from-dbtype.ts # FromDbType adapter for @alkdev/operations
filters.ts # Filter schema generation per column type
handlers.ts # CRUD handler generation (findMany, insertOne, etc.)
index.ts
```
## Key Design Decisions
### 1. UJSX Element Tree as the IR, Not a Separate Builder API
The architecture docs in `docs/research/` proposed a `DbTypeBuilder` class with methods like `DbType.String()`, `DbType.Table()`. The probe results show that UJSX elements (`<table>`, `<column>`) with props serve the same purpose — they carry all the metadata (type, notNull, primaryKey, default, references) and compose via function components (`IdCol`, `AuditCols`).
A separate builder API would be redundant: both the element tree and the builder produce the same TypeBox + metadata. The element tree is strictly more capable (function components for composition, reconciler for migration diffing in the future) and already exists as `@alkdev/ujsx`.
**Alternative considered**: `DbTypeBuilder` (the research docs pattern). Rejected because it duplicates what UJSX already provides and cannot compose as naturally.
### 2. Type.Module as the Schema Bundle
All table schemas and their relations live in a single `Type.Module` call. `Type.Ref` resolves cross-table references including mutual/circular ones. This eliminates the circular-import problem that the storage_sqlite pattern solves with a separate `relations.ts` file.
The module is also serializable as JSON Schema with `$defs`, enabling `Value.Diff` for schema migration and `FromSchema` for round-tripping.
**Alternative considered**: A custom registry with separate schema objects. Rejected because `Type.Module` already does everything needed (ref resolution, validation, serialization) and doesn't require a new abstraction.
### 3. HostConfig for Dialect Rendering, Not a Transform Registry
The research docs proposed a `TransformRegistry` with priority-sorted rules. UJSX's `HostConfig` serves the same purpose — each dialect is a host, `createInstance` maps element types to Drizzle column builders, `appendChild` assembles columns into tables.
This leverages existing infrastructure (`createRoot`, `createComponent`, reconciler) rather than building a parallel dispatch system. It also positions the project for future migration support via `prepareUpdate`/`commitUpdate`.
**Alternative considered**: TransformRegistry from `docs/research/architecture.md`. Rejected because HostConfig is the same pattern with more capabilities (reconciler, function components, context) and already exists in `@alkdev/ujsx`.
### 4. Column Formats as Annotations
`Type.String({ format: 'uuid' })` and `Type.String({ format: 'email' })` are annotations by default in TypeBox. `Value.Check` does not enforce format unless validators are explicitly registered via `FormatRegistry.Set`. This is correct JSON Schema behavior.
For dbtype, formats serve as metadata that hosts can use (e.g., the PG host maps `format: 'uuid'` to `uuid()` column type, the SQLite host maps it to `text()` with `$defaultFn`). Validation is opt-in via format registration.
### 5. CRUD Generation as an Operations Adapter
The repo pattern (auto-generated CRUD for each table) is not a core feature of dbtype. It's an adapter for `@alkdev/operations` that consumes the same element tree and module bundle. This keeps dbtype focused on schema definition and rendering, while the operations integration is a separate concern.
## Document Lifecycle
| From | To | Condition |
|------|----|-----------|
| `draft` | `reviewed` | All open questions resolved |
| `reviewed` | `stable` | Implementation complete and verified by tests |
| `stable` | `deprecated` | Superseded by new architecture |
## References
- Research: `docs/research/architecture.md`, `docs/research/typemap-architecture.md`, `docs/research/dizzle-column-diffs.md`, `docs/research/typedef-kind-pattern.md`
- Probe results: `scripts/probe-e2e.ts`
- Sibling projects: `@alkdev/typebox`, `@alkdev/ujsx`, `@alkdev/operations`, `@alkdev/pubsub`
- Reference implementation: `@alkdev/drizzle-typebox` (current `src/`), `drizzle-graphql` (workspace)

View File

@@ -0,0 +1,91 @@
---
status: draft
last_updated: 2026-05-22
---
# Build & Distribution
Package structure, exports, dependencies, and tree-shaking strategy.
## Package
- **Name**: `@alkdev/dbtype`
- **Type**: ESM with CJS fallback
- **Peer dependencies**: `@alkdev/typebox >=0.34.49`, `@alkdev/ujsx >=0.1.0`, `drizzle-orm >=0.36.0`
- **Dev dependencies**: All three peer deps for testing, plus `tsx`, `vitest`, `typescript`
## Sub-path Exports
```json
{
"exports": {
".": {
"import": { "types": "./index.d.mts", "default": "./index.mjs" },
"require": { "types": "./index.d.cjs", "default": "./index.cjs" }
},
"/core": {
"import": { "types": "./core/index.d.mts", "default": "./core/index.mjs" },
"require": { "types": "./core/index.d.cjs", "default": "./core/index.cjs" }
},
"/sqlite": {
"import": { "types": "./sqlite/index.d.mts", "default": "./sqlite/index.mjs" },
"require": { "types": "./sqlite/index.d.cjs", "default": "./sqlite/index.cjs" }
},
"/pg": {
"import": { "types": "./pg/index.d.mts", "default": "./pg/index.mjs" },
"require": { "types": "./pg/index.d.cjs", "default": "./pg/index.cjs" }
},
"/mysql": {
"import": { "types": "./mysql/index.d.mts", "default": "./mysql/index.mjs" },
"require": { "types": "./mysql/index.d.cjs", "default": "./mysql/index.cjs" }
}
}
}
```
Tree-shaking: Users who only use SQLite import `/sqlite` and never pull in PG or MySQL column builders.
## Source Structure
```
src/
core/
elements.ts # h() wrappers, createComponent for table/column/index/fk
schema.ts # extractTable, createSelectSchema, createInsertSchema, createUpdateSchema
module.ts # buildModule, module construction helpers
column-types.ts # DbColumnType union, colToTypeBox mapping
defaults.ts # Symbolic default resolution ('now', 'uuid', 'autoincrement')
guards.ts # Type guards for element types
index.ts
hosts/
sqlite.ts # HostConfig for drizzle-orm/sqlite-core
pg.ts # HostConfig for drizzle-orm/pg-core
mysql.ts # HostConfig for drizzle-orm/mysql-core
repo/
from-dbtype.ts # FromDbType adapter for @alkdev/operations (phase 2)
filters.ts # Filter schema generation per column type
handlers.ts # CRUD handler generation
index.ts # Re-exports core
```
## Dependencies
```
@alkdev/dbtype
├── @alkdev/typebox (peer) # Type.Module, Type.Ref, Value.Check, FormatRegistry
├── @alkdev/ujsx (peer) # h, createComponent, HostConfig, createRoot
└── drizzle-orm (peer) # Dialect-specific column builders (per sub-path)
```
The core package (`@alkdev/dbtype/core`) depends only on `@alkdev/typebox` and `@alkdev/ujsx`. The dialect modules (`/sqlite`, `/pg`, `/mysql`) add `drizzle-orm` as a peer dependency (scoped to the specific dialect sub-module).
## Constraints
- **Core must not import from drizzle-orm** — the core (elements, schema extraction, module) is dialect-agnostic
- **Dialect modules must not import from each other** — SQLite host doesn't pull in PG types
- **Build is ESM-first** — CJS is generated as a fallback for compatibility
## References
- Typemap sub-path exports pattern: `docs/research/typemap-architecture.md`
- Current package: `package.json`

View File

@@ -0,0 +1,51 @@
# ADR-001: UJSX Element Tree as the IR
## Status
Proposed
## Context
The research docs (`docs/research/architecture.md`) proposed a `DbTypeBuilder` class with methods like `DbType.String()`, `DbType.Table()` — a programmatic API that produces `TDbColumn` objects wrapping TypeBox schemas with database metadata. This is the same pattern used by drizzle-orm itself (builder methods returning column objects).
Meanwhile, `@alkdev/ujsx` provides an element-based system where `<table>`, `<column>` trees carry the same metadata in props, compose via function components, and render to dialect-specific outputs via `HostConfig`.
These two approaches produce the same output (TypeBox schemas + Drizzle column builders) but differ in authoring surface and composability.
## Decision
Use UJSX element trees as the IR, not the `DbTypeBuilder` API.
## Rationale
1. **Composition**: Function components (`IdColumn`, `AuditColumns`) compose naturally in the element tree. A `DbTypeBuilder` API would need spread patterns or explicit column merging to achieve the same result.
2. **No duplication**: The `DbTypeBuilder` pattern would create a parallel API to what UJSX elements already provide. Both would carry type, notNull, primaryKey, default metadata. The element tree IS the IR.
3. **Future extensibility**: The UJSX reconciler (`prepareUpdate`/`commitUpdate`) can diff old and new element trees for schema migration. A builder API has no diffing capability — you'd need to compare two separate builder call sites.
4. **Already proven**: The e2e probe (`scripts/probe-e2e.ts`) validates the full pipeline: UJSX elements → extractTable → Type.Module → Value.Check → Drizzle rendering. All 28 assertions pass.
5. **Consistency with sibling projects**: Flowgraph uses UJSX for workflow templates. Using the same pattern for dbtype creates a unified mental model across the alkdev stack.
## Consequences
### Positive
- Single authoring surface (elements) instead of two (elements + builder)
- Function components for reusable column groups (the storage_sqlite `commonCols` pattern, but as composable components)
- Reconciler-ready for future migration support
- Leverages existing `@alkdev/ujsx` infrastructure
### Negative
- Requires `@alkdev/ujsx` as a peer dependency (adds a dependency)
- JSX syntax requires build configuration (`jsxImportSource`), though `h()` calls work without it
- Less familiar to developers used to drizzle's builder API
- Function component resolution must happen before host rendering — the tree must be "resolved" (function components called) before the host processes it. The probe validates this works.
## References
- Probe validation: `scripts/probe-e2e.ts`
- Research comparison: `docs/research/architecture.md` (DbTypeBuilder section)
- UJSX architecture: `@alkdev/ujsx/docs/architecture/`

View File

@@ -0,0 +1,50 @@
# ADR-002: Type.Module as the Schema Bundle
## Status
Proposed
## Context
Schemas need to be stored, validated, related, and serialized. The research docs proposed a custom `TransformRegistry` and `DbType` kind system for this. `@alkdev/typebox` already provides `Type.Module` with `Type.Ref` resolution, `Value.Check` validation, and JSON Schema serialization — exactly what we need.
## Decision
Use `Type.Module` as the schema bundle. No custom registry or kind system.
## Rationale
1. **Ref resolution works**: `Type.Ref('TableName')` inside a module resolves correctly for forward references, circular references, and mutual references. No import-order management, no separate `relations.ts`.
2. **Validation works**: `Value.Check(M.Import('Users'), userData)` validates after format registration. The module carries all `$defs` and resolves `$ref` pointers.
3. **Serialization works**: `JSON.stringify(M.Import('Users'))` produces valid JSON Schema with `$defs`. This enables migration diffing via `Value.Diff`.
4. **Incremental construction**: Build the defs map as a plain `Record<string, TSchema>`, mutate it, then compile with `Type.Module`. No special API needed.
5. **Derived schemas**: `Type.Partial(Type.Ref('Users'))` produces an update schema. Insert schemas can be added as explicit module entries. No custom derivation logic needed.
6. **No new abstraction**: Using what TypeBox already provides means less code to maintain and fewer custom kind registrations.
## Consequences
### Positive
- No custom kind system or registry to maintain
- Circular/mutual references handled natively
- Free JSON Schema serialization for migration diffing
- Free validation via Value.Check
- Free derived schemas via Type.Partial + Type.Ref
### Negative
- Module keys are a flat namespace — no nested paths like `"tables/Users"` (acceptable, table names are already unique within a database)
- `Type.Ref` outside a module has `static: unknown` — always use `M.Import(key)` for type inference
- Every `Import()` call embeds the full `$defs` — increases JSON Schema payload size for individual schema exports (acceptable for our use case)
- Custom column metadata (`notNull`, `primaryKey`, `default`) is not in the TypeBox schema — it lives in the element props and column metadata extracted during tree walking. The module carries validation types, not database constraints.
## References
- Probe validation: `scripts/probe-e2e.ts`
- TypeBox Module API: `@alkdev/typebox/src/type/module/module.ts`
- Research comparison: `docs/research/architecture.md` (DbType IR section), `docs/research/typedef-kind-pattern.md`

View File

@@ -0,0 +1,49 @@
# ADR-003: HostConfig for Dialect Rendering
## Status
Proposed
## Context
The research docs proposed a `TransformRegistry` with priority-sorted rules matching `DbType:*` kinds to dialect-specific Drizzle column builders. UJSX provides `HostConfig` — a similar pattern where `createInstance(tag, props, ctx)` maps element types to output instances.
These solve the same problem: dispatch a schema element to the appropriate dialect-specific builder.
## Decision
Use `HostConfig` for dialect rendering, not a custom `TransformRegistry`.
## Rationale
1. **Same pattern, more capabilities**: Both `HostConfig.createInstance` and `TransformRule.transform` map an input type to an output instance. HostConfig adds `appendChild` (for composing columns into tables), `finalizeInstance` (for calling `sqliteTable()` after all columns are ready), and `prepareUpdate`/`commitUpdate` (for future migration diffing).
2. **Function components are free composition**: `<IdColumn />`, `<AuditColumns />` compose via the UJSX reconciliation. A TransformRegistry would need its own composition mechanism.
3. **Reconciler for free**: The UJSX reconciler handles element diffing, key-based matching, and child reordering. For future schema migration, `prepareUpdate`/`commitUpdate` can diff old and new element props to compute ALTER TABLE statements.
4. **Context for dialect config**: `createRootContext` carries dialect-specific configuration (naming conventions, type mappings, enum pre-declarations).
5. **Already proven**: The probe scripts use HostConfig to render element trees to Drizzle SQLite tables. The pattern works end-to-end.
## Consequences
### Positive
- Leverages existing UJSX infrastructure
- Future migration support via reconciler
- Function component composition for common column patterns
- Context propagation for dialect-specific config
### Negative
- `createTextInstance` is required by HostConfig but semantically wrong for DB schemas — must throw or noop
- More infrastructure than a simple TransformRegistry (fiber tree, reconciler)
- HostConfig is defined in `@alkdev/ujsx`, creating a tighter coupling between the packages
- The transform registry from the research docs could handle cross-dialect conversion (PG → SQLite), which HostConfig doesn't address — but we don't need cross-dialect conversion because the element tree IS dialect-agnostic
## References
- Probe host rendering: `scripts/probe-e2e.ts`
- UJSX HostConfig: `@alkdev/ujsx/docs/architecture/host-config.md`
- Research comparison: `docs/research/architecture.md` (Transform Registry section), `docs/research/typemap-architecture.md` (module structure)

View File

@@ -0,0 +1,46 @@
# ADR-004: Column Formats Are Annotations
## Status
Proposed
## Context
When a column has `type: 'uuid'` or `type: 'string'` with `format: 'email'`, the `format` field in the TypeBox schema (`Type.String({ format: 'uuid' })`) serves two purposes:
1. **Validation metadata** — should `Value.Check` enforce the format?
2. **Database metadata** — should the PG host render it as `uuid()` instead of `text()`?
TypeBox follows JSON Schema semantics: `format` is annotation-only by default. `Value.Check` does not enforce format strings unless validators are registered via `FormatRegistry.Set`.
## Decision
Treat `format` as annotation-only in the TypeBox schema. Register specific format validators when validation is needed. Use the column `type` prop (not `format`) for dialect-specific mapping.
## Rationale
1. **Probing confirmed this behavior**: `Type.String({ format: 'uuid' })` fails `Value.Check` until `FormatRegistry.Set('uuid', validator)` is called. This is correct JSON Schema behavior.
2. **Dialect mapping uses `type`, not `format`**: The PG host maps `<column type="uuid">` to `uuid()`. The SQLite host maps the same to `text()`. The `format` field is an additional hint for validation, not the driver for database rendering.
3. **Explicit is better than implicit**: Requiring `FormatRegistry.Set` for custom formats makes it clear when validation is active. No silent enforcement of formats the application doesn't care about.
4. **`format` is still useful**: It provides machine-readable metadata in the JSON Schema serialization. API generators (OpenAPI, GraphQL) can use `format: 'uuid'` or `format: 'email'` for documentation and client-side validation.
## Consequences
### Positive
- Correct JSON Schema semantics
- No surprise validation failures
- `format` still carries metadata for non-validation purposes
- Dialect mapping is explicit (based on `type` prop)
### Negative
- Applications must explicitly register format validators to get format enforcement
- Two fields (`type` and `format`) encode overlapping information (e.g., `type: 'uuid'` implies `format: 'uuid'`)
## References
- Format registration: `scripts/probe-e2e.ts`
- TypeBox FormatRegistry: `@alkdev/typebox/src/type/registry/format.ts`

View File

@@ -0,0 +1,47 @@
# ADR-005: CRUD Generation as an Operations Adapter
## Status
Proposed
## Context
drizzle-graphql auto-generates CRUD operations (findMany, insert, update, delete) from Drizzle schemas. The dbtype architecture has enough information (element tree → module → schemas + rendered Drizzle tables) to do the same, but outputting `OperationSpec`s for `@alkdev/operations` instead of GraphQL resolvers.
This could be a core feature of dbtype, or a separate adapter package.
## Decision
CRUD generation is a separate adapter (`@alkdev/dbtype/repo` or a future `@alkdev/dbtype-operations` package), not a core feature.
## Rationale
1. **Separation of concerns**: dbtype's core job is "define once, validate and deploy anywhere" — schema definition, validation, and dialect rendering. CRUD generation is a different concern (API generation from schemas).
2. **The adapter consumes the same inputs**: The `FromDbType` adapter takes the element tree, the Type.Module, and the rendered Drizzle tables — all produced by dbtype core. It's a consumer, not a producer.
3. **Optional dependency**: Not every dbtype user needs CRUD operations. Keeping it in core would force `@alkdev/operations` as a dependency.
4. **The adapter pattern is proven**: `@alkdev/operations` already has `from-openapi`, `from-mcp`, and `from-typemap` adapters. `from-dbtype` follows the same pattern.
5. **Phase ordering**: Core (phase 1) must be stable before building the adapter (phase 2). The adapter depends on stable schema extraction APIs, which don't exist yet.
## Consequences
### Positive
- Core package stays focused on schema + rendering
- Users who only need validation and Drizzle rendering don't pull in operations
- The adapter can evolve independently
- Follows the established adapter pattern from @alkdev/operations
### Negative
- Two packages to install for the full experience
- The adapter needs access to both Type.Module entries (for schemas) and rendered Drizzle tables (for query execution), which means the rendering pipeline must expose both outputs
## References
- Operations adapters: `@alkdev/operations/from-openapi`, `@alkdev/operations/from-mcp`
- drizzle-GraphQL reference: `/workspace/drizzle-graphql`
- Repo adapter design: [repo-adapter.md](repo-adapter.md)

View File

@@ -0,0 +1,175 @@
---
status: draft
last_updated: 2026-05-22
---
# Elements: UJSX Element Definitions
The UJSX element types, their props, function components, and how they compose.
## Overview
dbtype elements are UJSX elements (`h()` calls or JSX) with a constrained set of tags: `table`, `column`, `index`, `fk`. Each element carries typed props that encode both validation metadata (for TypeBox) and database metadata (for Drizzle rendering).
## Element Types
### `<table>`
The top-level schema element. Contains `<column>`, `<index>`, and `<fk>` children.
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `name` | `string` | yes | Table name in the database |
Children are column, index, and foreign key elements. Function component children that return column elements are transparent (their output is used, not the component itself).
### `<column>`
A single column definition within a table.
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `name` | `string` | yes | Column name |
| `type` | `DbColumnType` | yes | Column type vocabulary |
| `notNull` | `boolean` | no | Column is NOT NULL |
| `primaryKey` | `boolean` | no | Column is primary key |
| `unique` | `boolean` | no | Column has UNIQUE constraint |
| `default` | `DbDefault \| unknown` | no | Symbolic default or literal value |
| `references` | `string` | no | FK target table name |
| `format` | `string` | no | TypeBox format annotation (uuid, email, etc.) |
| `mode` | `'json' \| 'text'` | no | Storage mode for compound types |
| `values` | `string[]` | no | Enum values (for `type: 'enum'`) |
| `length` | `number` | no | Max length (for varchar) |
| `precision` | `number` | no | Numeric precision |
| `scale` | `number` | no | Numeric scale |
| `postgres` | `PgColumnOpts` | no | PG-specific overrides |
| `sqlite` | `SqliteColumnOpts` | no | SQLite-specific overrides |
| `mysql` | `MySqlColumnOpts` | no | MySQL-specific overrides |
### `<index>`
An index definition on a table.
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `name` | `string` | yes | Index name |
| `columns` | `string[]` | yes | Column names in the index |
| `unique` | `boolean` | no | Whether the index is unique |
### `<fk>`
A foreign key constraint.
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `columns` | `string[]` | yes | Local column names |
| `references` | `string` | yes | Target table name |
| `foreignColumns` | `string[]` | yes | Target column names |
| `onDelete` | `'cascade' \| 'set null' \| 'restrict' \| 'no action'` | no | ON DELETE action |
| `onUpdate` | `'cascade' \| 'set null' \| 'restrict' \| 'no action'` | no | ON UPDATE action |
## Column Type Vocabulary
`DbColumnType` is the cross-dialect type vocabulary. Each value maps to a specific Drizzle column builder per dialect:
```typescript
type DbColumnType =
| 'uuid'
| 'string'
| 'text'
| 'varchar'
| 'integer'
| 'bigint'
| 'boolean'
| 'timestamp'
| 'real'
| 'numeric'
| 'enum'
| 'json'
| 'array'
| 'object'
```
The mapping to TypeBox and Drizzle types is defined in the host, but the element tree uses `DbColumnType` as the universal vocabulary.
## Symbolic Defaults
```typescript
type DbDefault =
| 'now' // Current timestamp (dialect-specific SQL)
| 'uuid' // UUID generation (dialect-specific mechanism)
| 'autoincrement' // Auto-incrementing integer
| 'current_timestamp' // CURRENT_TIMESTAMP
| unknown // Literal value or SQL expression
```
## Function Components
Reusable column groups defined as UJSX components:
```typescript
// Common ID column
const IdColumn = createComponent('IdColumn', () =>
h('column', { name: 'id', type: 'uuid', primaryKey: true, default: 'uuid' })
)
// Audit timestamp columns
const AuditColumns = createComponent('AuditColumns', () => [
h('column', { name: 'createdAt', type: 'timestamp', notNull: true, default: 'now' }),
h('column', { name: 'updatedAt', type: 'timestamp', notNull: true, default: 'now' }),
])
// Usage
const UsersEl = h('table', { name: 'users' },
h(IdColumn, {}),
h('column', { name: 'name', type: 'string', notNull: true }),
h(AuditColumns, {}),
)
```
Function components are transparent — the host never sees them. The `createInstance` method only receives resolved intrinsic elements (`column`, `table`).
## Tree Walking
The `extractTable()` function walks an element tree, resolving function components, and produces:
1. **TypeBox schema**: `Type.Object({ ... })` for the module entry
2. **Column metadata**: `Record<string, ColumnProps>` for Drizzle rendering
3. **Table metadata**: `{ name, columns, indexes, foreignKeys }` for the host
```typescript
function extractTable(el: UElement): {
name: string
schema: TObject // TypeBox schema for Type.Module
columns: Record<string, ColumnMeta> // Props + computed metadata
indexes: IndexMeta[]
foreignKeys: FkMeta[]
}
```
For each column, `extractTable`:
- Calls `colToTypeBox(type, props)` to produce the inner TypeBox schema
- Stores the full props object as metadata for the host
- Tracks `primaryKey`, `default`, `notNull`, `references` for schema derivation (insert, update)
## Constraints
- **Column elements must have `name` and `type` props** — these are required for both TypeBox schema construction and Drizzle rendering
- **Function components must return column elements** (or arrays of column elements) — returning other element types inside a table is undefined
- **The `references` prop is metadata-only** — it's not part of the TypeBox schema. It informs the host about FK constraints and the repo adapter about relation structure
- **`default` values can be symbolic strings or literals** — symbolic defaults (`now`, `uuid`, `autoincrement`) are resolved by the host; literal values pass through directly
## Open Questions
1. **Should column elements support `inner` TypeBox schemas?** The research docs proposed `DbType.String({ notNull: true, inner: Type.String({ format: 'email', maxLength: 255 }) })`. With UJSX elements, this would be `<column name="email" type="string" notNull format="email" maxLength={255} />`. Is the flat props model sufficient, or do we need nested TypeBox schemas?
2. **Should `<table>` accept `extraConfig` props?** Drizzle tables accept a third callback argument for indexes and unique constraints defined in terms of column references. How does this map to element props?
3. **Should we support JSX file extensions?** The current probe uses `h()` calls. JSX syntax (`.tsx` files with `jsxImportSource: '@alkdev/ujsx'`) would be more ergonomic for authoring but requires build configuration.
## References
- UJSX element factory: `@alkdev/ujsx/src/core/h.ts`
- UJSX HostConfig: `@alkdev/ujsx/src/host/config.ts`
- Probe element construction: `scripts/probe-e2e.ts`
- Research: `docs/research/architecture.md` (DbTypeBuilder section)

151
docs/architecture/hosts.md Normal file
View File

@@ -0,0 +1,151 @@
---
status: draft
last_updated: 2026-05-22
---
# Hosts: Dialect Rendering via HostConfig
How dbtype renders UJSX element trees to Drizzle table definitions for each supported database dialect.
## Overview
Each database dialect (SQLite, PostgreSQL, MySQL) is a `HostConfig` implementation. The same UJSX element tree renders to `sqliteTable`, `pgTable`, or `mysqlTable` depending on which host is chosen. The host translates column type props, symbolic defaults, and constraints to the appropriate Drizzle column builder methods.
## HostConfig Interface
```typescript
interface DbtypeRootCtx<Dialect extends string> {
dialect: Dialect
tables: Record<string, DbtypeTableInst>
}
interface DbtypeTableInst {
name: string
columns: Record<string, any> // Drizzle column builders
constraints: any[]
result?: any // The final drizzle table
}
interface DbtypeColumnInst {
name: string
columnType: string
builder: any // Drizzle column builder
dbMeta: Record<string, any>
}
```
The `HostConfig` generic parameters:
- `TTag`: `"table" | "column" | "index" | "fk"` — the allowed element types
- `TInstance`: The union of table and column instance types for this dialect
- `TRootCtx`: The dialect-specific root context
## Column Type Mapping
### SQLite
| Column Type Prop | Drizzle Builder | Notes |
|-----------------|----------------|-------|
| `uuid` | `text(name).primaryKey().$defaultFn(crypto.randomUUID)` | No native UUID type |
| `string` | `text(name)` | |
| `integer` | `integer(name)` | |
| `boolean` | `integer(name, { mode: 'boolean' })` | |
| `timestamp` | `integer(name, { mode: 'timestamp' })` | |
| `enum` | `text(name, { enum: values })` | SQLite has no native enum |
| `json` | `text(name, { mode: 'json' })` | Stored as JSON text |
### PostgreSQL
| Column Type Prop | Drizzle Builder | Notes |
|-----------------|----------------|-------|
| `uuid` | `uuid(name).defaultRandom()` or `.primaryKey()` | Native UUID |
| `string` | `text(name)` or `varchar(name, n)` | |
| `integer` | `integer(name)` | |
| `boolean` | `boolean(name)` | |
| `timestamp` | `timestamptz(name, { withTimezone: true })` | |
| `enum` | `pgEnum(name, values)` | Requires pre-declaration |
| `json` | `jsonb(name)` | |
### MySQL
| Column Type Prop | Drizzle Builder | Notes |
|-----------------|----------------|-------|
| `uuid` | `varchar(name, { length: 36 })` | No native UUID |
| `string` | `text(name)` or `varchar(name, n)` | |
| `integer` | `int(name)` | |
| `boolean` | `boolean(name)` or `tinyint(name)` | |
| `timestamp` | `timestamp(name)` | |
| `enum` | `mysqlEnum(name, values)` | |
| `json` | `json(name)` | |
## Symbolic Defaults
Column props like `default="now"` and `default="uuid"` are translated to dialect-specific SQL or JS functions by the host:
| Symbol | SQLite | PostgreSQL | MySQL |
|--------|--------|------------|-------|
| `now` | `sql\`(strftime('%s', 'now'))\`` | `sql\`now()\`` | `sql\`NOW()\`` |
| `uuid` | `.$defaultFn(() => crypto.randomUUID())` | `.defaultRandom()` | `.$defaultFn(() => crypto.randomUUID())` |
| `autoincrement` | Implicit on `INTEGER PRIMARY KEY` | `serial()` type | `.autoincrement()` |
## Common Column Components
Function components that compose into any table:
```typescript
const IdColumn = createComponent('IdColumn', () =>
h('column', { name: 'id', type: 'uuid', primaryKey: true, default: 'uuid' })
)
const AuditColumns = createComponent('AuditColumns', () => [
h('column', { name: 'createdAt', type: 'timestamp', notNull: true, default: 'now' }),
h('column', { name: 'updatedAt', type: 'timestamp', notNull: true, default: 'now' }),
])
```
Used by spreading into a table:
```typescript
const UsersEl = h('table', { name: 'users' },
h(IdColumn, {}),
h('column', { name: 'name', type: 'string', notNull: true }),
h(AuditColumns, {}),
)
```
This replaces the storage_sqlite pattern of `const commonCols = { id: text('id').primaryKey(), ... }` with reusable, dialect-agnostic components.
## Rendering Pipeline
1. **Walk elements**: The host's `createInstance` method receives each `<table>`, `<column>`, `<index>`, `<fk>` element
2. **Build columns**: `createInstance('column', props, ctx)` maps `props.type` to the dialect-specific Drizzle builder, applying `notNull`, `primaryKey`, `unique`, `default` constraints
3. **Assemble tables**: `appendChild(tableInst, columnInst, ctx)` attaches column builders to the table's columns record
4. **Finalize**: `finalizeInstance(tableInst, ctx)` calls `sqliteTable(name, columns)` (or `pgTable`, `mysqlTable`) and stores the result
```typescript
const root = createRoot(sqliteHost, {})
root.render(UsersEl)
// root's context now contains: { tables: { users: drizzleSqliteTable } }
```
## Constraints
- **`createTextInstance` is not supported** — DB schemas are purely structural, no text nodes
- **Column type props must be from the `DbColumnType` vocabulary** — hosts map these to dialect-specific builders; unknown types fall back to `text()`
- **Symbolic defaults are resolved by the host** — `default="now"` becomes `sql\`(strftime('%s', 'now'))\`` on SQLite and `sql\`now()\`` on PG
- **PG enums require pre-declaration** — the host must track enum types and emit `pgEnum()` calls before tables that reference them
- **A host renders one dialect at a time** — to generate schemas for multiple dialects, render multiple times with different hosts
## Open Questions
1. **How to handle PG enum pre-declaration?** PG requires `pgEnum()` at module scope before tables. Options: (A) return both enums and tables from render, (B) start with text for all enums, (C) per-column opt-in. Leaning toward A.
2. **Should hosts return the rendered table or store it in context?** The probe scripts use context (`ctx.tables`), but returning from render would be more functional. Need to resolve this.
3. **`prepareUpdate`/`commitUpdate` for migrations?** The UJSX reconciler could diff old and new element trees to produce ALTER TABLE statements. This is a future feature, not phase 1.
## References
- UJSX HostConfig: `@alkdev/ujsx/src/host/config.ts`
- Drizzle column diffs: `docs/research/dizzle-column-diffs.md`
- Storage pattern: `@alkdev/storage_sqlite/`
- Probe: `scripts/probe-e2e.ts`

170
docs/architecture/module.md Normal file
View File

@@ -0,0 +1,170 @@
---
status: draft
last_updated: 2026-05-22
---
# Module: Type.Module as the Schema Bundle
Technical details on how dbtype uses `Type.Module` for schema construction, validation, serialization, and migration.
## Overview
dbtype uses `@alkdev/typebox`'s `Type.Module` as the schema storage and resolution mechanism. A module holds all table schemas, their relations, and derived schemas (insert, update, partial) in a single flat namespace. `Type.Ref` resolves cross-table references — including circular ones — without import ordering issues.
This document covers the mechanics, constraints, and patterns discovered during architecture probing.
## Construction Patterns
### Basic Pattern
```typescript
const defs: Record<string, any> = {
Users: Type.Object({ id: Type.String({ format: 'uuid' }), name: Type.String() }),
Tasks: Type.Object({ id: Type.String({ format: 'uuid' }), title: Type.String() }),
}
const M = Type.Module(defs)
const Users = M.Import('Users')
```
### With Relations
```typescript
defs.UsersRelations = Type.Object({ tasks: Type.Array(Type.Ref('Tasks')) })
defs.TasksRelations = Type.Object({ user: Type.Ref('Users') })
```
`Type.Ref('Users')` within the module resolves to the Users schema. No circular import issues.
### With Derived Schemas
```typescript
defs.InsertUsers = Type.Object({ name: Type.String(), email: Type.String() }) // manual
defs.UpdateUsers = Type.Partial(Type.Ref('Users')) // computed
```
### Incremental Construction
```typescript
// Build defs incrementally
const defs: Record<string, any> = {}
defs.Users = extractTableSchema(UsersElement)
defs.Tasks = extractTableSchema(TasksElement)
// Add a column later
defs.Users = Type.Object({ ...defs.Users.properties, role: Type.String() })
// Add relations
defs.UsersRelations = Type.Object({ tasks: Type.Array(Type.Ref('Tasks')) })
// Compile when ready
const M = Type.Module(defs)
```
## Validation
### Format Registration Required
TypeBox treats `format` as an annotation by default. To enforce format validation, register custom formats:
```typescript
import { FormatRegistry } from '@alkdev/typebox'
FormatRegistry.Set('uuid', (value) => /^[0-9a-f]{8}-...$/i.test(value))
FormatRegistry.Set('email', (value) => /^[^@]+@[^@]+\.[^@]+$/.test(value))
```
After registration, `Value.Check` enforces these formats.
### Validation Pattern
```typescript
const M = Type.Module(defs)
const Users = M.Import('Users')
// Valid
Value.Check(Users, { id: '550e8400-e29b-41d4-a716-446655440000', name: 'alice', email: 'a@b.com', ... })
// Invalid — Value.Check returns false, Value.Errors provides details
Value.Check(Users, { id: 'bad-uuid', ... }) // false
for (const err of Value.Errors(Users, badData)) { ... }
```
## Serialization
`JSON.stringify(M.Import(key))` produces JSON Schema with `$defs`:
```json
{
"$defs": {
"Users": { "$id": "Users", "type": "object", "properties": { ... }, "required": [...] },
"Tasks": { "$id": "Tasks", "type": "object", "properties": { ... }, "required": [...] },
"UsersRelations": { "$id": "UsersRelations", "type": "object", "properties": { "tasks": { "type": "array", "items": { "$ref": "Tasks" } } } }
},
"$ref": "Users"
}
```
Key properties:
- Each `$defs` entry has an `$id` matching its key
- `Type.Ref` remains as `{ "$ref": "Key" }` — not inlined
- The entire structure is valid JSON Schema
- All entries in the module are present in `$defs` (even if only one was imported)
### Roundtrip
The serialized form can be parsed back into a schema-like structure. `Value.Diff` works on these serialized objects to produce structural edit lists.
## Migration Diffing
```typescript
const v1 = JSON.parse(JSON.stringify(M.Import('Users')))
// Modify schema
defs.Users = Type.Object({ ...defs.Users.properties, role: Type.String() })
const M2 = Type.Module(defs)
const v2 = JSON.parse(JSON.stringify(M2.Import('Users')))
const edits = Value.Diff(v1, v2)
// [
// { type: 'insert', path: '/$defs/Users/properties/role', value: { type: 'string' } },
// { type: 'update', path: '/$defs/Users/required/3', value: 'role' },
// ]
```
Edits use JSON Pointer paths. A migration generator can translate these to:
- `INSERT` for new properties → `ALTER TABLE ADD COLUMN`
- `DELETE` for removed properties → `ALTER TABLE DROP COLUMN`
- `UPDATE` for type changes → `ALTER TABLE ALTER COLUMN TYPE`
This is structural diffing, not semantic — it doesn't understand that changing `Type.String()` to `Type.String({ maxLength: 255 })` is a constraint addition, not a type change. Semantic diffing is a future concern.
## Cross-Module References
`Module.Import()` embeds the source module's `$defs` in the resulting `TImport` schema. This enables referencing types from another module:
```typescript
const CommonM = Type.Module({ Uuid: Type.String({ format: 'uuid' }) })
const CommonUuid = CommonM.Import('Uuid')
const AppM = Type.Module({
User: Type.Object({ id: CommonUuid, name: Type.String() }),
})
```
However, this nests `$defs` within `$defs` (the User's `$defs` contains CommonUuid's `$defs`), which increases payload size. For dbtype's use case, keeping everything in a single module is simpler and avoids nesting.
## Constraints
- **Module entries are computed at construction time** — `Type.Partial(Type.Ref('Users'))` is resolved when the module is built, producing a concrete optional-property object
- **`Type.Ref` outside a module has `static: unknown`** — always use `M.Import(key)` for proper type inference
- **Module keys are a flat namespace** — no nested paths like `"tables/Users"`. Table names must be unique within the module.
- **`Module.Import` embeds all `$defs`** — every import carries the full module. This is correct for validation but increases JSON Schema size.
- **Symbol properties are lost in `JSON.stringify`** — `[Kind]`, `[Hint]`, etc. are stripped. The serialized form is JSON Schema, not TypeBox schema. Roundtripping requires `FromSchema` or reconstructed TypeBox objects.
## References
- TypeBox Module: `@alkdev/typebox/src/type/module/module.ts`
- TypeBox Format: `@alkdev/typebox/src/type/registry/format.ts`
- Probe: `scripts/probe-e2e.ts`

View File

@@ -0,0 +1,104 @@
---
status: draft
last_updated: 2026-05-22
---
# Open Questions Tracker
All unresolved architectural questions for dbtype, organized by theme.
## Schema & Module
### OQ-01: Should relation entries use a naming convention?
- **Origin**: [schema.md](schema.md)
- **Status**: Open
- **Priority**: Medium
- **Context**: Currently `UsersRelations` / `TasksRelations`. Is this naming convention sufficient, or should relations be structured differently? A `relations` field on the table entry would couple relations to table definitions.
- **Cross-references**: OQ-03
### OQ-02: Should derived schemas live in the module or be extracted separately?
- **Origin**: [schema.md](schema.md)
- **Status**: Open
- **Priority**: Low
- **Context**: Insert/update schemas can be added as module entries (`InsertUsers`, `UpdateUsers`) or extracted by walking the module. Adding them to the module increases size but makes everything accessible via `Import`. Extracting separately is more flexible but requires separate code paths.
- **Cross-references**: OQ-03
### OQ-03: Should the module support multiple database namespaces?
- **Origin**: [schema.md](schema.md)
- **Status**: Open
- **Priority**: Low
- **Context**: One module per database, or one module with all tables across all databases? Probably one per database namespace, but this needs confirmation from real use cases.
## Element & Host
### OQ-04: Should column elements support nested TypeBox schemas?
- **Origin**: [elements.md](elements.md)
- **Status**: Open
- **Priority**: High
- **Context**: The research docs proposed `DbType.String({ notNull: true, inner: Type.String({ format: 'email', maxLength: 255 }) })`. With UJSX elements, this would be `<column name="email" type="string" notNull format="email" maxLength={255} />`. The flat props model works for common cases, but custom validation constraints (patterns, ranges, custom checks) may need a nested `inner` prop. Should we support `<column name="email" type="string" notNull inner={Type.String({ format: 'email', maxLength: 255 })} />`?
- **Cross-references**: ADR-001
### OQ-05: How to handle PG enum pre-declaration?
- **Origin**: [hosts.md](hosts.md)
- **Status**: Open
- **Priority**: High
- **Context**: PG requires `pgEnum()` at module scope before tables that reference it. Options: (A) return both enums and tables from render, (B) start with text for all enums, (C) per-column opt-in with `postgres: { nativeEnum: true }`. Option A is cleanest but changes the render API. Option B is simplest but loses PG enum validation.
### OQ-06: Should hosts return rendered tables or store them in context?
- **Origin**: [hosts.md](hosts.md)
- **Status**: Open
- **Priority**: Medium
- **Context**: The probe scripts use `ctx.tables` to collect rendered tables. A more functional approach would have `render()` return the rendered table directly. Need to resolve this before implementation.
### OQ-07: Should we support JSX file extensions?
- **Origin**: [elements.md](elements.md)
- **Status**: Open
- **Priority**: Low
- **Context**: JSX syntax (`.tsx` with `jsxImportSource: '@alkdev/ujsx'`) would be more ergonomic than `h()` calls. This requires build configuration (TSConfig `jsx`, bundler support). The `h()` API works universally; JSX is a convenience layer.
## Repo Adapter
### OQ-08: Per-dialect handler differences?
- **Origin**: [repo-adapter.md](repo-adapter.md)
- **Status**: Open
- **Priority**: Medium
- **Context**: PG has `.returning()` on all mutations, MySQL often doesn't. Should the adapter handle this transparently (always try `.returning()`, fall back gracefully), or should it be explicit in the config?
### OQ-09: Relation rendering responsibility?
- **Origin**: [repo-adapter.md](repo-adapter.md)
- **Status**: Open
- **Priority**: Medium
- **Context**: Should the host render relations (new element type `<relation>`), or should the adapter generate them from the module's relation entries? The adapter knows the rendered table objects; the module knows the relation structure. Leaning toward the adapter generating them.
## Migration & Diffing
### OQ-10: How far should migration diffing go in phase 1?
- **Origin**: [module.md](module.md)
- **Status**: Open
- **Priority**: Low
- **Context**: `Value.Diff` on serialized schemas produces structural edit lists (insert/delete/update property). Translating these to `ALTER TABLE` statements is straightforward for additive changes (new column) but complex for destructive ones (drop column, change type). Phase 1 likely skips migration generation entirely (rely on `drizzle-kit`), but the module structure should not prevent it later.
## Summary Table
| ID | Question | Origin | Priority | Status |
|----|----------|--------|----------|--------|
| OQ-01 | Relation naming convention | schema.md | Medium | Open |
| OQ-02 | Derived schemas in module or separate | schema.md | Low | Open |
| OQ-03 | Multiple database namespaces | schema.md | Low | Open |
| OQ-04 | Nested TypeBox schemas in column props | elements.md | High | Open |
| OQ-05 | PG enum pre-declaration | hosts.md | High | Open |
| OQ-06 | Host render return value vs context | hosts.md | Medium | Open |
| OQ-07 | JSX file extensions | elements.md | Low | Open |
| OQ-08 | Per-dialect handler differences | repo-adapter.md | Medium | Open |
| OQ-09 | Relation rendering responsibility | repo-adapter.md | Medium | Open |
| OQ-10 | Migration diffing scope | module.md | Low | Open |

View File

@@ -0,0 +1,155 @@
---
status: draft
last_updated: 2026-05-22
---
# Repo Adapter: from-dbtype for @alkdev/operations
How dbtype schemas produce CRUD `OperationSpec`s 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 `OperationSpec`s: `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
```typescript
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` |
```typescript
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`:
```typescript
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:
```typescript
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 host must also render relations, not just tables
- **Returning clauses are dialect-specific** — PG supports `.returning()` on all mutations, MySQL does not. The adapter must handle this per-dialect.
## Open Questions
1. **Per-dialect handler differences?** PG has `.returning()` on all mutations, MySQL often doesn't. Should the adapter handle this transparently, or expose it in the config?
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.
3. **Relation rendering responsibility?** Should the host render relations (new `<relation>` element), or should the adapter generate them from the module's relation entries?
## 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)

166
docs/architecture/schema.md Normal file
View File

@@ -0,0 +1,166 @@
---
status: draft
last_updated: 2026-05-22
---
# Schema: Type.Module as the Schema Bundle
How dbtype uses `Type.Module` to store all table schemas, relations, and derived schemas in a single namespace with automatic `Type.Ref` resolution.
## Overview
The `Type.Module` is the central data structure in dbtype. It holds every table's TypeBox schema, all cross-table relations, and derived schemas (insert, update, select variants) in one flat namespace. `Type.Ref` resolves forward and circular references naturally, eliminating the need for separate relation files or import-order management.
The module is also the serialization boundary: `JSON.stringify(module.Import('Users'))` produces valid JSON Schema with `$defs`, enabling migration diffing via `Value.Diff`.
## Construction
### From Element Tree to Module
The element tree (`<table>`, `<column>`) is walked to extract a `Record<string, TSchema>` map, then compiled into a module:
```
UJSX elements → extractTable() → { name, schema, columns } → defs map → Type.Module(defs)
```
Each `<column>` element produces a TypeBox type based on its `type` prop:
| Column Type Prop | TypeBox Schema |
|-----------------|---------------|
| `uuid` | `Type.String({ format: 'uuid' })` |
| `string` | `Type.String()` |
| `integer` | `Type.Integer()` |
| `boolean` | `Type.Boolean()` |
| `timestamp` | `Type.Number()` |
| `enum` | `Type.Union(values.map(v => Type.Literal(v)))` |
### Incremental Construction
The defs map is a plain `Record<string, TSchema>` — it can be built incrementally, mutated, and extended before compilation:
```typescript
const defs: Record<string, any> = {}
// Add tables one at a time
defs.Users = Type.Object({ id: Type.String({ format: 'uuid' }), name: Type.String() })
defs.Tasks = Type.Object({ id: Type.String({ format: 'uuid' }), userId: Type.String({ format: 'uuid' }), title: Type.String() })
// Add columns to an existing table
defs.Users = Type.Object({ ...defs.Users.properties, role: Type.String() })
// Add relations
defs.UsersRelations = Type.Object({ tasks: Type.Array(Type.Ref('Tasks')) })
defs.TasksRelations = Type.Object({ user: Type.Ref('Users') })
// Compile
const M = Type.Module(defs)
```
Once compiled, `M.Import(key)` returns a `TImport` schema with the full `$defs` namespace embedded.
## Schema Derivation
### Select Schema
The module entry as-is is the select schema. Every column is present, nullable columns become `Type.Union([innerType, Type.Null()])`.
### Insert Schema
Derive from the table entry by:
- Removing auto-generated primary keys (columns with `primaryKey: true` and `default` set)
- Making nullable columns and columns with defaults `Type.Optional`
- Keeping required (`notNull` without default) columns mandatory
Implemented by adding a computed entry to the module:
```typescript
defs.InsertUsers = Type.Object({
name: Type.String(),
email: Type.String(),
// id, createdAt, updatedAt omitted (auto-generated)
})
```
### Update Schema
All columns optional. Use `Type.Partial(Type.Ref('TableName'))`:
```typescript
defs.UpdateUsers = Type.Partial(Type.Ref('Users'))
```
### Filter Schema
Per-column comparison operators derived from the column type. Generated by the repo adapter, not the core module.
## Relations
Relations are stored as separate entries in the module, using `Type.Ref` to reference other tables:
```typescript
defs.UsersRelations = Type.Object({ tasks: Type.Array(Type.Ref('Tasks')) })
defs.TasksRelations = Type.Object({ user: Type.Ref('Users') })
```
This gives:
- **Type-safe validation**: `Value.Check(M.Import('UsersRelations'), { tasks: [...] })` validates the full nested structure
- **No circular import issues**: `Type.Ref` resolves within the module namespace regardless of definition order
- **Queryable structure**: The `$defs` map is enumerable — you can find all relations for a table by naming convention
- **Drizzle integration**: The repo adapter reads relation entries to generate `relations()` calls for drizzle's relational query builder
Foreign key metadata lives on the column element's `references` prop (`<column name="userId" type="uuid" references="users" />`), not in the relation entry. Relations describe the "from this side, I see many of those" semantics.
## Serialization
`JSON.stringify(M.Import('TableName'))` produces JSON Schema with `$defs`:
```json
{
"$defs": {
"Users": { "$id": "Users", "type": "object", "properties": { ... } },
"Tasks": { "$id": "Tasks", "type": "object", "properties": { ... } },
"UsersRelations": { "$id": "UsersRelations", "type": "object", "properties": { "tasks": { "items": { "$ref": "Tasks" }, "type": "array" } } }
},
"$ref": "Users"
}
```
Key properties:
- Each `$defs` entry gets an `$id` matching its key name
- `Type.Ref` leaves `$ref` pointers (not inlined) — consumers must resolve them
- The serialized form is valid JSON Schema
- `Value.Diff` produces structural edits between two serialized schemas (useful for migration diffing)
## Migration Diffing
```typescript
const v1 = JSON.parse(JSON.stringify(M.Import('Users')))
// ... add a column to defs.Users ...
const v2 = JSON.parse(JSON.stringify(M2.Import('Users')))
const edits = Value.Diff(v1, v2)
// edits: [{ type: 'insert', path: '/$defs/Users/properties/role', value: { type: 'string' } }, ...]
```
The edits use JSON Pointer paths, which can be translated to `ALTER TABLE ADD COLUMN` statements.
## Constraints
- **Module keys must be unique** — two tables cannot have the same name in the same module
- **`Type.Ref` resolves within the module only** — no cross-module references without `Module.Import`
- **`Type.Ref` outside a module has `static: unknown`** — always access via `M.Import(key)` for proper type inference
- **Defs map is mutable until compiled** — once passed to `Type.Module`, mutations to the original map don't affect the compiled module
- **Format validation requires `FormatRegistry.Set`** — `uuid`, `email`, and other custom formats must be registered before `Value.Check` will enforce them
## Open Questions
1. **Should relation entries use a naming convention?** Currently `UsersRelations` / `TasksRelations`. Is this sufficient, or should relations be structured differently (e.g., a `relations` field on the table entry)?
2. **Derived schemas in the module or separate?** Insert/update schemas can be added as module entries (`InsertUsers`, `UpdateUsers`) or extracted by walking the module schema. Which is cleaner for the repo adapter?
3. **Should the module support multiple databases?** One module per database, or one module with all tables across all databases? Probably one per database namespace.
## References
- TypeBox Module API: `@alkdev/typebox` source — `type/module/module.ts`, `type/ref/ref.ts`
- Probe validation: `scripts/probe-e2e.ts`
- Research: `docs/research/architecture.md`