Changelog¶
O formato segue Keep a Changelog e o projeto adota Versionamento Semântico.
[0.9.1] — 2026-09-05¶
Correção de empacotamento encontrada ao validar o artefato publicado da 0.9.0.
Corrigido¶
- Cada entrada do pacote publicava uma cópia própria do core, e a consequência era
silenciosa:
tempest-db-js/migrationse o bináriotempest-dbtraziam o seuColumn, então um model construído pela entrada principal reflete como se não tivesse coluna nenhuma —instanceof Columncomparava duas classes diferentes. Na prática,reflectTabledevolvia{}, oCREATE TABLEsaía só com as constraints e otempest-db checknão via nada. Agora o core é empacotado uma vez, na raiz, e as outras entradas o carregam pelo próprio nome do pacote (self-reference viaexports), com teste de empacotamento sobre os arquivos construídos — o defeito é invisível na árvore de origem, que é um grafo de módulos só (#48 expôs, mas atinge todo o fluxo de migração desde que a subentrada existe).
[0.9.0] — 2026-09-05¶
O ciclo das issues #24–#51: 28 entregas em cima da análise que comparou este pacote com a
camada de banco do tempest-fastapi-sdk e com a superfície do SQLAlchemy 2.0. Três eixos:
corretude (FK que não era verificada, chave composta truncada, formato de data que o
próprio pacote não lia de volta), superfície de query (CTE, janela, conjunto, EXISTS,
CASE/CAST, escrita que lê outra tabela) e o que todo serviço reescrevia por cima do
builder (mixins, cursor pagination, signals, outbox, auditoria, tenant, erro de
integridade, backup).
⚠️ Breaking¶
-
O 3º parâmetro de
SyncSession/AsyncSessionpassou deQueryLoggerparaQueryHooks({ onQuery, onQueryEnd, slowQueryMs }), e o mesmo nos construtores deSyncEngine/AsyncEngine. Quem usacreateEngine/createSyncEnginenão é afetado; quem instanciava sessão ou engine à mão passando a função de log trocaloggerpor{ onQuery: logger }(#29). -
SQLite passa a verificar
FOREIGN KEY. Antes a verificação ficava desligada (o default do próprio SQLite, por conexão), entãoINSERTórfão passava eON DELETE CASCADEnunca disparava. Agora o engine liga na abertura de toda conexão SQLite, nos dois drivers. Base que já tinha linha órfã passa a recusar a escrita que a toca — o que é o ponto. Escape:{ sqlite: { foreignKeys: false } }(#24).
Adicionado¶
-
Unit of work com identity map, opt-in —
session.unitOfWork()devolve um mapa de identidade mais um log de mudanças:getda mesma chave duas vezes devolve a mesma instância sem segunda query, e umflush()escreve tudo numa transação (inserts → updates → deletes, a ordem que mantém FK satisfeita quando pai novo e filhos vão juntos). O update leva só o que mudou, comDateeUint8Arraycomparados por conteúdo — por referência, toda linha com data seria reescrita a cada flush. Falha no meio faz rollback do conjunto e preserva o estado rastreado, para corrigir e reflushar.Tracked<Row>distingue no tipo a linha rastreada da solta. O caminho padrão de objeto simples não muda (#51). -
aliased(Model, alias)— lê o mesmo model sob outro nome (FROM "employees" AS "sub"). O join builder já exigia alias, mas oselect()não tinha como nomear a tabela, e sem isso uma subquery correlacionada sobre a mesma tabela não era expressável: os dois lados teriam o mesmo nome. O resultado é um model de verdade — colunas, naming e codecs iguais —, entãoselect,join,col("alias.coluna")e a coerção de linha o aceitam sem caso especial. As table args não vão junto: alias é um jeito de ler a tabela, não uma segunda declaração dela (#50). -
customType({ base, toDb, fromDb })— tipo de coluna próprio (Money em centavos,Temporal, id com brand, value object). A conversão roda nos três lugares onde importa: escrita (values/set), leitura (coerção de linha,RETURNING,stream, join) e operando dewhere— inclusive cada elemento de umine os dois extremos de umbetween. O DDL e o IR continuam usando o tipobase, então um tipo próprio é invisível ao schema e não pode gerar drift.nullpassa direto, e uma expressão SQL não é convertida — é renderizada (#49). -
check()eindex()emtableArgs—CHECK(com a expressão na mesma linguagem dowhere, não string crua, para o diff comparar árvore em vez de texto) e índices, incluindo único e parcial. Entram no IR, no diff, no DDL e na introspecção: a migração cria e derruba, e o rebuild de tabela do SQLite recria os índices em vez de deixá-los sumir junto com a tabela antiga.tempest-db checkpassou a comparar índice explícito nos dois bancos;CHECKe índice parcial ficam de fora da comparação de propósito — o banco devolve a expressão como texto, e comparar texto com a árvore acusaria diferença a cada grafia. Índice parcial no MySQL lança (#48). -
Escrita que lê outra tabela —
insert(M).fromSelect(colunas, query)(INSERT ... SELECT, com as linhas sem passar pelo processo Node),update(M).from(Other, alias)edel(M).using(Other, alias). Onde o dialeto não tem a cláusula, o compilador lança com a alternativa na mensagem (UPDATE ... FROMnão existe no MySQL;DELETE ... USINGnão existe no SQLite nem no MySQL): emitir mesmo assim daria erro do servidor, e ignorar mudaria quais linhas são escritas (#47). -
set()/values()aceitam referência de coluna (col("c.tier")), não só valor esql.raw— sem isso,UPDATE ... FROMnão teria como escrever o valor vindo da outra tabela (#47). -
CTE:
cteecteRecursive—WITHeWITH RECURSIVE. O.modelda CTE é um model de verdade com o nome dela como tabela, entãoselect,joinewherea aceitam sem caso especial. A forma recursiva percorre árvore numa query só; oRECURSIVEé emitido para a cláusula quando qualquer entrada é recursiva, como manda o padrão. Hint de materialização (MATERIALIZED/NOT MATERIALIZED) para PostgreSQL 12+ (#46). -
join(...).pick(alias)— projeta um source do join, com nomes de coluna simples em vez da linha composta. É o que faz um join caber onde umSELECTde uma tabela cabe: o ramo recursivo de uma CTE, um ramo deUNION. Sem isso, as colunas do ramo ("c.id") não batem com as da CTE. Operações de conjunto passaram a aceitar um ramo de join assim (#46). -
Funções de janela e
select().compute()—over(fn, { partitionBy, orderBy, frame })comrowNumber,rank,denseRank,percentRank,lag,lead,firstValue,lastValuee qualquer agregação.compute({ alias: expressão })projeta por alias sem agrupar, então a linha continua e o valor vem junto — e o alias entra no tipo da linha, porqueExpressionpassou a carregar o tipo do valor. Função que só existe dentro de janela devolve umWindowFn, que sóover()aceita: usarlag()semOVERdeixa de ser erro de runtime do banco e vira erro de compilação (#45). -
Backup e restore, no CLI e programáticos —
tempest-db backup <arquivo> --urletempest-db restore, maisbackupDatabase/restoreDatabase. PostgreSQL usapg_dump/pg_restore/psqlcom o formato escolhido pela extensão (.sqlplano, o resto custom), e a senha vai porPGPASSWORD— nunca emargv, que qualquer processo da máquina lê. SQLite usaVACUUM INTO, não cópia de arquivo: com WAL ligado o.dbsozinho não é o banco inteiro, e oVACUUM INTOé consistente mesmo com outra conexão escrevendo. Sufixo de driver (postgresql+asyncpg) é removido antes de chamar a ferramenta, e ferramenta ausente viraBackupToolMissing. Os dois comandos são despachados antes de carregar o config de migração — banco que ainda não migra é justamente o que precisa de dump (#44). -
Trilha de auditoria append-only —
auditLogModel(tabela)para o schema eenableAudit(Model, { log, actor, exclude })para ligar. Uma entrada por create/update/delete, comrowKey(chave composta inteira), ação, ator resolvido na hora da escrita e um diff{ coluna: [antes, depois] }que traz só o que mudou — update sem delta não gera entrada. Implementado sobre os signals (#36), então a entrada é escrita na mesma session e, portanto, na mesma transação da mudança: rollback leva a entrada junto, porque trilha que registra mudança não-commitada é pior que trilha nenhuma (#43). -
TenantScopedRepository— repository preso a um tenant: o predicado entra em toda leitura e a coluna é carimbada em toda escrita, porque os métodos doBaseRepositorypassam a atravessar um ponto único de escopo (scopeFilters/scopeWrite, protegidos e sobrescrevíveis) em vez de cada um lembrar doWHERE. Linha de outro tenant é "não encontrada", não "proibida" — distinguir já seria vazamento —, e escrever nomeando outro tenant lança em vez de ser sobrescrito em silêncio. Modelo sem a coluna faz o construtor lançar (#42). -
engine.explain(fn)— captura o plano de todo statement que um bloco roda, com os parâmetros que o código realmente usou (o bloco recebe uma session gravadora).EXPLAIN (FORMAT JSON)no PostgreSQL,EXPLAIN QUERY PLANno SQLite, mais umsummary()legível por plano.analyze: trueexecuta o statement para medir, então é recusado em escrita — analisar umUPDATEo aplicaria duas vezes — e o SQLite lança, porqueEXPLAIN ANALYZEnão existe lá (#41). -
Outbox transacional —
outboxModel(tabela)para o schema eOutboxRepositorypara o relay:publish,pending,claim,markSent,markFailedcom backoff e desistência permanente.claimusaFOR UPDATE SKIP LOCKEDsobre subquery, então dois relays concorrentes pegam lotes disjuntos, e incrementaattemptsno próprio claim, o que dá a política de dead letter de graça.publish()não abre transação própria de propósito: a atomicidade vem dotransaction()re-entrante, com a linha de negócio e o evento na mesma session — umsaveWithOutboxseria um segundo jeito de fazer a mesma coisa (#40). -
Operações de conjunto:
union,unionAll,intersecteexcept— combinam dois ou mais SELECTs num builder executável como qualquer outro, com a forma dos ramos verificada em tempo de compilação (é o erro que o banco só reporta em runtime).orderBy/limit/offsetvalem para o conjunto; ramo com ordenação ou limite próprios é parentetizado, senão aquelas cláusulas passariam a valer para o combinado — outra query.INTERSECT/EXCEPTno MySQL lançam, por escopo (#39). -
exists/notExistsescalar—EXISTS (...)correlacionado, que é a forma certa quando só importa a existência (o banco para na primeira linha que casa, o que umINsobre conjunto materializado não faz), e subquery escalar como valor.scalar()recebe o resultado de.asSubquery(coluna), então uma subquery escalar de duas colunas vira erro de compilação em vez de erro de runtime no banco (#38). -
whereaceita expressão na forma objeto —{ userId: col("users.id") }compara duas colunas, e{ total: { gt: col("paid") } }também. Antes, expressão nessa posição era ligada como parâmetro: a comparação virava coluna contra a string"users.id", silenciosamente. Referência qualificada (tabela.coluna) passou a ser resolvida como"tabela"."coluna"em vez de virar um identificador só (#38). -
BaseRepository:existsExcluding,bulkUpsert,softDelete/restore,deleteBatchechangesSince— as operações que todo serviço reescrevia por cima do builder.changesSinceé o read de delta sync: filtro estrito porupdatedAt, ordem do mais antigo, desempate pela PK, e umserverTimelido antes da query como marca d'água (usar o maiorupdatedAtrecebido deixaria a linha commitada durante a página cair no vão entre dois pulls). Linha soft-deletada volta como tombstone, que é o que faz o cliente apagar a cópia local.softDelete/restore/changesSincelançam nomeando o mixin quando o modelo não tem a coluna (#37). -
sql.excluded(coluna)— referencia a linha que está entrando num upsert (excluded."col"no PostgreSQL/SQLite,VALUES(col)no MySQL). Sem isso, um upsert de N linhas não tem como escrever o valor novo, porque cada linha tem o seu (#37). -
Signals do repositório —
preSave,postSave,preDeleteepostDeleteem volta decreate/createMany/update/delete, por linha. Handler que lança numpre*veta a escrita; o payload traz a mesma session, então handler que escreve comita (ou faz rollback) junto com a escrita observada.update/deleterecebem filtro, não linha, então a leitura extra que entrega a linha ao handler só acontece quando existe handler (hasHandlers) — sem uso, custo zero.clearSignalspara teste (#36). -
Busca de texto:
contains, o operadoriContains,escapeLike,fullTextefullTextRank— a camada portátil tokeniza o termo e escapa cada token, com a cláusulaESCAPEemitida sempre (o PostgreSQL assume\\por padrão, o SQLite não tem escape nenhum até declarar um). Antes, o operando delike/ilikeia cru: quem buscava100%casava com a tabela inteira. A camada do PostgreSQL usato_tsvector/websearch_to_tsquery/ts_ranke, fora dele, compila comocontains— as linhas certas, sem stemming, degradação documentada em vez de erro.escapeLikeexistia só na docstring doilike; agora existe de verdade (#35). -
orderByaceita expressão além de nome de coluna — sem isso não há como ordenar por relevância (#35). -
parseIntegrityError— lê o erro do driver de volta para a constraint que recusou a escrita:{ violation, constraint, table, columns, detail }, ounullquando não é violação de integridade. É o que separa409 EMAIL_TAKENde um conflito genérico sem cada serviço escrever a própria regex. Segue a cadeia decause(então oQueryExecutionErrornão atrapalha), entende as duas formas de reportar código do SQLite (node:sqlitenumérico,better-sqlite3nomeado) e o SQLSTATE +DETAIL:do PostgreSQL — de onde saem todas as colunas de uma constraint composta. Passando o modelo, os nomes voltam como propriedade em vez de coluna. MySQL devolvenull, por escopo (#34). -
pool.prePingepool.recycleMs— o que faltava para conexão que morre sem avisar (failover, restart de pgbouncer, firewall cortando socket ocioso).prePingvalida a conexão comSELECT 1antes de pinar para a transação, que é onde o estrago é pior:BEGINpassa e o bloco morre pela metade.recycleMsvira omax_lifetimedo postgres.js. Os dois são PostgreSQL: no MySQL lançam, porque o mysql2 não tem knob equivalente, e no SQLite o blocopoolnão se aplica (#33). -
Nível de isolamento e bloco somente-leitura por transação —
transaction(fn, { isolation, readOnly }). O PostgreSQL põe tudo no próprioBEGIN; o MySQL precisa de umSET TRANSACTIONantes e usaSTART TRANSACTION READ ONLY; o SQLite só implementaserializablee lança para qualquer outro nível ou parareadOnly, em vez de aceitar em silêncio uma garantia que não dá. Característica pedida em bloco aninhado também lança: isolamento é fixado quando a transação abre (#32). -
caseWhenecast—CASE WHEN ... THEN ... ELSE ... ENDeCAST(x AS tipo)como expressões de primeira classe. Os ramos doCASEusam a mesma linguagem dewhere, sem gramática nova, e o alvo doCASTé um vocabulário portátil que cada dialeto renderiza com o nome que aceita (integeréINTEGERno PostgreSQL e no SQLite,SIGNEDno MySQL). Antes as duas saíam porsql.raw, perdendo tipo (#31). -
Agregação sobre expressão —
sum/avg/min/maxpassam a aceitar expressão além de nome de coluna, que é o que torna a agregação condicional (SUM(CASE WHEN status = 'paid' THEN total ELSE 0 END)) expressável: uma passada na tabela em vez de uma query por bucket (#31). -
transaction()re-entrante — bloco aninhado na mesma sessão adere ao de fora: umBEGIN, umCOMMIT, e a falha interna faz rollback do conjunto. É o que faz um service que orquestra vários repositories funcionar, já que todos seguram a mesma sessão.session.transactionDepthesession.inTransactionexpõem o estado (#30). -
AsyncSession.beginNested— savepoint no caminho async, que só existia noSyncSessionapesar de a referência da API documentarsession.beginNested(fn)sem qualificar. O caminho async é o default do PostgreSQL, justamente onde savepoint mais importa: era impossível recuperar falha parcial sem derrubar a transação inteira (#30). -
onQueryEndeslowQueryMsemEngineOptions— o par doonQuery, que dispara antes do statement e por isso não mede nada. O novo hook dispara depois, comdurationMs,rowCounte — no caminho de erro — oerrordo driver: statement lento que ainda falha é o que mais interessa.slowQueryMsfiltra por limiar, o que dá um log de query lenta sem agente de APM. Emstream(), o tempo vai até o fim da iteração. Erro lançado no hook é engolido, como nos outros (#29). -
BaseRepository.cursorPaginate— paginação por cursor:{ items, nextCursor }, semCOUNT(*)e com fronteira estável sob insert concorrente, que é o que a paginação por offset não dá em tabela grande. A chave primária é sempre anexada como desempate (composta entra inteira), então empate noorderBynão faz linha sumir entre páginas. O cursor é opaco e validado: corrompido, de outra versão ou gerado com outroorderBylançaInvalidCursorem vez de montar umWHEREerrado. A comparação é escrita na forma expandida (a > v OR (a = v AND b > w)), não como row value, porque o suporte a tupla varia entre os bancos (#27). -
encodeColumnValue/decodeColumnValue— os codecs por coluna que a serialização já usava, agora exportados: é o que permite guardar um valor de coluna fora do banco (num cursor, num cache) e trazê-lo de volta com o tipo certo. -
Mixins de modelo —
withTimestamps(createdAt/updatedAt),withSoftDelete(deletedAt, maisnotDeleted()/onlyDeleted()para owhere) ewithAudit(createdBy/updatedBy, com o tipo do autor configurável por fábrica). São funções que recebem a classe base e devolvem a subclasse, então compõem (withAudit(withSoftDelete(withTimestamps(Model)))) e contribuem colunas de verdade: aparecem noInferModel, noInferInsert, no IR de migração e no DDL (#26). -
defaultAsWriteValue— converte o default guardado numa coluna (DefaultValue) para o que o caminho de escrita renderiza. Era a peça que faltava entre o formato do IR e o deset()/values(). -
EngineOptions.sqlite— pragmas por conexão aplicados na abertura:foreignKeys(defaulttrue),journalMode,busyTimeoutMs,synchronous. Cada um é relido depois de escrito, porque o SQLite responde a um pragma que não pode honrar mantendo o valor antigo e não dizendo nada:journalMode: "wal"num banco:memory:agora lança em vez de fingir.sqlitenum engine PostgreSQL/MySQL lança (#28).
Corrigido¶
-
Statement que devolve linha era decidido por um regex incompleto. O caminho do
node:sqliteescolhe entreall()erun()antes de executar, e o teste cobria sóSELECT/PRAGMA— entãoEXPLAIN,WITH ... SELECT,VALUESeTABLEeram rodados como se não devolvessem nada e reportavam zero linha, em silêncio, em vez de falhar. Obetter-sqlite3não sofria (ele pergunta ao statement), o que deixava os dois drivers discordando (#41). -
sql.now()no SQLite gravava num formato que o próprio pacote não lê.CURRENT_TIMESTAMPproduz"YYYY-MM-DD HH:MM:SS"— semT, sem milissegundo, sem fuso —, e o JS parseia isso como horário local: uma linha escrita às 21:00Z era lida como 00:00Z numa máquina UTC-3. Pior, comparar a coluna contra umDateligado (ISO) comparava" "com"T"e não casava nada, em silêncio. Agora o SQLite renderizastrftime('%Y-%m-%dT%H:%M:%fZ','now'), exatamente o formato que o pacote liga e lê. Afeta o default do DDL e o valor deonUpdate; base já gravada com o formato antigo precisa de umUPDATEde conversão (#37). -
Column.onUpdate()passou a ser aplicado. O valor era guardado na coluna e nunca consumido — nem no DDL, nem no builder —, então.onUpdate(sql.now())não fazia nada, apesar de a receitacreated_at / updated_atdocumentar que "o valor é reaplicado a cada UPDATE". Agora oUpdateBuilderinjeta o valor de toda coluna comonUpdateque oset()não menciona. Aplicado na escrita, não no schema: só o MySQL temON UPDATEde coluna, e renderizar no DDL faria o mesmo modelo divergir por banco. Valor explícito noset()continua vencendo (#26). -
Chave primária composta é respeitada inteira no
BaseRepositorye noactiveRecord. As duas camadas tinham uma cópia deprimaryKeyOfque devolvia a primeira coluna marcadaprimaryKey()e seguia:getById,update,delete,reloade oON CONFLICTdosave()filtravam por metade da chave e podiam ler ou escrever a linha errada. Agora a resolução mora num lugar só —primaryKeysOfeprimaryKeyFilter, ambos exportados —,getByIdaceita{ orderId, lineNumber }, e um escalar em chave composta lança em vez de casar meia chave. Chave de uma coluna continua aceitando o valor cru (#25).
[0.8.0] — 2026-09-05¶
O better-sqlite3 deixou de ser promessa: EngineOptions.driver e o sufixo
sqlite+better-sqlite3 passaram a selecionar driver de verdade.
Adicionado¶
BetterSqliteDriver— driver SQLite sobre a peer dependency opcionalbetter-sqlite3, exportado no índice público e com o mesmo cache de prepared statement doNodeSqliteDriver. Linhas, coerção (bigint,Date,boolean, JSON),RETURNINGestream()são idênticos entre os dois — trocar de driver não muda o código do consumidor. O pacote é carregado lazy, comopostgresemysql2, e a falta dele dá erro nomeando onpm install.- Seleção de driver de verdade —
{ driver: "better-sqlite3" }esqlite+better-sqlite3:///app.dbabrem obetter-sqlite3; a opção vence o sufixo quando os dois aparecem.node:sqlitecontinua o padrão, sem instalar nada. Receita nova: Escolhendo o driver do SQLite.
⚠️ Breaking¶
EngineOptions.drivercom nome desconhecido agora lança. Antes o campo era ignorado em silêncio para qualquer valor, em qualquer dialeto — quem passava{ driver: "better-sqlite3" }rodava nonode:sqlitesem aviso. Agora"sqlite3"no SQLite,"asyncpg"no PostgreSQL e"mysql3"no MySQL falham na criação do engine. É breaking em runtime só para quem já estava sendo ignorado, que é exatamente o engano que a issue #22 registrou.- O sufixo da URL continua tolerante de propósito:
sqlite+aiosqlite,postgresql+asyncpge afins são ignorados, não rejeitados, para URL copiada de serviço Python continuar conectando.
Corrigido¶
EngineOptions.driver, o sufixo+better-sqlite3e a peer dependency opcional eram documentados e ignorados (#22).openSqliteDrivernunca liaoptions.drivernemparsed.driver, e nenhuma linha desrc/carregavabetter-sqlite3. Quem escolhia o driver — por WAL,pragma(), extensão carregável, ou por já usá-lo no resto do serviço — rodava em outro sem saber.
[0.7.0] — 2026-08-30¶
Duas correções vindas do mesmo consumidor real (zap-api): o ruído que o
InferInsert obrigava a escrever, e o NOTICE do Postgres sujando o stdout do
serviço.
⚠️ Breaking¶
- Coluna anulável passou a ser opcional em
InferInsert. É uma frouxidão de tipo, então nenhum código que compilava para de compilar — mas quem derivava tipos deInferInsertesperando as anuláveis obrigatórias vê a forma mudar (nickname: string | null→nickname?: string | null). NOTICEdo PostgreSQL não é mais impresso. SemonNotice, ele é descartado; antes o postgres.js o imprimia viaconsole.log. Quem dependia desse print precisa passaronNotice.
Adicionado¶
onNoticeemEngineOptions— recebe os notices do servidor (CREATE TABLE IF NOT EXISTSnuma tabela existente,DROP ... IF EXISTS), no mesmo espírito doonQuery, com erro do logger engolido. O default é silenciar: escrever no stdout do processo hospedeiro é decisão da aplicação, não de uma biblioteca — e o default anterior quebrava o log estruturado de quem consome stdout (Docker, Loki, CloudWatch) a cada boot, já que um runner de migration é a primeira coisa que roda.driverOptionsemEngineOptions— repasse direto para o driver, aplicado por último (vencepooleonNotice), para o que a superfície tipada não modela:connection/types/transform/ssldo postgres.js, ajustes do mysql2,readOnlydonode:sqlite. Evita que cada gap vire feature request.
Corrigido¶
- Coluna anulável sem default exigia
campo: nullem todo insert. Omitir uma coluna que aceitaNULLe não declaraDEFAULTgravaNULL— o mesmo que passarnull. Exigir onullescrito à mão só adicionava ruído que lê como decisão deliberada de zerar a coluna, e fazia toda coluna nova adicionada por migration quebrar a compilação de todos os call sites de insert. AgoranotNullsem default é a única coisa obrigatória;nullexplícito continua aceito. - Insert de várias linhas descartava chave ausente na primeira linha. A lista
de colunas vinha de
values[0], então emvalues([{ a }, { a, note: "x" }])onotenunca era nomeado e o valor sumia sem erro. Agora a lista é a união das chaves de todas as linhas. Era inalcançável enquanto toda linha precisava carregar toda chave — e passou a ser alcançável no instante em que anulável virou opcional. - Linhas que discordam sobre coluna com default agora levantam
ValidationError. UmINSERTtem uma lista de colunas só, então a linha que omite receberiaNULLem vez do default; o SQLite não tem a palavraDEFAULTdentro deVALUES, então não há saída portável por linha — falhar alto é a opção honesta.
Limitações conhecidas¶
EngineOptions.driver("better-sqlite3") continua documentado e não implementado:openSqliteDriversempre usanode:sqlite.driverOptionscobre as opções do driver, não a troca de driver.
[0.6.0] — 2026-08-30¶
Fecha as cinco lacunas que sobraram do ciclo anterior (#13–#17): a query API avançada, o MySQL de verdade e o CLI de migração fora do SQLite.
⚠️ Breaking¶
runMigrationCliagora éasynce devolvePromise<CliResult>.CliConfig.driveraceitaSyncDriver | AsyncDriver. Quem chama a função direto precisa deawait; o bináriotempest-dbjá foi ajustado.
- const result = runMigrationCli(["upgrade"], config);
+ const result = await runMigrationCli(["upgrade"], config);
SelectBuilderganhou um terceiro parâmetro de tipo (Grouped, defaultfalse), que é o que torna.having()inalcançável antes de.aggregate(). Uma anotaçãoSelectBuilder<Row, Proj>passa a significar "não agrupado"; para aceitar os dois, escrevaSelectBuilder<Row, Proj, boolean>.
Adicionado¶
- Subquery em
IN/NOT IN—.asSubquery(coluna)projeta uma coluna e marca oSELECTcomo operando, então a reivindicação de lote da fila cabe numa query só (UPDATE ... WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED LIMIT n)). A subquery carrega o próprio mapa de nomes e binda seus parâmetros na posição em que aparece. MySQL recusaLIMITem subquery — erro explícito na compilação. HAVING—.having(input)depois de.aggregate(), com as chaves tipadas contra os aliases + colunas agrupadas. O compilador reemite a expressão (COUNT(*) > $1) porque o PostgreSQL não aceita alias noHAVING;.orderBy()passa a aceitar alias de agregação, que todo dialeto aceita.- Expressões no
where—col<Row>("coluna"),val(x)efn.*(lower/upper/trim/length/abs/coalesceportáveis,fn.callpara o resto) tornam expressáveis a comparação coluna vs coluna e o índice funcional. Referência de coluna passa pelo mapa de nomes e pela qualificação de join; operando que não é expressão continua sendo ligado como parâmetro. RETURNINGno MySQL —.returning()funciona num insert de uma linha: a sessão insere e lê a linha de volta porLAST_INSERT_ID()(ou pela PK fornecida) na mesma conexão, reservando-a fora de transação. É o que fazBaseRepository.create()eactiveRecord.save()funcionarem no MySQL. Insert de N linhas com.returning()lança, porqueLAST_INSERT_ID()só identifica a primeira.- CLI de migração async —
runMigrationCliroda sobre oAsyncMigrationRunnerpara todo dialeto, adaptando o driver comtoAsyncDriver.checkroteia por dialeto viacheckDriftAsync(introspectSqliteAsyncnovo; PostgreSQL peloinformation_schema; MySQL devolve mensagem explícita de não implementado). Migração pelo CLI no PostgreSQL destravada. - CI — job
mysqlcom serviço MySQL 8 real; o jobpostgrespassa a rodar também o teste ponta-a-ponta do CLI.mysql2declarada como peer dependency opcional (o código já a importava dinamicamente, sem declarar). - Docs — receitas bilíngues novas "Expressões no
where" e "MySQL: o que muda"; "Fila durável" ganhou a versão numa query só; "Agregações" ganhouHAVING; "Migrações" ganhou o fluxo async/PostgreSQL.
Corrigido¶
introspectSqlitee a comparação de drift foram fatoradas para que os caminhos sync e async compartilhem uma implementação só — uma segunda cópia divergiria da primeira na próxima mudança de regra.Expressiondentro dein/betweenera serializada como parâmetro em vez de virar SQL; agora levanta erro na montagem, mesmo princípio do guard deset().
Limitações conhecidas¶
- Subquery só em
IN/NOT IN;EXISTSe subquery escalar continuam fora. - Introspecção MySQL (
information_schema) não existe, entãochecknão detecta drift lá. col()/fn.*checam o nome da coluna, não o tipo do operando — comparar uma coluna de texto com uma numérica compila.
[0.5.0] — 2026-08-30¶
Ciclo focado nos buracos que a primeira migração real de um serviço (zap-api,
gateway WhatsApp) encontrou — o padrão outbox/fila sobre PostgreSQL, ponta a ponta.
Adicionado¶
- Lock de linha —
.forUpdate({ skipLocked, noWait, of })e.forShare(...)noSelectBuilder(espelhawith_for_update()do SQLAlchemy). RenderizaFOR UPDATE [OF ...] [SKIP LOCKED | NOWAIT]no PostgreSQL e no MySQL 8.0+; o SQLite lança erro explícito em vez de emitir umSELECTsem lock. Lock combinado comDISTINCT/agregação também lança. Destrava o padrão de fila com workers concorrentes. - Expressões SQL como valor de escrita —
sql.raw("attempts + 1"),sql.expr`balance - ${amount}`(tagged template, cada${}vira parâmetro ligado) e os tokens portáveis (sql.now(),sql.uuidv4(), …) agora valem em.set()e.values(), não só em.default(). O contador é incrementado no banco, sem read-modify-write e sem race. Toda expressão carrega uma marca (isSqlExpression) que o dialeto reconhece. - Predicado no conflict target —
onConflictDoNothing(target, { where })eonConflictDoUpdate(target, set, { indexWhere, updateWhere })emitemON CONFLICT (...) WHERE <predicado>, obrigatório no PostgreSQL para que um índice único parcial case como conflict target. Portável para o SQLite; MySQL lança erro explícito. session.raw(sql, params, { as })— escape hatch de SQL cru em runtime, paralelo doOp.executedas migrações, nas sessões async e sync. Sempre parametrizado, integrado aonQuery,QueryExecutionErrore à conexão reservada da transação;{ as: Model }coage as linhas pelos tipos do modelo.- Nome de coluna explícito e naming strategy —
.name("consumer_name")por coluna (estilomapped_column("...")) estatic naming = "snake_case"por tabela. O mapeamento vale em select/insert/update/delete,where,orderBy,groupBy, agregações,returning, conflict target, joins,BaseRepository, active-record e no IR das migrações — então não gera drift falso. A linha retornada continua em nome de propriedade. Colisão de nomes falha alto. column.array(element)— colunastext[]/integer[]do PostgreSQL, comT[]inferido,DEFAULT ARRAY[...]::tipo[], introspecção (data_type = ARRAYudt_name) e drift cientes do tipo do elemento. SQLite e MySQL lançam erro explícito em vez de cair para JSON silenciosamente.- Operadores novos —
ieq(igualdade case-insensitive →lower(col) = lower($1), portável nos 3 dialetos e casando índice funcional) e os operadores de arraycontains(@>),containedBy(<@) eoverlaps(&&), PostgreSQL-only. - Docs — cinco receitas bilíngues novas: Fila durável com PostgreSQL, Nomes de coluna, Colunas array do PostgreSQL, Comparação case-insensitive e SQL cru em runtime. Suíte de integração contra PostgreSQL real cobrindo lock concorrente, índice parcial, arrays e contador atômico.
Corrigido¶
set()/values()gravavam lixo em silêncio. Um valor não-escalar —{ raw: "attempts + 1" }, um array numa coluna escalar, uma função — era ligado como parâmetro, e o driver o serializava (ou gravavanull) sem erro nenhum: uma colunaINTEGER NOT NULLviravanull. Agora qualquer valor que não seja escalar nem expressão marcada levantaValidationErrorna montagem da query, junto com o nome da coluna e o tipo esperado. Chave que não é coluna do modelo também é rejeitada.- Cache de template do INSERT não podia servir statements com predicado de conflito ou expressão nos valores, cuja SQL depende dos valores. Esses casos passam por um caminho não-cacheado que renderiza as cláusulas em ordem de statement, mantendo as posições dos placeholders corretas.
- Introspecção do PostgreSQL lia toda coluna array como
text, o que fazia ocheckDriftPostgresreportar drift eterno num schema correto.
Documentado¶
ilikeé pattern matching, não igualdade:%e_são coringas, e{ ilike: "%" }casa todas as linhas. Usado como "eq case-insensitive" num lookup de autenticação, é bypass de login. A doc do operador agora diz isso, eieqexiste justamente para eliminar a tentação.
Limitações conhecidas¶
FOR UPDATE/FOR SHARE, predicado deON CONFLICTecolumn.array()não têm equivalente em todos os dialetos; cada um lança erro explícito onde não é suportado, em vez de degradar em silêncio.- Subquery em
WHERE ... IN (...)continua fora do builder — o padrão de fila é escrito comoSELECT ... FOR UPDATE SKIP LOCKEDseguido deUPDATE ... WHERE id IN (ids)na mesma transação, ou viasession.raw.
[0.4.0] — 2026-07-09¶
Adicionado¶
- Chaves estrangeiras, UNIQUE e constraints de tabela —
.references(...)e.unique()por coluna (estilomapped_column(ForeignKey(...), unique=True)) estatic tableArgs = () => [unique(...), foreignKey(...)]para composto/nomeado (estilo__table_args__). Renderizados nos 3 dialetos, com operações reversíveisadd_constraint/drop_constraint, diff, replay e detecção de drift. Veja a receita Chaves estrangeiras e UNIQUE.
[0.1.0] — 2026-06-29¶
Primeira versão pública, publicada no npm.
Adicionado¶
- Fase 1 — Schema declarativo class-based. Classe base
Model+ fábricacolumncom catálogo rico de tipos espelhando o SQLAlchemy (smallInteger,integer,bigInteger→bigint,numeric/decimal→string,real,double,varchar/string,char,text,boolean,date,time,datetime,timestamp,blob→Uint8Array,json<T>/jsonb<T>,uuid,enum→união literal). Modificadores.primaryKey(),.notNull(),.default(),.onUpdate(). Tipos inferidos porInferModel(SELECT) eInferInsert(insert). - Defaults portáveis (
sql.now(),sql.uuidv4(), etc.), guardados na coluna pro IR de migração. parseDatabaseUrl/detectDialect— banco identificado via URL (à lamake_url).- Serialização (
toDict/toJSON/stringify/fromDict/parse) com coerção por tipo de coluna. - Fase 3 — operadores tipados por tipo de coluna (
OperatorsFor<T>):string→like/ilike/in;number/bigint/Date→ordenados+between;boolean→ eq/isNull. Combinação inválida = erro de compilação. - Fase 4a — compilação SQL por dialeto:
getDialect(...).compile(node)→{ sql, params }parametrizado (?/$1), SELECT/INSERT/UPDATE/DELETE +RETURNING;ilikenativo no Postgres. - Fase 4b — execução real:
createEngine(async) /createSyncEngine(SQLite sync),Session.executecom terminais tipados,engine.transaction+ savepoints, coerção de linha. SQLite vianode:sqlite; PostgreSQL viapostgres.js. - Fase 5 — joins tipados:
join(Model, alias).innerJoin/leftJoin(...)→ tipo composto{ [alias]: Row },leftJoinnullable; refsalias.columntipadas. - Fase 6 — migrações (
tempest-db-js/migrations, estilo Alembic):reflectSchema,diffSchema, operações tipadas +invert,renderOperation(DDL por dialeto),generateMigration, grafo DAG (topoOrder/heads),MigrationRunner(upgrade/downgradereais). SQL só no renderer. - Fase 7 — repository:
BaseRepository<Model>(CRUD + paginação tipada) sobreAsyncSession, convenção 404 (RecordNotFound/[]),PaginationFilter/PaginationResultalinhados aotempest-fastapi-sdk. - Refinamentos: combinadores
and/or/notnowhere(select/update/delete/ join); batch-mode SQLite (recreate_table) pra mudanças de coluna preservando dados; introspecção SQLite +checkDrift(compara DB vivo com os modelos). - Mais refinamentos:
session.stream(query)(iteração preguiçosa sync/async); relationshasMany/belongsTo+loadRelations(eager-load tipado, sem N+1); CLI de migraçãorunMigrationCli(upgrade/downgrade/check/revision --autogenerate); PostgreSQL estrutural (introspecção, enum nomeado,PoolOptions). - Fase 2 — Query builder tipado (AST pura, sem execução).
select(Model)/select(Model, [cols])→ inferência de linha completa ouPick, com.where(),.orderBy(),.limit(),.offset().insert(Model).values(...)tipado porInferInsert, com.returning().update(Model)/del(Model)com guard de estado tipado: a query só se torna executável após.where(...)ou.unguarded()explícito — um UPDATE/DELETE em tabela inteira sem querer vira erro de compilação..returning(cols)inferindo projeçãoPickem todas as mutações.
- Documentação bilíngue (PT-BR + EN-US) em MkDocs Material, publicada no GitHub Pages.
Notas¶
- Alpha (
v0.1.0). A superfície pública pode ainda mudar antes dav1.0. - Execução SQLite real e testada (
node:sqlite); PostgreSQL viapostgres.js.