docs: resolve all open architecture questions with ADRs 009-014

Resolve OQ-01 through OQ-10 with formal ADRs and update all architecture
docs to reference decisions. Add two new tracked questions (OQ-11, OQ-12)
surfaced during review.

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

Also upgrades elements.md, hosts.md, repo-adapter.md status to stable,
clarifies OQ-06 as design clarification, and specifies MySQL .returning()
detection mechanism in ADR-012.
This commit is contained in:
2026-05-23 12:47:55 +00:00
parent d4fd67f4d2
commit 98764086f4
12 changed files with 481 additions and 42 deletions

View File

@@ -0,0 +1,55 @@
# ADR-009: Relation Entry Naming Convention — `FooRelations`
## Status
Accepted
## Context
Relation entries in the module describe what a table "sees" from its side of a relationship (e.g., `UsersRelations` lists the tasks belonging to a user). The question was whether these entries should use the `FooRelations` naming convention currently in place, or whether relations should be structured differently — for example, as a `relations` field on the table entry itself.
Alternative approaches considered:
**Option A: Separate `FooRelations` entries (current pattern)**`defs.UsersRelations = Type.Object({ tasks: Type.Array(Type.Ref('Tasks')) })`. Relations are their own entries in the module's `$defs` map, alongside table schemas.
**Option B: `relations` field on the table entry** — Adding a `relations` property to the table's TypeBox object, coupling relation data directly to the table schema.
**Option C: Different naming convention** — e.g., `UserToTasks`, `TaskToUser`, or `Users$relations`.
## Decision
Use Option A: the `FooRelations` naming convention. Relation entries remain separate module entries named `{TableName}Relations`.
## Rationale
1. **Aligns with Drizzle's convention**: Drizzle's relational query builder uses `relations(table, ({ one, many }) => ({ ... }))` which is similarly named — e.g., `usersRelations`. The `FooRelations` pattern maps directly.
2. **Stays within Type.Module's flat namespace**: Module keys are a flat namespace (no nested paths like `"tables/Users"`). Coupling relations onto the table entry would either require a special `relations` property (breaking the TypeBox schema model) or a nested `$defs` structure. Separate entries keep things flat and simple.
3. **Queryable by convention**: The `$defs` map is enumerable. Finding all relations for a table is straightforward: look for `${tableName}Relations` in the defs. This is discoverable and doesn't require special tooling.
4. **Independent lifecycle**: Relations can be added, modified, or removed independently of the table schema. This matters for the repo adapter, which reads relation entries to generate Drizzle `relations()` calls.
5. **No circular dependency risk**: `Type.Ref` resolves within the module namespace regardless of definition order. `UsersRelations` references `Tasks` and vice versa without import cycles.
6. **Consistent with existing probes**: The probe scripts already use this pattern successfully.
## Consequences
### Positive
- Simple, discoverable convention
- Aligns with Drizzle ecosystem naming
- No special-casing needed in module or extract logic
- Relations are independently addressable via `M.Import('UsersRelations')`
### Negative
- Relation names are coupled to table names by convention, not by type system. Renaming a table requires updating the corresponding `FooRelations` entry name.
- The `Relations` suffix occupies the namespace — a table named `UsersRelations` would conflict. This is unlikely but theoretically possible.
## References
- [schema.md](../schema.md) — Relations section
- [module.md](../module.md) — Module flat namespace constraint
- [open-questions.md](../open-questions.md) — OQ-01

View File

@@ -0,0 +1,87 @@
# ADR-010: One Module per Database, Include All Derived Schemas
## Status
Accepted
## Context
Two related questions about module structure:
**OQ-02**: Should derived schemas (insert, update) live in the module as entries, or be extracted separately by walking the module? Adding them to the module increases its size but makes everything accessible via `Import`. Extracting separately is more flexible but requires separate code paths.
**OQ-03**: Should the module support multiple database namespaces? One module per database, or one module with all tables across all databases?
These are intertwined because derived schemas only make sense in the context of a single database's tables, and the module scope decision affects how derived schemas are organized.
### OQ-02: Derived schemas in module vs. separate
**Option A: Include as module entries**`defs.InsertUsers`, `defs.UpdateUsers`, `defs.FilterUsers` are added to the module alongside tables and relations. Accessible via `M.Import('InsertUsers')`.
**Option B: Extract by walking** — Derived schemas are generated on-the-fly from the table entry and column metadata, without being stored in the module.
**Option C: Hybrid** — Include by default, but allow `includeDerived: false` to skip them.
### OQ-03: Module per database vs. single module
**Option A: One module per database namespace** — Each database (or schema) gets its own `Type.Module`. No cross-database references within one module.
**Option B: One module with all tables** — All tables across databases in one module, with naming conventions to avoid collisions.
## Decision
**OQ-02**: Include all derived schemas in the module by default (Option C, defaulting to inclusion).
A `repo_from` interface (or similar) will be added to generate CRUD operations in the same vein as drizzle-graphql, using dbtype's own operations system. This interface needs access to select, insert, update, and filter schemas. Since both the "include everything" and "extract separately" paths have valid use cases and the decision isn't hard to reverse, we default to including everything — making it accessible via `M.Import()` — and can add an opt-out later if needed.
**OQ-03**: One module per database namespace (Option A).
Each database namespace (e.g., `public`, `tenant_a`) gets its own `Type.Module` instance. This keeps the module's flat namespace clean, avoids table name collisions across databases, and aligns with how Drizzle organizes schemas.
## Rationale
### For including derived schemas (OQ-02)
1. **Convenience for the adapter**: The repo adapter needs insert, update, and filter schemas anyway. Having them in the module means one import path (`M.Import('InsertUsers')`) rather than a separate extraction step.
2. **`repo_from` needs them**: The `repo_from` interface generates operation specs from the module. It needs access to all derived schemas. Having them in the module is the simplest path.
3. **Not hard to reverse**: If including everything becomes a problem (e.g., module size for very large schemas), we can add `includeDerived: false` and generate them on-the-fly. The generation logic is the same either way.
4. **Consistency with the module pattern**: The module is already the single source of truth for table schemas and relations. Derived schemas are computed from the same source — they belong alongside.
### For one module per database (OQ-03)
1. **Drizzle alignment**: Drizzle organizes tables by schema/namespace. One module per database maps naturally to one Drizzle schema object.
2. **No namespace collisions**: The module is a flat namespace. Two databases could have tables with the same name — one module per database avoids collisions without naming hacks like `db1_users`.
3. **Independent evolution**: Each database's module can be versioned, validated, and diffed independently. Cross-database changes are separate concerns.
4. **Type.Ref stays local**: Cross-database references would require `Module.Import` nesting (see [module.md](../module.md) cross-module references), which increases payload size. One module per database keeps things simple.
## Consequences
### Positive
- All schemas accessible via `M.Import()` — one consistent API
- Module mirrors database structure — one module = one database
- No namespace collision risk
- `repo_from` has everything it needs in one place
- Easy to reverse the derived-schema decision if needed
### Negative
- Larger module size (insert, update, filter schemas for every table). For a schema with 20 tables, this adds ~80 additional entries (34 per table). This is acceptable given that modules are compiled once and imported many times.
- Cross-database joins would require consuming code to merge contexts from multiple modules. This is a phase 2+ concern.
### Future Considerations
- An `includeDerived: false` opt-out could be added to exclude insert/update/filter entries from the module, for consumers who only need table and relation schemas. This can be added without breaking the default behavior.
- The `repo_from` interface (phase 2) will be the primary consumer of derived schemas. Its design should be informed by how derived schemas are accessed in practice.
## References
- [schema.md](../schema.md) — Schema derivation section and OQ-02/OQ-03
- [module.md](../module.md) — Module construction and cross-module references
- [repo-adapter.md](../repo-adapter.md) — The `from-dbtype` adapter that will use `repo_from`

View File

@@ -0,0 +1,78 @@
# ADR-011: JSX/TSX File Support
## Status
Accepted
## Context
dbtype elements can be authored using `h()` calls from `@alkdev/ujsx`. The probe scripts use this pattern:
```typescript
const UsersEl = h('table', { name: 'users' },
h('column', { name: 'id', type: 'uuid', primaryKey: true, default: 'uuid' }),
h('column', { name: 'name', type: 'string', notNull: true }),
)
```
JSX syntax would be more ergonomic for authoring, especially for larger schemas:
```tsx
const UsersEl = (
<table name="users">
<column name="id" type="uuid" primaryKey default="uuid" />
<column name="name" type="string" notNull />
</table>
)
```
JSX requires:
- `.tsx` file extensions
- TSConfig `jsx` and `jsxImportSource` settings (`jsx: 'react-jsx'`, `jsxImportSource: '@alkdev/ujsx'`)
- Bundler/transpiler support for JSX transformation
The question is whether to support JSX as a first-class authoring format, and if so, what the implications are.
## Decision
Support JSX/TSX file extensions as an ergonomic authoring layer. JSX desugars to `h()` calls via the configured `jsxImportSource`, so the runtime representation is identical.
JSX support means:
- Element definition files can use `.tsx` extensions with `<table>`, `<column>`, `<index>`, `<fk>` tags
- TSConfig must set `jsx: 'react-jsx'` and `jsxImportSource: '@alkdev/ujsx'`
- The `h()` function remains the underlying API and works universally (in `.ts` files, in build steps, in non-JSX environments)
- Generated output (from the host render pipeline) will never use JSX — it produces Drizzle table objects directly
## Rationale
1. **Ergonomics for authoring**: Manually writing `h()` calls is verbose and error-prone for complex schemas. JSX provides a familiar, declarative syntax that is easier to read and write. This is especially important for dbtype's target audience — developers defining database schemas.
2. **Zero-cost abstraction**: JSX compiles to `h()` calls. There is no runtime overhead, no separate JSX runtime — just a different authoring syntax for the same element tree. The `jsxImportSource: '@alkdev/ujsx'` ensures the `h` import is automatic.
3. **Precedent in ecosystem**: React, Solid, Preact, and other UI libraries use this pattern. Developers are familiar with JSX for declarative tree structures. dbtype applies the same pattern to database schemas.
4. **No architectural impact**: JSX is purely an authoring convenience. The element tree, `extractTable()`, host rendering, and module compilation are all unchanged. If JSX support is removed, all schemas still work with `h()` calls.
5. **HostConfig already handles intrinsic elements**: The UJSX host only sees resolved intrinsic elements (`table`, `column`, `index`, `fk`). Whether those elements were created via `h()` or JSX is irrelevant to the rendering pipeline.
## Consequences
### Positive
- More ergonomic schema authoring
- Familiar declarative syntax for developers
- Zero runtime cost — compiles to same `h()` calls
- No changes to rendering pipeline or module architecture
### Negative
- Requires TSConfig configuration (`jsx`, `jsxImportSource`) for projects using JSX
- Some build tools may need additional configuration for `.tsx` files
- Two authoring styles (`h()` and JSX) could confuse new users — should be clearly documented with JSX as the recommended authoring format
- IDE support for custom JSX element types (autocompletion for `<table>`, `<column>` props) requires TypeScript type definitions for the UJSX element props
## References
- [elements.md](../elements.md) — Element definitions and function components
- [open-questions.md](../open-questions.md) — OQ-07
- UJSX HostConfig: `@alkdev/ujsx/src/host/config.ts`

View File

@@ -0,0 +1,68 @@
# ADR-012: Always `.returning()` with Graceful Fallback
## Status
Accepted
## Context
Different database dialects have different capabilities for returning data from mutation operations:
- **PostgreSQL**: Supports `.returning()` on all mutations (`INSERT`, `UPDATE`, `DELETE`). Returns the affected rows with requested columns.
- **SQLite**: Supports `.returning()` on all mutations (since SQLite 3.35.0 / Drizzle support).
- **MySQL**: Does **not** support `.returning()` on most mutations. `INSERT ... RETURNING` is not part of the MySQL protocol.Affected row count is available, but not the row data.
This creates a per-dialect handler difference for the repo adapter. The generated `OperationSpec` handlers for mutations (insert, update, delete) need to handle the fact that some dialects can return the modified rows and some cannot.
Options considered:
**Option A: Always use `.returning()`, let the dialect handle it** — Generate `.returning()` on all mutation handlers. Dialects that support it return the data. Dialects that don't will throw or silently drop it.
**Option B: Explicit per-dialect config** — The adapter config specifies whether `.returning()` is available. Handlers are generated differently per dialect.
**Option C: Always attempt `.returning()`, gracefully fallback** — Use `.returning()` on all mutations. For dialects where `.returning()` is not available, fall back to returning the input data (for inserts) or a count of affected rows (for updates/deletes).
## Decision
Use Option C: always use `.returning()` on mutation handlers, with graceful fallback for dialects that don't support it.
The adapter generates handlers that:
1. Always call `.returning()` on the Drizzle query builder
2. If `.returning()` succeeds, return the result rows
3. If `.returning()` is not supported by the dialect (MySQL), fall back to:
- `insertOne` / `insert`: Return the input data (validated by the insert schema)
- `update` / `delete`: Return a count of affected rows
The detection mechanism: the adapter checks the dialect string from the host config (`root.ctx.dialect`) to determine whether to include `.returning()` in the generated query, rather than catching runtime errors from Drizzle. This avoids try-catch in the query path and makes the behavior deterministic and predictable.
## Rationale
1. **Consistent API shape**: Callers of operation specs get the same return type regardless of dialect. This means the operations system doesn't need dialect-aware code paths in consumer logic.
2. **The operations system is an abstraction layer**: The whole point of the repo adapter is to hide dialect differences behind a uniform interface. Exposing `.returning()` availability in the config would leak dialect details to consumers.
3. **Graceful fallback is practical**: For inserts, the input data is already validated by the insert schema — returning it is a reasonable approximation of what `.returning()` would provide (minus auto-generated fields like IDs, which the caller can fetch separately if needed). For updates and deletes, the count of affected rows is useful and predictable.
4. **Drizzle already handles the dialect difference**: Drizzle's query builder throws on MySQL if you call `.returning()`. The adapter should catch this condition (or check dialect capabilities) rather than forcing consumers to handle it.
5. **Not hard to reverse**: If per-dialect configuration becomes necessary later (e.g., some operations need to opt out of `.returning()` for performance), the adapter can add a `returning: false` per-table override without breaking the default behavior.
## Consequences
### Positive
- Uniform mutation return types across all dialects
- No dialect-specific configuration needed for basic CRUD
- Consumers don't need to know whether the underlying DB supports `.returning()`
- SQLite and PG get full `.returning()` data; MySQL gets reasonable fallbacks
### Negative
- MySQL insert operations lose auto-generated column values in the return (they return validated input, not the DB-generated row). Consumers who need the generated ID must do a follow-up query.
- The return type for mutations is technically dialect-dependent at runtime, even if the TypeScript type says otherwise. This should be clearly documented.
- The fallback behavior adds a small amount of complexity to the adapter's handler generation logic.
## References
- [repo-adapter.md](../repo-adapter.md) — Handler generation section and OQ-08
- [open-questions.md](../open-questions.md) — OQ-08

View File

@@ -0,0 +1,61 @@
# ADR-013: Relation Rendering is the Adapter's Responsibility
## Status
Accepted
## Context
The repo adapter needs Drizzle `relations()` objects to power the relational query builder (`db.query[tableName].findMany({ with: ... })`). The question is who renders these relation objects:
**Option A: The host renders relations** — Add a new `<relation>` element type. The host would render it alongside tables, producing both `ctx.tables` and `ctx.relations`. This couples relation rendering to the host pipeline.
**Option B: The adapter generates relations** — The adapter reads the module's relation entries (`FooRelations`), uses the already-rendered table objects from `ctx.tables`, and generates the Drizzle `relations()` calls. The host doesn't need to know about relations at all.
**Option C: Hybrid — host renders, adapter consumes** — The host renders relation elements during the render walk, but the adapter is the primary consumer and dictates what shape the output takes.
## Decision
Use Option B: the adapter generates relation objects from the module's relation entries and the rendered table objects.
The adapter:
1. Reads relation entries from the compiled module (`M.Import('UsersRelations')`, `M.Import('TasksRelations')`)
2. Uses the rendered table objects available in `root.ctx.tables`
3. Generates the appropriate Drizzle `relations()` calls for each relation entry
4. Passes the full `{ tables, relations }` schema to the Drizzle relational query builder
The host rendering pipeline does not change — it produces `ctx.tables` (and `ctx.enums` for PG) as before. Relation generation is entirely in the adapter's domain.
## Rationale
1. **The adapter has the right context**: The adapter consumes both the element tree (for schema derivation) and the rendered Drizzle objects (for query execution). It has the table objects, the module compiled bundle, and the operation config. Generating relations from these inputs is a natural fit.
2. **The module already defines relations**: `UsersRelations = Type.Object({ tasks: Type.Array(Type.Ref('Tasks')) })` is already in the module. The adapter just needs to walk this definition and produce `relations(users, ({ many }) => ({ tasks: many(tasks) }))`. No new element type is needed.
3. **Relations are a query concern, not a schema concern**: Relations exist to power Drizzle's relational query builder. They don't affect the database schema (that's what FKs do). They're about how you query data, which is the adapter's domain.
4. **The host doesn't need another element type**: Adding `<relation>` to the host pipeline would require a new intrinsic element, a new `createInstance` path, and new rendering logic. This adds complexity to the host for a concern that's better handled by the adapter.
5. **The adapter knows which tables are rendered**: The adapter receives `root.ctx.tables` — the actual rendered Drizzle table objects. It can construct `relations()` calls using these real objects, which is exactly what Drizzle's API expects.
6. **Consistent with `repo_from` direction**: The `repo_from` interface will generate operation specs from the module and rendered objects. Relations are part of this generation — they define what `with` parameters are available in `findMany`/`findFirst` operations.
## Consequences
### Positive
- No new host element type — simpler host pipeline
- Relations are generated where they're consumed — the adapter
- The module's relation entries are the single source of truth
- The adapter can customize relation generation (e.g., one-to-one vs. one-to-many) without host changes
### Negative
- The adapter must implement relation generation logic, mapping from `Type.Object({ field: Type.Array(Type.Ref('Target')) })` to `relations(source, ({ one, many }) => ({ field: many(target) }))`. This is straightforward but not trivial for complex relation patterns.
- If a consumer uses the host directly (without the repo adapter), they won't get relation objects. This is acceptable — relations are only needed for the relational query builder, which is an adapter-level concern.
## References
- [repo-adapter.md](../repo-adapter.md) — Relations section and OQ-09
- [schema.md](../schema.md) — Relations section
- [open-questions.md](../open-questions.md) — OQ-09

View File

@@ -0,0 +1,64 @@
# ADR-014: Leverage drizzle-kit for Migrations (Phase 1+)
## Status
Accepted
## Context
The module architecture supports structural diffing via `Value.Diff` on serialized schemas. Translating these structural edit lists (insert/delete/update property) to `ALTER TABLE` statements is straightforward for additive changes (new column) but complex for destructive ones (drop column, change column type).
The question is how far the migration diffing should go in phase 1, and whether dbtype should implement its own migration generator.
Options considered:
**Option A: No migration generation in phase 1** — Rely entirely on `drizzle-kit` for migrations. dbtype focuses on schema definition and validation; `drizzle-kit` handles the migration workflow.
**Option B: Basic additive migrations in phase 1** — Implement `ALTER TABLE ADD COLUMN` for new columns only. Skip destructive migrations.
**Option C: Full migration generation** — Translate all `Value.Diff` edit types to SQL migrations.
## Decision
Use Option A: leverage `drizzle-kit`'s existing migration tools. dbtype does not implement migration generation in phase 1 or the foreseeable future.
The rationale is that dbtype's primary output for the host is Drizzle table definitions. `drizzle-kit` already handles migration generation from Drizzle table definitions. Since dbtype renders to Drizzle tables, the natural migration workflow is:
1. Define schemas with dbtype elements
2. Render to Drizzle table definitions via the host
3. Run `drizzle-kit generate` to produce migration files from the Drizzle tables
4. Run `drizzle-kit migrate` to apply them
The module structure (with `Value.Diff` support) should not be changed or constrained — it remains available for future migration generation if needed.
## Rationale
1. **drizzle-kit already solves this**: Drizzle has invested significant effort in migration generation, introspection, and push workflows. Reimplementing this in dbtype would be duplicative and fragile.
2. **dbtype's output is Drizzle tables**: The host renders element trees to Drizzle table objects. `drizzle-kit generate` works on Drizzle table objects. There's no gap — dbtype produces exactly what `drizzle-kit` needs.
3. **Migration generation is a hard problem**: Correct handling of destructive migrations, column type changes, constraint additions/removals, data migrations, and rollback support requires significant investment. This is not a differentiating feature for dbtype.
4. **`Value.Diff` remains available**: The module's structural diffing capability (see [module.md](../module.md)) is not removed or disabled. It remains as a foundation for future migration tooling if the need arises.
5. **The module structure doesn't prevent future migration generation**: The `Value.Diff` edit list format (insert/delete/update property) and the serialized JSON Schema form are deliberately general. If dbtype ever needs to generate migrations directly, the foundation is there.
## Consequences
### Positive
- No migration infrastructure to build and maintain
- Leverages drizzle-kit's mature, well-tested migration workflow
- Phase 1 scope stays focused on schema definition, validation, and rendering
- Module structure remains migration-ready without committing to implementation
### Negative
- Consumers must use `drizzle-kit` for migrations — there's no dbtype-native migration path
- If `drizzle-kit` doesn't support a particular dialect's migration feature, dbtype can't fill that gap without implementing its own migration generator
- The `Value.Diff` capability in the module is documented but not exercised by any production code path yet
## References
- [module.md](../module.md) — Migration diffing section
- [open-questions.md](../open-questions.md) — OQ-10