Changelog¶
The format follows Keep a Changelog and the project adopts Semantic Versioning.
[0.9.1] — 2026-09-05¶
A packaging fix found while validating the published 0.9.0 artifact.
Fixed¶
- Each entry point shipped its own copy of the core, and the consequence was silent:
tempest-db-js/migrationsand thetempest-dbbinary each carried their ownColumn, so a model built through the main entry reflected as having no columns at all —instanceof Columncompared two different classes. In practicereflectTablereturned{}, theCREATE TABLEcame out with only the constraints, andtempest-db checksaw nothing. The core is now bundled once, at the root, and the other entries load it by the package's own name (a self-reference throughexports), with a packaging test asserting on the built files — the defect is invisible in the source tree, which is a single module graph (surfaced by #48, but it affected the whole migration workflow for as long as the subpath has existed).
[0.9.0] — 2026-09-05¶
The #24–#51 cycle: 28 deliveries built on the analysis comparing this package against
tempest-fastapi-sdk's database layer and SQLAlchemy 2.0's surface. Three axes:
correctness (a foreign key that was never enforced, a truncated composite key, a date
format the package could not read back), query surface (CTEs, windows, set operations,
EXISTS, CASE/CAST, writes that read another table) and what every service rewrote on top
of the builder (mixins, cursor pagination, signals, outbox, audit, tenancy, integrity
errors, backups).
⚠️ Breaking¶
-
The 3rd parameter of
SyncSession/AsyncSessionchanged fromQueryLoggertoQueryHooks({ onQuery, onQueryEnd, slowQueryMs }), and likewise for theSyncEngine/AsyncEngineconstructors. Users ofcreateEngine/createSyncEngineare unaffected; code constructing a session or engine by hand with a logger function passes{ onQuery: logger }instead (#29). -
SQLite now enforces
FOREIGN KEY. Enforcement used to be off (SQLite's own per-connection default), so an orphanINSERTwas accepted andON DELETE CASCADEnever fired. The engine now turns it on when any SQLite connection opens, on both drivers. A database that already holds an orphan row will start rejecting writes that touch it — which is the point. Escape hatch:{ sqlite: { foreignKeys: false } }(#24).
Added¶
-
Opt-in unit of work with an identity map —
session.unitOfWork()returns an identity map plus a change log:getof the same key twice returns the same instance with no second query, and oneflush()writes everything in a transaction (inserts → updates → deletes, the order that keeps a foreign key satisfied when a new parent and its children go together). An update carries only what changed, withDateandUint8Arraycompared by content — by reference, every row holding a date would be rewritten on every flush. A mid-flush failure rolls the set back and keeps the tracked state, so it can be fixed and flushed again.Tracked<Row>distinguishes a tracked row from a loose one in the type. The plain-object default path is unchanged (#51). -
aliased(Model, alias)— reads the same model under another name (FROM "employees" AS "sub"). The join builder already required an alias, butselect()had no way to name its table, and without that a correlated subquery over the same table was not expressible: both sides would carry the same name. The result is a real model — same columns, naming and codecs — soselect,join,col("alias.column")and row coercion take it with no special casing. Table args are not carried over: an alias is a way to read the table, not a second declaration of it (#50). -
customType({ base, toDb, fromDb })— a column type of your own (money in cents,Temporal, a branded id, a value object). The conversion runs in all three places that matter: writes (values/set), reads (row coercion,RETURNING,stream, joins) andwhereoperands — including each element of aninand both ends of abetween. The DDL and the IR keep using thebasetype, so a custom type is invisible to the schema and cannot cause drift.nullpasses straight through, and a SQL expression is not converted — it is rendered (#49). -
check()andindex()intableArgs—CHECK(with the expression in the same language aswhere, not a raw string, so the diff compares a tree instead of text) and indexes, including unique and partial ones. They flow into the IR, the diff, the DDL and introspection: migrations create and drop them, and SQLite's table rebuild recreates the indexes instead of letting them vanish with the old table.tempest-db checknow compares explicit indexes on both databases;CHECKs and partial indexes are deliberately left out of the comparison — the database returns the expression as text, and comparing text against the tree would report a difference for every spelling. A partial index on MySQL throws (#48). -
Writes that read another table —
insert(M).fromSelect(columns, query)(INSERT ... SELECT, with the rows never passing through the Node process),update(M).from(Other, alias)anddel(M).using(Other, alias). Where a dialect lacks the clause the compiler throws, with the alternative in the message (UPDATE ... FROMdoes not exist on MySQL;DELETE ... USINGexists on neither SQLite nor MySQL): emitting it anyway would be a server error, and dropping it would change which rows are written (#47). -
set()/values()accept a column reference (col("c.tier")), not just a value orsql.raw— without itUPDATE ... FROMwould have no way to write the value coming from the other table (#47). -
CTEs:
cteandcteRecursive—WITHandWITH RECURSIVE. A CTE's.modelis a real model whose table is the CTE's name, soselect,joinandwheretake it with no special casing. The recursive form walks a tree in a single query, andRECURSIVEis emitted for the clause when any entry is recursive, as the standard requires. A materialization hint (MATERIALIZED/NOT MATERIALIZED) for PostgreSQL 12+ (#46). -
join(...).pick(alias)— projects one join source with bare column names instead of the composite row. It is what makes a join fit where a single-tableSELECTfits: a CTE's recursive branch, aUNIONbranch. Without it the branch's columns ("c.id") do not line up with the CTE's. Set operations now accept such a join branch (#46). -
Window functions and
select().compute()—over(fn, { partitionBy, orderBy, frame })withrowNumber,rank,denseRank,percentRank,lag,lead,firstValue,lastValueand any aggregate.compute({ alias: expression })projects by alias without grouping, so the row stays and the value comes with it — and the alias lands in the row type, becauseExpressionnow carries the value's type. A function that only exists inside a window returns aWindowFn, which onlyover()accepts: usinglag()withoutOVERstops being a database runtime error and becomes a compile error (#45). -
Backup and restore, from the CLI and programmatically —
tempest-db backup <file> --urlandtempest-db restore, plusbackupDatabase/restoreDatabase. PostgreSQL usespg_dump/pg_restore/psqlwith the format picked by the extension (.sqlplain, anything else custom), and the password travels inPGPASSWORD— never inargv, which any process on the machine can read. SQLite usesVACUUM INTO, not a file copy: with WAL on, the.dbfile alone is not the whole database, andVACUUM INTOis consistent even while another connection writes. The driver suffix (postgresql+asyncpg) is stripped before the tool is invoked, and a missing tool becomesBackupToolMissing. Both commands are dispatched before the migration config is loaded — a database that cannot be migrated yet is exactly the one needing a dump (#44). -
Append-only audit trail —
auditLogModel(table)for the schema andenableAudit(Model, { log, actor, exclude })to turn it on. One entry per create/update/delete, withrowKey(a composite key in full), the action, an actor resolved at write time, and a{ column: [before, after] }diff carrying only what changed — an update with no delta writes nothing. Built on the signals (#36), so the entry is written on the same session and therefore in the change's own transaction: a rollback takes the entry with it, because a trail recording an uncommitted change is worse than no trail (#43). -
TenantScopedRepository— a repository bound to one tenant: the predicate joins every read and the column is stamped on every write, becauseBaseRepository's methods now pass through a single scoping point (scopeFilters/scopeWrite, protected and overridable) instead of each one remembering theWHERE. Another tenant's row is "not found", not "forbidden" — telling them apart would already leak — and writing while naming another tenant throws rather than being silently overwritten. A model without the column makes the constructor throw (#42). -
engine.explain(fn)— captures the plan of every statement a block runs, with the parameters the code actually used (the block gets a recording session).EXPLAIN (FORMAT JSON)on PostgreSQL,EXPLAIN QUERY PLANon SQLite, plus a readablesummary()per plan.analyze: trueexecutes the statement to measure it, so it is refused for writes — analyzing anUPDATEwould apply it twice — and SQLite throws, sinceEXPLAIN ANALYZEdoes not exist there (#41). -
Transactional outbox —
outboxModel(table)for the schema andOutboxRepositoryfor the relay:publish,pending,claim,markSent,markFailedwith backoff and permanent give-up.claimusesFOR UPDATE SKIP LOCKEDover a subquery, so two concurrent relays take disjoint batches, and it incrementsattemptson the claim itself, which gives a dead-letter policy for free.publish()deliberately does not open its own transaction: atomicity comes from the re-entranttransaction(), with the business row and the event on the same session — asaveWithOutboxwould be a second way to do the same thing (#40). -
Set operations:
union,unionAll,intersectandexcept— they combine two or more SELECTs into a builder executable like any other, with branch shapes checked at compile time (the mistake the database only reports at runtime).orderBy/limit/offsetapply to the set; a branch carrying its own ordering or limit is parenthesized, since otherwise those clauses would bind to the combined query — a different query.INTERSECT/EXCEPTthrow on MySQL, by scope (#39). -
exists/notExistsandscalar— correlatedEXISTS (...), the right shape when only existence matters (the database can stop at the first match, which anINover a materialized set cannot), plus scalar subqueries as values.scalar()takes the result of.asSubquery(column), so a two-column scalar subquery is a compile error instead of a runtime error in the database (#38). -
whereaccepts an expression in the object form —{ userId: col("users.id") }compares two columns, and so does{ total: { gt: col("paid") } }. An expression in that position used to be bound as a parameter: the comparison silently became the column against the string"users.id". A qualified reference (table.column) now resolves to"table"."column"instead of becoming one identifier (#38). -
BaseRepository:existsExcluding,bulkUpsert,softDelete/restore,deleteBatchandchangesSince— the operations every service rewrote on top of the builder.changesSinceis the delta-sync read: a strictupdatedAtfilter, oldest first, tie-broken by primary key, and aserverTimeread before the query as the watermark (using the newestupdatedAtreceived would let a row committed mid-page fall into the gap between two pulls). A soft-deleted row comes back as a tombstone, which is what makes the client drop its local copy.softDelete/restore/changesSincethrow naming the mixin when the model lacks the column (#37). -
sql.excluded(column)— references the incoming row of an upsert (excluded."col"on PostgreSQL/SQLite,VALUES(col)on MySQL). Without it an N-row upsert has no way to write the new value, since each row has its own (#37). -
Repository signals —
preSave,postSave,preDeleteandpostDeletearoundcreate/createMany/update/delete, per row. A handler that throws on apre*vetoes the write; the payload carries the same session, so a handler that writes commits (or rolls back) with the write it observed.update/deletetake a filter rather than a row, so the extra read that hands the row to a handler only happens when one is registered (hasHandlers) — no usage, no cost.clearSignalsfor tests (#36). -
Text search:
contains, theiContainsoperator,escapeLike,fullTextandfullTextRank— the portable layer tokenizes the term and escapes every token, always emitting theESCAPEclause (PostgreSQL assumes\\by default, SQLite has no escape character at all until one is declared). Before this, thelike/ilikeoperand went through raw: searching for100%matched the whole table. The PostgreSQL layer usesto_tsvector/websearch_to_tsquery/ts_rankand, elsewhere, compiles ascontains— the right rows without stemming, a documented degradation rather than an error.escapeLikeexisted only inilike's docstring; now it exists (#35). -
orderByaccepts an expression as well as a column name — without it there is no way to order by relevance (#35). -
parseIntegrityError— reads the driver's error back into the constraint that refused the write:{ violation, constraint, table, columns, detail }, ornullwhen it is not an integrity violation. It is what separates409 EMAIL_TAKENfrom a generic conflict without every service writing its own regex. It follows thecausechain (soQueryExecutionErroris no obstacle), understands both ways SQLite reports a code (node:sqlitenumeric,better-sqlite3named) and PostgreSQL's SQLSTATE plusDETAIL:line — where all the columns of a composite constraint come from. Pass the model and the names come back as properties instead of columns. MySQL returnsnull, by scope (#34). -
pool.prePingandpool.recycleMs— what was missing for a connection that dies without saying so (a failover, a pgbouncer restart, a firewall dropping an idle socket).prePingvalidates the connection withSELECT 1before pinning it for a transaction, which is where the damage is worst:BEGINsucceeds and the block dies halfway.recycleMsbecomes postgres.js'smax_lifetime. Both are PostgreSQL-only: on MySQL they throw, since mysql2 has no equivalent knob, and on SQLite thepoolblock does not apply (#33). -
Per-transaction isolation level and read-only blocks —
transaction(fn, { isolation, readOnly }). PostgreSQL puts everything on theBEGINitself; MySQL needs aSET TRANSACTIONfirst and spells read-only onSTART TRANSACTION; SQLite implements onlyserializableand throws for any other level or forreadOnly, rather than silently accepting a guarantee it cannot give. Requesting a characteristic on a nested block throws too: isolation is fixed when the transaction opens (#32). -
caseWhenandcast—CASE WHEN ... THEN ... ELSE ... ENDandCAST(x AS type)as first-class expressions. TheCASEbranches use the samewherelanguage, with no new grammar, and theCASTtarget is a portable vocabulary each dialect renders with the name it accepts (integerisINTEGERon PostgreSQL and SQLite,SIGNEDon MySQL). Both used to requiresql.raw, which loses the type (#31). -
Aggregation over an expression —
sum/avg/min/maxnow take an expression as well as a column name, which is what makes conditional aggregation (SUM(CASE WHEN status = 'paid' THEN total ELSE 0 END)) expressible: one pass over the table instead of a query per bucket (#31). -
Re-entrant
transaction()— a nested block on the same session joins the outer one: oneBEGIN, oneCOMMIT, and an inner failure rolls the whole thing back. It is what makes a service orchestrating several repositories work, since they all hold the same session.session.transactionDepthandsession.inTransactionexpose the state (#30). -
AsyncSession.beginNested— savepoints on the async path, which only existed onSyncSessioneven though the API reference documentedsession.beginNested(fn)unqualified. The async path is PostgreSQL's default, which is exactly where savepoints matter: recovering from a partial failure without dropping the whole transaction was impossible (#30). -
onQueryEndandslowQueryMsinEngineOptions— the other half ofonQuery, which fires before the statement and therefore cannot time anything. The new hook fires afterwards withdurationMs,rowCountand — on the failure path — the driver'serror: a slow statement that also fails is the interesting one.slowQueryMsfilters by threshold, which gives a slow-query log with no APM agent. Forstream(), the time spans until the iteration ends. An error thrown by the hook is swallowed, like the others (#29). -
BaseRepository.cursorPaginate— cursor pagination:{ items, nextCursor }, with noCOUNT(*)and a boundary that stays stable under concurrent inserts, which is what offset paging cannot give on a large table. The primary key is always appended as a tie-break (a composite key in full), so a tie onorderBycannot make a row fall between pages. The cursor is opaque and validated: corrupted, from another version, or produced under a differentorderBythrowsInvalidCursorinstead of building the wrongWHERE. The comparison is written in expanded form (a > v OR (a = v AND b > w)) rather than as a row value, because tuple-comparison support varies across the databases (#27). -
encodeColumnValue/decodeColumnValue— the per-column codecs serialization already used, now exported: they are what lets a column value be stored outside the database (in a cursor, in a cache) and read back with the right type. -
Model mixins —
withTimestamps(createdAt/updatedAt),withSoftDelete(deletedAt, plusnotDeleted()/onlyDeleted()forwhere) andwithAudit(createdBy/updatedBy, actor type configurable through a factory). They are functions taking the base class and returning the subclass, so they compose (withAudit(withSoftDelete(withTimestamps(Model)))) and contribute real columns: they appear inInferModel, inInferInsert, in the migration IR and in the DDL (#26). -
defaultAsWriteValue— converts a column's stored default (DefaultValue) into what the write path renders. It was the missing piece between the IR's shape andset()/values(). -
EngineOptions.sqlite— per-connection pragmas applied at open time:foreignKeys(defaults totrue),journalMode,busyTimeoutMs,synchronous. Each one is read back after it is written, because SQLite answers a pragma it cannot honor by keeping the old value and saying nothing:journalMode: "wal"on a:memory:database now throws instead of pretending. Passingsqliteto a PostgreSQL/MySQL engine throws (#28).
Fixed¶
-
Whether a statement returns rows was decided by an incomplete regex. The
node:sqlitepath picksall()orrun()before executing, and the test only coveredSELECT/PRAGMA— soEXPLAIN,WITH ... SELECT,VALUESandTABLEwere run as non-returning and reported zero rows, silently, instead of failing. better-sqlite3 was unaffected (it asks the statement), which left the two drivers disagreeing (#41). -
sql.now()wrote a format on SQLite that this package could not read back.CURRENT_TIMESTAMPproduces"YYYY-MM-DD HH:MM:SS"— noT, no milliseconds, no zone — and JS parses that as local time: a row written at 21:00Z read back as 00:00Z on a UTC-3 machine. Worse, comparing the column against a boundDate(ISO) compared" "against"T"and silently matched nothing. SQLite now rendersstrftime('%Y-%m-%dT%H:%M:%fZ','now'), exactly the format this package binds and parses. It affects the DDL default and theonUpdatevalue; a database already written with the old format needs a conversionUPDATE(#37). -
Column.onUpdate()is now applied. The value was stored on the column and never consumed — not by the DDL, not by the builder — so.onUpdate(sql.now())did nothing, even though thecreated_at / updated_atrecipe documented that "the value is re-applied on every UPDATE".UpdateBuildernow injects the value of everyonUpdatecolumn theset()does not mention. Applied on the write path, not in the schema: only MySQL has a column-levelON UPDATE, and rendering it into the DDL would make the same model diverge per database. An explicit value inset()still wins (#26). -
A composite primary key is now honored in full by
BaseRepositoryandactiveRecord. Both layers carried a copy ofprimaryKeyOfthat returned the firstprimaryKey()column and moved on:getById,update,delete,reloadandsave()'sON CONFLICTfiltered on half the key and could read or write the wrong row. Resolution now lives in one place —primaryKeysOfandprimaryKeyFilter, both exported —getByIdaccepts{ orderId, lineNumber }, and a scalar for a composite key throws instead of matching half of it. A single-column key still takes the bare value (#25).
[0.8.0] — 2026-09-05¶
better-sqlite3 stopped being a promise: EngineOptions.driver and the
sqlite+better-sqlite3 suffix now really select a driver.
Added¶
BetterSqliteDriver— a SQLite driver over the optionalbetter-sqlite3peer dependency, exported from the public index, with the same prepared-statement cache asNodeSqliteDriver. Rows, coercion (bigint,Date,boolean, JSON),RETURNINGandstream()are identical across both — swapping drivers does not change consumer code. The package is loaded lazily, likepostgresandmysql2, and a missing install throws an error naming thenpm install.- Real driver selection —
{ driver: "better-sqlite3" }andsqlite+better-sqlite3:///app.dbopen better-sqlite3; the option wins over the suffix when both appear.node:sqlitestays the default, with nothing to install. New recipe: Choosing the SQLite driver.
⚠️ Breaking¶
EngineOptions.drivernow throws on an unknown name. The field used to be silently ignored for any value, on any dialect — passing{ driver: "better-sqlite3" }ran onnode:sqlitewith no warning. Now"sqlite3"on SQLite,"asyncpg"on PostgreSQL and"mysql3"on MySQL fail when the engine is created. It is a runtime break only for code that was already being ignored, which is exactly the trap issue #22 recorded.- The URL suffix stays forgiving on purpose:
sqlite+aiosqlite,postgresql+asyncpgand friends are ignored, not rejected, so a URL copied from a Python service keeps connecting.
Fixed¶
EngineOptions.driver, the+better-sqlite3suffix and the optional peer dependency were documented and ignored (#22).openSqliteDrivernever readoptions.driverorparsed.driver, and no line insrc/ever loadedbetter-sqlite3. Anyone choosing the driver — for WAL,pragma(), a loadable extension, or because the rest of the service already used it — silently ran on another one.
[0.7.0] — 2026-08-30¶
Two fixes from the same real consumer (zap-api): the noise InferInsert forced
into every insert, and PostgreSQL NOTICE output polluting the service's stdout.
⚠️ Breaking¶
- Nullable columns are now optional in
InferInsert. This loosens a type, so nothing that compiled stops compiling — but anyone deriving types fromInferInsertand expecting nullables to be required will see the shape change (nickname: string | null→nickname?: string | null). - PostgreSQL
NOTICEis no longer printed. WithoutonNoticeit is dropped; postgres.js used to print it throughconsole.log. Anyone relying on that print needs to passonNotice.
Added¶
onNoticeinEngineOptions— receives server-side notices (CREATE TABLE IF NOT EXISTSon an existing table,DROP ... IF EXISTS), in the same spirit asonQuery, with a throwing logger swallowed. The default is silence: writing to the host process's stdout is the application's decision, not a library's — and the previous default broke the structured log of anything consuming stdout (Docker, Loki, CloudWatch) on every boot, since a migration runner is usually the first thing to run.driverOptionsinEngineOptions— passed straight to the driver, applied last (winning overpoolandonNotice), for what the typed surface does not model: postgres.js'sconnection/types/transform/ssl, mysql2's own settings,node:sqlite'sreadOnly. Keeps every gap from becoming a feature request.
Fixed¶
- A nullable column without a default required
field: nullin every insert. Omitting a column that acceptsNULLand declares noDEFAULTwritesNULL— the same thing passingnulldoes. Requiring the hand-writtennullonly added noise that reads like a deliberate decision to blank the column, and made every new column added by a migration break compilation at every insert call site. NownotNullwithout a default is the only thing required; an explicitnullis still accepted. - A multi-row insert dropped keys absent from the first row. The column list
came from
values[0], so invalues([{ a }, { a, note: "x" }])thenotecolumn was never named and the value vanished with no error. The list is now the union of every row's keys. It was unreachable while every row had to carry every key — and became reachable the moment nullable columns turned optional. - Rows that disagree about a defaulted column now raise
ValidationError. OneINSERThas one column list, so the omitting row would getNULLinstead of its default; SQLite has noDEFAULTkeyword insideVALUES, so there is no portable per-row escape — failing loudly is the honest option.
Known limitations¶
EngineOptions.driver("better-sqlite3") remains documented and unimplemented:openSqliteDriveralways usesnode:sqlite.driverOptionscovers driver options, not swapping the driver.
[0.6.0] — 2026-08-30¶
Closes the five gaps left over from the previous cycle (#13–#17): the advanced query API, MySQL for real, and the migration CLI beyond SQLite.
⚠️ Breaking¶
runMigrationCliis nowasyncand returnsPromise<CliResult>.CliConfig.driveracceptsSyncDriver | AsyncDriver. Callers of the function needawait; thetempest-dbbinary is already updated.
- const result = runMigrationCli(["upgrade"], config);
+ const result = await runMigrationCli(["upgrade"], config);
SelectBuildergained a third type parameter (Grouped, defaulting tofalse), which is what makes.having()unreachable before.aggregate(). An annotation ofSelectBuilder<Row, Proj>now means "not grouped"; to accept both, writeSelectBuilder<Row, Proj, boolean>.
Added¶
- Subqueries in
IN/NOT IN—.asSubquery(column)projects one column and marks theSELECTas an operand, so the queue's batch claim fits in a single query (UPDATE ... WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED LIMIT n)). The subquery carries its own name map and binds its parameters at the position it appears. MySQL rejectsLIMITin a subquery — an explicit compile-time error. HAVING—.having(input)after.aggregate(), with keys typed against the aliases + grouped columns. The compiler re-emits the expression (COUNT(*) > $1) because PostgreSQL does not accept an alias inHAVING;.orderBy()now accepts an aggregate alias, which every dialect does accept.- Expressions in
where—col<Row>("column"),val(x)andfn.*(lower/upper/trim/length/abs/coalesceportable,fn.callfor the rest) make column vs column comparison and functional-index lookups expressible. A column reference goes through the name map and join qualification; an operand that is not an expression is still bound as a parameter. RETURNINGon MySQL —.returning()works on a single-row insert: the session inserts and reads the row back byLAST_INSERT_ID()(or by the supplied PK) on the same connection, reserving one outside a transaction. That is what makesBaseRepository.create()andactiveRecord.save()work on MySQL. A multi-row insert with.returning()throws, becauseLAST_INSERT_ID()identifies only the first row.- Async migration CLI —
runMigrationCliruns onAsyncMigrationRunnerfor every dialect, adapting the driver withtoAsyncDriver.checkroutes by dialect throughcheckDriftAsync(newintrospectSqliteAsync; PostgreSQL viainformation_schema; MySQL returns an explicit not-implemented message). Migrating PostgreSQL through the CLI is unblocked. - CI — a
mysqljob with a real MySQL 8 service; thepostgresjob also runs the end-to-end CLI test.mysql2is declared as an optional peer dependency (the code already imported it dynamically, without declaring it). - Docs — new bilingual recipes "Expressions in
where" and "MySQL: what changes"; "A durable queue" gained the single-query version; "Aggregations" gainedHAVING; "Migrations" gained the async/PostgreSQL flow.
Fixed¶
introspectSqliteand the drift comparison were factored so the sync and async paths share one implementation — a second copy would diverge from the first the next time a rule changes.- An
Expressioninsidein/betweenwas serialized as a parameter instead of becoming SQL; it now raises while the query is built, on the same principle as theset()guard.
Known limitations¶
- Subqueries only in
IN/NOT IN;EXISTSand scalar subqueries are still out. - MySQL introspection (
information_schema) does not exist, socheckcannot detect drift there. col()/fn.*check the column name, not the operand's type — comparing a text column against a numeric one compiles.
[0.5.0] — 2026-08-30¶
A cycle focused on the gaps the first real service migration (zap-api, a WhatsApp
gateway) ran into — the outbox/queue pattern on PostgreSQL, end to end.
Added¶
- Row locking —
.forUpdate({ skipLocked, noWait, of })and.forShare(...)onSelectBuilder(mirrors SQLAlchemy'swith_for_update()). RendersFOR UPDATE [OF ...] [SKIP LOCKED | NOWAIT]on PostgreSQL and MySQL 8.0+; SQLite throws an explicit error instead of emitting an unlockedSELECT. A lock combined withDISTINCT/an aggregate throws too. Unblocks the job-queue pattern with competing workers. - SQL expressions as write values —
sql.raw("attempts + 1"),sql.expr`balance - ${amount}`(a tagged template where every${}becomes a bound parameter) and the portable tokens (sql.now(),sql.uuidv4(), …) now work in.set()and.values(), not only in.default(). The counter is incremented in the database, with no read-modify-write and no race. Every expression carries a brand (isSqlExpression) the dialect recognizes. - A predicate on the conflict target —
onConflictDoNothing(target, { where })andonConflictDoUpdate(target, set, { indexWhere, updateWhere })emitON CONFLICT (...) WHERE <predicate>, which PostgreSQL requires for a partial unique index to match as a conflict target. Portable to SQLite; MySQL throws an explicit error. session.raw(sql, params, { as })— a runtime raw-SQL escape hatch, the counterpart of migrations'Op.execute, on both async and sync sessions. Always parameterized, integrated withonQuery,QueryExecutionErrorand the transaction's reserved connection;{ as: Model }coerces rows through the model's types.- Explicit column names and a naming strategy —
.name("consumer_name")per column (mapped_column("...")style) andstatic naming = "snake_case"per table. The mapping applies across select/insert/update/delete,where,orderBy,groupBy, aggregates,returning, conflict targets, joins,BaseRepository, active-record and the migration IR — so it produces no false drift. The returned row stays in property-name space. A name collision fails loudly. column.array(element)— PostgreSQLtext[]/integer[]columns, withT[]inferred,DEFAULT ARRAY[...]::type[], and introspection (data_type = ARRAY+udt_name) and drift aware of the element type. SQLite and MySQL throw an explicit error instead of silently falling back to JSON.- New operators —
ieq(case-insensitive equality →lower(col) = lower($1), portable across all three dialects and matching a functional index) and the array operatorscontains(@>),containedBy(<@) andoverlaps(&&), PostgreSQL-only. - Docs — five new bilingual recipes: A durable queue on PostgreSQL, Column names, PostgreSQL array columns, Case-insensitive comparison and Raw SQL at runtime. An integration suite against a real PostgreSQL covering concurrent locking, the partial index, arrays and the atomic counter.
Fixed¶
set()/values()silently wrote garbage. A non-scalar value —{ raw: "attempts + 1" }, an array on a scalar column, a function — was bound as a parameter, and the driver serialized it (or storednull) with no error at all: anINTEGER NOT NULLcolumn becamenull. Now any value that is neither a scalar nor a branded expression raisesValidationErrorwhile the query is built, naming the column and the expected type. A key that is not a column of the model is rejected too.- The INSERT template cache must not serve statements carrying a conflict predicate or an expression among the values, whose SQL depends on the values. Those take an uncached path that renders the clauses in statement order, keeping placeholder positions correct.
- PostgreSQL introspection read every array column as
text, which madecheckDriftPostgresreport drift forever on a correct schema.
Documented¶
ilikeis pattern matching, not equality:%and_are wildcards, and{ ilike: "%" }matches every row. Used as a "case-insensitive eq" in an authentication lookup, it is a login bypass. The operator's documentation now says so, andieqexists precisely to remove the temptation.
Known limitations¶
FOR UPDATE/FOR SHARE, theON CONFLICTpredicate andcolumn.array()have no equivalent on every dialect; each throws an explicit error where it is unsupported, rather than degrading silently.- Subqueries in
WHERE ... IN (...)are still outside the builder — the queue pattern is written asSELECT ... FOR UPDATE SKIP LOCKEDfollowed byUPDATE ... WHERE id IN (ids)in the same transaction, or viasession.raw.
[0.4.0] — 2026-07-09¶
Added¶
- Foreign keys, UNIQUE and table constraints — column-level
.references(...)and.unique()(SQLAlchemymapped_column(ForeignKey(...), unique=True)style) plusstatic tableArgs = () => [unique(...), foreignKey(...)]for composite/named (__table_args__style). Rendered across all three dialects, with reversibleadd_constraint/drop_constraintoperations, diff, replay and drift detection. See the Foreign keys & UNIQUE recipe.
[0.1.0] — 2026-06-29¶
First public release, published on npm.
Added¶
- Phase 1 — class-based declarative schema. The
Modelbase class + thecolumnfactory with a rich type catalog mirroring 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→literal union). Modifiers.primaryKey(),.notNull(),.default(),.onUpdate(). Types inferred byInferModel(SELECT) andInferInsert(insert). - Portable defaults (
sql.now(),sql.uuidv4(), etc.), stored on the column for the migration IR. parseDatabaseUrl/detectDialect— database identified via URL (à lamake_url).- Serialization (
toDict/toJSON/stringify/fromDict/parse) with per-column-type coercion. - Phase 3 — operators typed per column type (
OperatorsFor<T>):string→like/ilike/in;number/bigint/Date→ordered+between;boolean→ eq/isNull. An invalid combination = compile error. - Phase 4a — per-dialect SQL compilation:
getDialect(...).compile(node)→ parameterized{ sql, params }(?/$1), SELECT/INSERT/UPDATE/DELETE +RETURNING; nativeilikein Postgres. - Phase 4b — real execution:
createEngine(async) /createSyncEngine(SQLite sync),Session.executewith typed terminals,engine.transaction+ savepoints, row coercion. SQLite vianode:sqlite; PostgreSQL viapostgres.js. - Phase 5 — typed joins:
join(Model, alias).innerJoin/leftJoin(...)→ composite type{ [alias]: Row },leftJoinnullable; typedalias.columnrefs. - Phase 6 — migrations (
tempest-db-js/migrations, Alembic-style):reflectSchema,diffSchema, typed operations +invert,renderOperation(per-dialect DDL),generateMigration, DAG graph (topoOrder/heads),MigrationRunner(realupgrade/downgrade). SQL only in the renderer. - Phase 7 — repository:
BaseRepository<Model>(typed CRUD + pagination) overAsyncSession, 404 convention (RecordNotFound/[]),PaginationFilter/PaginationResultaligned withtempest-fastapi-sdk. - Refinements:
and/or/notcombinators inwhere(select/update/delete/ join); SQLite batch-mode (recreate_table) for column changes preserving the data; SQLite introspection +checkDrift(compares the live DB with the models). - More refinements:
session.stream(query)(lazy sync/async iteration);hasMany/belongsTorelations +loadRelations(typed eager-loading, no N+1); migration CLIrunMigrationCli(upgrade/downgrade/check/revision --autogenerate); structural PostgreSQL (introspection, named enum,PoolOptions). - Phase 2 — typed query builder (pure AST, no execution).
select(Model)/select(Model, [cols])→ full-row orPickinference, with.where(),.orderBy(),.limit(),.offset().insert(Model).values(...)typed byInferInsert, with.returning().update(Model)/del(Model)with a typed state guard: the query only becomes executable after an explicit.where(...)or.unguarded()— an accidental full-table UPDATE/DELETE becomes a compile error..returning(cols)inferring aPickprojection on every mutation.
- Bilingual documentation (PT-BR + EN-US) in MkDocs Material, published on GitHub Pages.
Notes¶
- Alpha (
v0.1.0). The public surface may still change beforev1.0. - SQLite execution is real and tested (
node:sqlite); PostgreSQL viapostgres.js.