Arquitetura¶
Esta página explica as decisões de design do tempest-db-js — por que ele é do jeito que é. Se você só quer usar o ORM, o Tutorial basta. Se quer entender (ou contribuir), comece aqui.
A restrição central: TypeScript apaga tipos¶
O SQLAlchemy 2.0 consegue ler Mapped[int] em runtime, porque o Python mantém
as anotações de tipo acessíveis via typing.get_type_hints e usa descriptors pra
fazer User.id significar coisas diferentes no acesso de classe (referência de
coluna) e de instância (valor).
O TypeScript não tem nada disso: os tipos são apagados na compilação. Em runtime,
id: number simplesmente não existe. Logo, uma classe assim seria invisível pro
ORM:
class User {
id: number; // ❌ some em runtime — o ORM não sabe que existe uma coluna
name: string;
}
A solução: a coluna é um valor¶
O tempest-db-js faz cada coluna ser um valor em runtime que carrega o tipo:
class User extends Model {
static tablename = "users";
id = column.integer().primaryKey(); // runtime: objeto Column | tipo: Column<number, {...}>
name = column.text().notNull();
}
O objeto Column:
- guarda em runtime o tipo SQL (
"INTEGER") e flags (primaryKey,notNull,hasDefault); - carrega um tipo fantasma
T(via um símbolodeclare) que existe só no sistema de tipos, nunca em runtime.
A partir disso, mapped types extraem a forma da linha:
type ColValue<Col> = Col extends Column<infer T, infer F>
? F extends { notNull: true } | { primaryKey: true } ? T : T | null
: never;
type InferModel<C> = { [K in ColumnKeys<InstanceType<C>>]: ColValue<InstanceType<C>[K]> };
Mesmo princípio do Drizzle/Kysely
Drizzle e Kysely resolveram o mesmo problema do mesmo jeito: a coluna é um builder-valor, não uma anotação. O tempest-db-js adota essa base e a embrulha numa classe declarativa, pra ficar perto do SQLAlchemy.
O trade-off honesto¶
Como a coluna é um valor, a linha retornada não pode ser uma instância da classe
com métodos (não dá pra User.id ser ao mesmo tempo Column<number> pra montar
query e number pra ler valor, sem o truque de descriptor do Python). Então:
- Linhas são objetos planos inferidos (
InferModel), não instâncias ativas. - Active-record (métodos na instância de linha, tipo
user.save()) fica como objetivo pós-MVP.
Em troca, ganhamos inferência de query forte — o que mais importa num ORM tipado.
O query builder: AST pura + tipos fantasma¶
Os builders (select, insert, update, del) não executam nada. Cada um:
- acumula uma AST serializável (
SelectNode,InsertNode, ...), exposta em.node; - carrega tipos fantasma que descrevem o resultado, sem custo de runtime.
A execução é uma camada separada (session.execute + dialetos), que compila a
AST pra SQL parametrizado e roda contra o banco. Separar "montar" de "executar"
deixa toda a type-safety testável só com tsc (sem precisar de banco) e torna cada
builder reaproveitável em qualquer sessão.
Dois parâmetros de tipo no select¶
Full— a linha completa. Usado pra tipar as chaves dewhere/orderBy.Proj— a projeção. É o que a execução retorna.
Sem projeção, Proj = Full. Com select(User, ["id"]), Proj = Pick<Full, "id">.
Separar os dois permite filtrar por uma coluna que não está na projeção.
O guard de estado em UPDATE/DELETE¶
update e del carregam um parâmetro de tipo Guarded extends boolean:
- nascem com
Guarded = false; .where(...)ou.unguarded()produzemGuarded = true;- o
session.executeaceita só buildersGuarded = true(tipoExecutable).
Resultado: um UPDATE/DELETE sem WHERE e sem opt-in explícito é erro de
compilação, não um acidente em produção. Veja
Inserir, atualizar, deletar.
Por que tudo isso é testável com tsc¶
Como builders são puro tipo + AST, os testes do tempest-db-js são majoritariamente
testes de tipo (expectTypeOf, @ts-expect-error). Um filtro com chave inválida
ou um update sem guard falha a compilação — e isso é exatamente o que os testes
verificam. Num ORM tipado, o tipo é o produto, então o teste de tipo é o teste de
produto.
Mapa dos módulos¶
| Módulo | Responsabilidade |
|---|---|
src/index.ts |
Model, column, InferModel/InferInsert, sql + re-exports |
src/query.ts |
select, SelectBuilder, AST de SELECT, WhereInput, operadores |
src/mutations.ts |
insert/update/del, builders, guard de estado, AST |
src/conditions.ts |
combinadores and/or/not e a árvore Condition |
src/dialect.ts |
compila AST → SQL parametrizado (SqliteDialect/PostgresDialect) |
src/engine.ts |
createEngine/createSyncEngine, sessão, transações, drivers |
src/join.ts |
join, tipos compostos por alias, nullability de leftJoin |
src/relations.ts |
hasMany/belongsTo + loadRelations (eager-load sem N+1) |
src/repository.ts |
BaseRepository<Model> — CRUD + paginação tipada |
src/serialize.ts |
coerção linha ↔ dict ↔ JSON por tipo de coluna |
src/migrations/* |
Schema IR, diff, codegen, DAG, runner, CLI (estilo Alembic) |
Recap¶
- TS apaga tipos → a coluna precisa ser um valor que carrega o tipo.
- Linhas são objetos planos inferidos; active-record é pós-MVP.
- Builders são AST pura + tipos fantasma; a execução é uma camada separada.
SelectBuilder<Full, Proj>separa chave de filtro do resultado projetado.Guarded extends booleantransforma full-table write acidental em erro de compilação.