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:
147
docs/architecture/README.md
Normal file
147
docs/architecture/README.md
Normal 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)
|
||||
91
docs/architecture/build-distribution.md
Normal file
91
docs/architecture/build-distribution.md
Normal 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`
|
||||
51
docs/architecture/decisions/001-ujsx-as-ir.md
Normal file
51
docs/architecture/decisions/001-ujsx-as-ir.md
Normal 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/`
|
||||
50
docs/architecture/decisions/002-type-module-as-bundle.md
Normal file
50
docs/architecture/decisions/002-type-module-as-bundle.md
Normal 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`
|
||||
49
docs/architecture/decisions/003-hostconfig-for-dialects.md
Normal file
49
docs/architecture/decisions/003-hostconfig-for-dialects.md
Normal 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)
|
||||
46
docs/architecture/decisions/004-format-annotation-only.md
Normal file
46
docs/architecture/decisions/004-format-annotation-only.md
Normal 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`
|
||||
47
docs/architecture/decisions/005-repo-as-adapter.md
Normal file
47
docs/architecture/decisions/005-repo-as-adapter.md
Normal 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)
|
||||
175
docs/architecture/elements.md
Normal file
175
docs/architecture/elements.md
Normal 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
151
docs/architecture/hosts.md
Normal 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
170
docs/architecture/module.md
Normal 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`
|
||||
104
docs/architecture/open-questions.md
Normal file
104
docs/architecture/open-questions.md
Normal 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 |
|
||||
155
docs/architecture/repo-adapter.md
Normal file
155
docs/architecture/repo-adapter.md
Normal 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
166
docs/architecture/schema.md
Normal 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`
|
||||
Reference in New Issue
Block a user