Skip to main content
Version: 1.17.0

Release Notes

Quark is v1.17.0 on the stable v1.x line. v1.x keeps API compatibility; breaking changes go to v2.x.

Every release since v0.10.0 — including all of v1.x — is a drop-in upgrade for the library: no breaking changes. The one thing v1.13.0 does break is how the CLI is installed by version — it moved to a module of its own, with its own tag series; see v1.13.0 below. The last breaking change to the library was in v0.9.0, and its migration guide is still the one to follow if you are coming from v0.8 or earlier.

Full commit-level detail lives in the CHANGELOG and on GitHub Releases.

v1.17.0​

A minor that fixes what several write paths did on some engines — an UpdateBatch that left the caller's transaction, upserts that wrote another row's key or overwrote a column, writes that left a cached read stale — and adds two opt-ins: a statement cache, and version checks for UpdateBatch. It also publishes the contract for code that extends Quark, with a conformance kit and a template for drivers. If your code calls UpdateBatch on a ForTx query, calls Upsert, or caches reads, upgrade.

Fixed​

UpdateBatch inside your transaction stays in it (QK-57; #455). On a ForTx query, UpdateBatch opened a transaction of its own on another connection of the pool. When you held no lock on its rows, the batch committed on its own and your rollback did not undo it; when you did, it waited for your lock until the query timeout, and your transaction failed with it. On SQLite, where the first write of a transaction takes the only writer lock, an UpdateBatch after any write in the same transaction hung until the query timeout. It now runs in a savepoint of your transaction: a failing row undoes the batch and leaves your transaction usable (on PostgreSQL, not aborted), and your commit or rollback decides the rest. UpsertBatch under RowLevelSecurityClient takes the same savepoint, so a batch that meets another tenant's row leaves nothing written even if you go on and commit.

Upsert writes the key of the row it wrote (QK-63, QK-64; #461). On MySQL, and on MariaDB through the MySQL dialect, the key came from a separate SELECT LAST_INSERT_ID() on whichever pooled connection answered, so an upsert that updated an existing row could write another row's key into the entity. On SQL Server and Oracle, an entity that carried a key kept it while the row was inserted with the key the identity column assigned. The key now comes from the statement that wrote the row — the INSERT's own result on MySQL, OUTPUT on SQL Server, MERGE … RETURNING on Oracle — and where the statement cannot say which row it wrote, the entity keeps the key it had; it never gets another row's. On SQL Server, a MERGE with no update branch that met an existing row returned sql.ErrNoRows; it now returns nil, as the other engines do. The CRUD reference has the key each engine leaves in the entity. On Oracle, Upsert and UpsertBatch need Oracle 23ai, as they already did; the Dialects page now says so.

Upsert without updateCols (QK-48, QK-61, QK-62; #459, #460). Three defects, each on some engines:

  • On PostgreSQL and SQLite a conflict returned a raw sql.ErrNoRows. It now returns nil, as on the other four engines, and writes no key back. If you read that error as "the row already existed", you now get nil.
  • On MySQL and MariaDB, when the duplicate was on a unique key other than the conflict column — the primary key, say — the existing row's conflict column was overwritten with the incoming value. The row is now left as it was.
  • On SQL Server and Oracle, the MERGE's update set included the primary key and created_at: a non-zero integer key made the engine refuse the whole statement (Msg 8102, ORA-32796), and created_at was overwritten. Both are left out, as Update leaves them out; updated_at is still refreshed.

The engines still disagree on what an empty updateCols means: PostgreSQL, SQLite, MySQL and MariaDB leave the conflicting row as it was, and SQL Server and Oracle update its other columns. Making them agree would change what existing calls do on one side, so it waits for v2; the CRUD reference says what each engine does.

Every write drops the cache entries it made stale (QK-66; #462). Upsert on PostgreSQL, SQLite and MariaDB dropped no cache tag, so a cached List kept answering with its old rows after an upsert; Restore and DeleteBatch did not drop the rows' tags; and a CreateBatch that failed partway on SQL Server dropped nothing for the rows it left inserted. Each write path now drops its table tag and the tags of the rows it changed. Inside a transaction the tags are dropped when the statement runs and again when the transaction commits, so a read from outside that cached the old rows before the commit no longer outlives it. Raw SQL (Client.Exec, RawQuery, Call, Client.Raw()) still drops nothing; the caching reference shows how to drop tags yourself.

CreateBatch on SQL Server returns the engine's error (QK-53; #453). When a row was rejected — by a unique key, for instance — the caller got converting NULL to int64 is unsupported instead of the engine's error, so IsUniqueViolation answered false. It now gets the engine's error, classified.

ApplyPlan applies a plan again after the schema was reset (QK-51; #458). On MySQL, MariaDB and Oracle, which have no transactional DDL, ApplyPlan keeps a checkpoint of the operations it applied. The same plan applied again after the schema was dropped found its checkpoint and changed nothing. When a checkpoint exists, ApplyPlan now reads the live schema first and, if an operation it recorded is no longer in effect, applies the plan from the start. Resuming after a failure partway through a plan works as before.

Foreign-key actions are written as the engine takes them (QK-49; #457). Quark copied a foreign key's ON DELETE and ON UPDATE into the DDL as given. Oracle refuses NO ACTION (its default), RESTRICT, SET DEFAULT and any ON UPDATE clause, and SQL Server refuses RESTRICT, so AddForeignKey and ApplyPlan failed on the engine — with part of the plan already applied where DDL commits on its own. Oracle's NO ACTION is now left out, and an action the engine lacks is refused with ErrUnsupportedFeature before any statement is sent; ApplyPlan checks every foreign key of the plan before its first operation. The dialect answers through a new optional interface, quarkdriver.ReferentialActioner; a dialect that does not implement it writes the actions as given, as before. MySQL and MariaDB accept SET DEFAULT without doing it; that is documented, not refused, because refusing it would fail a call that succeeds today.

Two dialect fixes (#441). On MariaDB, ApplyPlan's drop of a CHECK constraint always failed: the dialect wrote MySQL's DROP CHECK, which MariaDB does not have, and now writes DROP CONSTRAINT. On SQL Server, ForShare().SkipLocked() wrote a pair of lock hints the engine refuses; it now returns ErrUnsupportedFeature, as MariaDB's shared lock with modifiers does.

Added​

A statement cache, off by default (#452). WithStatementCache(size) keeps up to size prepared statements per pool and reuses them for the query builder's statements that carry arguments, instead of having the driver prepare, execute and close one on every call; inside a transaction each statement is prepared once on the transaction's connection. On MySQL 8.4, FindByPK took 46–48 % less time with the cache than without it in each of five runs on the CI runner (145 µs against 270 µs on an AMD EPYC 7763), and 41–42 % less in two runs on a laptop (Apple M4 Pro, Docker Desktop). It applies on MySQL, MariaDB, SQL Server and SQLite. It is ignored on PostgreSQL, whose pgx driver already keeps a statement cache per connection, and on Oracle: with go-ora v2.9.0, a prepared count re-executed on the same connection after other statements answered 0 for a row that existed, and Quark relies on such counts to tell a stale entity from one the query excludes. Sizing it against max_prepared_stmt_count, and how it compares with interpolateParams=true, are on the Dialects page.

CheckVersions() for UpdateBatch (#463). On a model with a quark:"version" field, Update, UpdateFields and Tracked.Save return ErrStaleEntity when the row's version moved. UpdateBatch sent the same version check and ignored its result: a stale row was skipped, the rest were written, the call returned nil, and the written rows' versions moved in the database but not in memory, so the next Update of the same struct failed with a conflict nobody caused. Under Query[T].CheckVersions(), UpdateBatch is all or nothing: if any row is stale or its key does not exist, the whole batch is rolled back and the error joins one ErrStaleEntity per stale row, naming its key; otherwise the in-memory versions are bumped. A row the query's conditions exclude is skipped, as Update skips it. Without CheckVersions() nothing changes in v1.

In 2.0, UpdateBatch checks versions by default. A batch that returns nil today with a stale row in it will return an error, and CheckVersions() will keep compiling with nothing left to switch on. The change comes no earlier than 2027-01-04, and it is listed in the register of deprecations and behaviour changes. To get the 2.0 behaviour now, add CheckVersions() to your batches. Upsert, Delete, HardDelete and Restore still carry no version check; the modeling guide has the table of which writes do.

CreateBatch keys in one round trip per chunk on SQL Server and MySQL (#452). These engines have no RETURNING for a multi-row INSERT, so CreateBatch with generated keys sent one statement per row. On SQL Server each chunk is now one MERGE whose OUTPUT returns every key next to its row's position, on tables whose key is an IDENTITY column without an INSTEAD OF trigger. On MySQL each chunk is one multi-row INSERT, and the keys are computed from the first one, only when the server runs innodb_autoinc_lock_mode 0 or 1 — the settings under which MySQL documents that a statement's keys are consecutive. MySQL 8's default is 2, so a default server keeps one INSERT per row. With lock mode 1 on MySQL 8.4, 1000 rows took 9.6 ms on the CI runner, against 241 ms sent one INSERT per row. If a row of a chunk fails, the chunk runs again row by row, so the rows left inserted and the error are what they were. Observers see the new statements: a MERGE on SQL Server; a savepoint, the multi-row INSERT and a read of auto_increment_increment on MySQL; and one catalog probe per client and table. The conditions are in the batch operations guide.

Performance​

Less work per list, preload and batch (#444). List no longer serializes its result to JSON when no cache is configured. On PostgreSQL a preload selects each chunk of keys with = ANY($1) and one array parameter instead of a list of placeholders, and assembles the children onto their parents with one growth per parent. Numbered placeholders are formatted without fmt, and Validate skips a model that declares no rule. On the engine bench against PostgreSQL 16 on the CI runner, Quark's time over hand-written database/sql, in the recorded medians before and after, went from 1.98 to 1.25 for 100 parents with their 500 children, from 1.35 to 1.17 for a 100-row List, and from 1.33 to 1.09 for a 1000-row CreateBatch. The preload's SQL changes shape on PostgreSQL, which observers and the slow-query log show. The bench now checks times only on the CPU models it has a record for, and keeps one ratio per CPU model where the same code measures up to 20 % apart between models (#454); the Benchmarks page has the method and every figure.

Extension contract​

What you can implement, and what a v1 minor may change (#443, #464). The new extension contract page lists the 53 types of quark and quarkdriver that code outside them can implement and hand to Quark. Each is marked as an extension point — a type you implement and Quark calls, such as a dialect, a hook, a cache store, a middleware or an observer — or as plumbing, a type Quark implements and hands to you, such as Executor, Event or Expr, and each has its stability. The rule is now recorded: within v1, an extension point gains no method and loses or changes none, and a new capability arrives as a separate optional interface with a documented default. A plumbing interface may gain a method in a minor release — never in a patch — and the release notes name it. Code that only calls plumbing is unaffected; your own implementation of a plumbing interface is not covered. This narrows the versioning promise in that one place; the compatibility decision record has the reasoning. No plumbing interface gains a method in this release, and a test holds every extension point to its recorded shape. Three conventions get exported interfaces — quark.TableNamer, quark.Validator and quarkdriver.SQLStater — which a type that already has the method satisfies.

Writing a driver (#441, #446). drivertest.VerifyDialect runs a dialect against a live database of its engine and checks every method of quarkdriver.Dialect, and of each optional interface the dialect implements, by what the engine then holds or refuses. quarkdriver/drivertest/suite runs 43 engine-generic subtests of Quark's own engine suite on any client. The five driver modules run the kit against their engines. The Writing a driver guide goes from an empty module to a driver that passes the kit; its code is a template module tested in CI, and the guide is checked line by line against it. It also says what the contract does not express yet: the places where Quark still branches on an engine's name.

Every statement reaches the middleware and the observers (#447). Schema work, catalog reads, savepoints and raw SQL skipped the middleware chain, the observers or both, so neither quark/otel nor your own observer saw them. Every statement Quark sends — Migrate, Sync, ApplyPlan, the migration lock, savepoints, Client.Exec, RawQuery, audit rows, the tenant checks of RowLevelSecurityNative — now passes the middleware chain and reaches the observers, in the order the engine receives them. QueryEvent.Kind says what each statement is for (query, exec, ddl, introspection, savepoint, raw), StatementKindFromContext gives a middleware the same, and quark/otel tags spans and metrics with quark.statement.kind. Expect more spans and observer events after upgrading. Still not observed: transaction control, which the driver sends; what your code sends on Client.Raw() or on the *sql.Tx an UpTx migration receives; and the LISTEN connection.

Build and modules​

Built and tested with Go 1.26.9 (#466). The toolchain that builds and tests Quark, and builds the CLI archives attached to this release, moves from Go 1.26.6 to 1.26.9. That release fixes GO-2026-6617, a race in the HTTP/2 server of net/http that can crash a process serving HTTP/2, and five other vulnerabilities in net/http and crypto/tls that govulncheck reported under 1.26.6. The library still requires Go 1.25.7 or later and builds with the Go your application uses; if your application serves HTTP/2, build it with a Go release that has the fix.

Module floors. cmd/quark (v1.2.1) and the five drivers/* modules (v0.3.0) require github.com/jcsvwinston/quark v1.16.0 (#465). The driver modules take a minor because they gained the conformance-kit tests, and the requirements those tests bring (#441); no driver code changed.

v1.16.0​

A minor that closes three cross-tenant defects of RowLevelSecurityClient — by-key reads, upserts and map updates could reach another tenant's rows — and opens the dialect contract to drivers written outside this repository. If your application uses a tenant router with RowLevelSecurityClient, upgrade.

Fixed​

By-key reads and writes stay inside the tenant (QK-42, QK-40, QK-41; #435). Under RowLevelSecurityClient, Find(id) read a row of another tenant, and Update(entity), UpdateBatch, Delete(entity), HardDelete(entity), DeleteBatch and Restore wrote by primary key alone. Every by-key path now carries the tenant predicate and the caller's own Where on top of the key; a key of another tenant reads as ErrNotFound, and a write the conditions exclude touches nothing, fires no After* hook, writes no audit entry and returns (0, nil) — what UpdateMap, DeleteBy and UpdateFields already returned. A real version conflict is still ErrStaleEntity. Create, CreateBatch and the writes stamp the resolved tenant on the row and log quark.tenant.foreign_value_replaced when the entity carried another. The soft-delete and tenant scopes now AND with the caller's whole expression in parentheses, so an Or group no longer escapes them: a trashed row no longer comes back through Or, and a bare Or(…) no longer returns rows outside the tenant. Find also stopped leaving its key on the query it was called on, and First stopped leaving LIMIT 1.

Upserts and map updates stay inside the tenant (QK-43, QK-44; #437). Under RowLevelSecurityClient, the update branch of Upsert and UpsertBatch updated whichever row held the conflicting key, in any tenant. It is now guarded per engine (ON CONFLICT … DO UPDATE … WHERE, ON DUPLICATE KEY UPDATE … IF(…), MERGE … WHEN MATCHED AND …); a key held by another tenant updates nothing and returns an error wrapping ErrConstraintViolation, as a Create of that key does. UpsertBatch is all or nothing in that mode. UpdateMap writes the resolved tenant when its map names the tenant column.

IN, NOT IN and BETWEEN accept any slice (QK-33; #425). Where(col, "IN", []string{…}) panicked; any slice or array is now expanded, and a byte slice (one value to database/sql) is refused with a hint. The dialect registry is safe for concurrent use.

Module floors. cmd/quark (v1.2.0) and the five drivers/* modules (v0.2.5) require github.com/jcsvwinston/quark v1.15.2 (#439).

Added​

The dialect contract lives in quarkdriver (#436). Dialect, its optional interfaces, the schema model, the lock types, the sentinels and the registry (RegisterDialect, LookupDialect) are declared in the leaf package; quark keeps every name as an alias of the same type, so code written against v1.15 compiles unchanged. A driver can now implement and register a dialect importing only quarkdriver.

The schema path asks the dialect instead of reading its name (#432, #433). Migrate, PlanMigration, Sync, the bookkeeping tables and ApplyPlan decide column types, auto-increment keys, IF NOT EXISTS, transactional DDL, ALTER COLUMN, drops and SQLite's table rebuild through optional interfaces in quarkdriver (ColumnTyper, AutoIncrementer, IdempotentDDL, ColumnAlterer, ObjectDropper, TableRebuilder). The six built-in dialects emit the same DDL as before; a dialect that implements none of them gets portable DDL or ErrUnsupportedFeature, never a silently wrong statement. Oracle's dialect no longer claims transactional DDL.

Tested framework integrations (#426, #427). net/http, chi, Echo, Gin, gRPC and Nucleus fixtures serve the same API on a client and pass one battery in CI; the frameworks guide is checked line by line against them.

quark init --with chi|echo|gin|grpc|nucleus (#438). The scaffold writes the same wiring the tested fixtures run — request context, a transaction per request, error-to-status mapping — and CI builds, vets and tests its output for each framework. The --with nucleus output changes shape: it is now the fixture's module.

A performance bench on a real PostgreSQL and MySQL (#431). benchmarks.mdx now publishes Quark against database/sql and pgx per operation, with the method and the open target.

v1.15.2​

A patch that fixes the rows that conditional writes touch — a defect present since the first release — and raises the sibling module floors.

Fixed​

Writes with conditions now match the rows a read with the same conditions returns (#428). Since the first release, DeleteBy, UpdateMap and UpdateFields rendered their own WHERE as col OP ? joined by AND, ignoring each condition's logic. WhereNot lost its NOT: WhereNot("status", "=", "active").DeleteBy() ran DELETE … WHERE "status" = ? and deleted the rows it was meant to keep, and UpdateMap and UpdateFields wrote them the same way. Or groups were joined with AND, and IN, NOT IN, BETWEEN and NOT BETWEEN failed with a driver error. The write paths now go through the same renderer as List, wrapped in parentheses after the key and tenant predicates; a test checks on SQLite and on the six-engine suite that each write touches exactly the rows List returns for the same conditions. PreloadWhere filters use the same renderer. If you call DeleteBy, UpdateMap or UpdateFields after WhereNot, check the data those calls touched; plain Where with comparison operators was not affected.

Module floors. cmd/quark (v1.1.2) and the five drivers/* modules (v0.2.4) require github.com/jcsvwinston/quark v1.15.1 (#429).

Documentation​

The CRUD reference said Update merges the primary key with the query's Where clauses and showed Where("tenant_id", …) as a guard. It never did: Update(entity), UpdateBatch, Delete(entity) and HardDelete(entity) write by key and ignore the query's conditions. The reference now says so; until that changes, use UpdateFields or DeleteBy when a condition must guard a write.

v1.15.1​

A patch with no change to the library: the CLI and the five driver modules raise their floor on the root to v1.15.0 and are released again.

Changed​

Module floors. cmd/quark (v1.1.1) and the five drivers/* modules (v0.2.3) require github.com/jcsvwinston/quark v1.15.0 instead of v1.14.0 (#423). Go resolves the highest version in the graph, so an application that already required v1.15.0 builds the same code; go install of the CLI and a driver added on its own now start from v1.15.0 too.

v1.15.0​

A minor from arc A8 of the suite plan, "Quark enterprise". What Quark can do as an enterprise data layer is measured by the enterprise bench: 69 controls, each a probe against the public API. 20 were present when it was first run against v1.14.0; 47 are present at this release, and the 22 that are not each say what is missing.

Added​

Escaped LIKE. WhereLike, WhereNotLike, WhereContains, WhereStartsWith and WhereEndsWith (and the typed Contains, StartsWith, EndsWith, LikeEscaped, NotLikeEscaped) escape %, _ and the escape character inside the user's text and declare the escape per engine — ESCAPE '\', doubled on MySQL and MariaDB, with [ also escaped on SQL Server. The plain Where(col, "LIKE", pattern) is unchanged and still passes the pattern through: changing what a backslash means on three engines is a breaking change, and it waits for the next major with the others.

The plan carries what the model declares. Migrate and PlanMigration emit foreign keys and CHECK constraints inline and the indexes alongside; Diff orders parent tables first, matches foreign keys by what they reference (SQLite does not keep their names) and indexes by shape. The model declares an index with quark:"index" or quark:"index=<name>", and a check with quark:"check=<expr>" or db:"col,enum=a|b". Constraints that exist in the database and are not declared are never proposed for dropping.

ALTER COLUMN on every engine. Type, nullability, default and primary key changes apply on the six engines, each in its dialect's form. SQLite has no ALTER COLUMN, so it rebuilds the table, carrying the indexes, triggers, checks and foreign keys it reads back from sqlite_master; a CHECK it cannot read back is refused rather than silently dropped.

Types. A [16]byte-shaped value gets the engine's uuid type (UUID on PostgreSQL and SQLite, CHAR(36), NCHAR(36) or VARCHAR2(36) elsewhere), and a key mapped to a custom column type keeps its PRIMARY KEY. Raw slices and maps are stored as native arrays on PostgreSQL and as JSON elsewhere; quark.Range[T] maps to the PostgreSQL range types and to JSON elsewhere; net.IP to INET. The operators @>, <@, &&, <<, >>, <<= and >>= are known to the builder and refused with ErrUnsupportedFeature off PostgreSQL. The type matrix is regenerated from the mapper.

Native row-level security that verifies. Under RowLevelSecurityNative, GetClient fails closed off PostgreSQL, and the router checks at first use per table that the engine enforces RLS (pg_class, pg_policy), refusing with ErrRLSNotEnforced when it cannot confirm it; SkipPolicyVerification opts out. Native RLS stays PostgreSQL-only, with the reason in the guide.

Keyset pagination. PaginateAfter(pageSize, token) seeks to the last row read through the query's ORDER BY — the tuple comparison written out, because SQL Server and Oracle do not have it — with the primary key appended so the order is total. One statement per page; it returns a KeysetPage with Items, HasMore and an opaque Next token. A token from a query with a different ORDER BY is refused with ErrInvalidQuery. Paginate keeps its page-number contract and its total.

The CLI. quark migrate diff | plan | verify --from-models <dir> reads the model structs from source, maps them with the runtime's own type mapping and diffs them against the live schema; verify exits non-zero on drift, so it works as a CI gate. quark tenant install-rls-policies | verify-rls-policies --from-models <dir> install and verify the policies the guide used to print, on PostgreSQL. The static reader learns the default, index, check and enum tags, and quark model accepts index. See the CLI guide.

Changed​

PlanMigration with no models is refused with ErrInvalidQuery. The desired schema of nothing is "drop every live table", and that is the plan a binary compiled without the user's models used to be handed.

Fixed​

Tenant confinement survives a transaction (QK-26). ForTx takes the tenant from the *Tx; a context that names a different tenant fails with ErrTenantMismatch. The rule is that the transaction fixes the tenant: a context without one inherits it, and one that names another is a mistake. Writes had also been ignoring the query's deferred error, so a query that should have been refused executed unconfined; they check it now.

precision and scale refined every column type they were put on (QK-28). They now refine float columns only and warn elsewhere. The decimal family — numeric, decimal, Oracle's NUMBER(p,s) — is one type to the diff (QK-30), so a plan no longer proposes rewriting a column into itself.

v1.14.0​

A minor that widens what the typed API can express and fixes the width of the column types it generates.

Added​

Queries that needed RawQuery, and no longer do. The query bench measures which of 60 real-world queries the typed API can express; it went from 44 to 58.

  • CountDistinct, Case for conditional aggregates, JSONExtract to put a JSON member in the SELECT list, and the window functions NTile, PercentRank, CumeDist, FirstValue, LastValue and NthValue.
  • FromCTE reads from a CTE declared with With, for when the derived rows are the query; FromTable names the source table for a projection struct.
  • PreloadWhere filters which related rows eager-load.
  • Window.Rows / Window.Range set the frame. Without one, a "moving average" is silently a running average.
  • UpdateMap accepts an Expr value, so stock = stock - 1 is expressible with Subtract and keeps its atomicity.
  • OnExpr puts a literal in a JOIN … ON. For an outer join that is not cosmetic: moving the predicate to WHERE turns a LEFT JOIN into an inner one. FullJoin and CrossJoin complete the set.

Plan.Down() returns the plan that undoes a migration plan. Operations that record a name but not the shape behind it — DROP TABLE, DROP COLUMN — return ErrIrreversibleOperation naming what is missing, instead of a partial rollback that reports success.

Changed​

A db tag whose column name cannot be an identifier is now refused. Quark and Nucleus's pkg/model both read a tag called db with different grammars, and a model written the other way produced a column called literally column:email;unique;not null — silently. A name that is a legal identifier but reads like a directive (db:"pk") warns instead, because a table really can have a column called pk.

Fixed​

Integer and float columns were too narrow. Every Go integer width mapped to a single INTEGER — four bytes on PostgreSQL, MySQL and SQL Server — so an int64 past 2³¹ was rejected by those engines while looking fine on SQLite. Auto-increment keys were SERIAL/INT and ran out at 2,147,483,647 rows, and float64 mapped to single-precision REAL on PostgreSQL.

Integers now map by width, floats take the double-precision type per engine, and keys are 64-bit everywhere. The type matrix is generated from the mapper, so it cannot drift from the code.

If you have tables from before this release, PlanMigration reports the widening as an ALTER COLUMN. Applying it loses no data, but on a large table the engine may rewrite it — run it when a rewrite is acceptable.

v1.13.0​

A packaging release: the library's module graph loses two thirds of its entries and the binary it produces does not change. The CLI becomes a module of its own, with its own tag series — the one thing here that changes what an installed command reports about itself.

The CLI, the acceptance harness and the engine suites move to modules of their own. What shared a module with the library were the two things that need every engine: cmd/quark, which points at whatever database it is given, and the per-engine suites, which start real databases in containers. Their requirements sat in the root go.mod, so they sat in the build list of every program that imported Quark. Measured on the same consumer, before and after: 123 → 39 modules in the build list, 6 → 1 driver requirements, 5 → 0 testcontainers-go requirements — and the binary links the same 11 modules and the same 162 packages as before. This is graph hygiene, not a faster or smaller program: what changes is what an SBOM, a dependency dashboard and go mod download all say about code that was never linked into anything. The one driver that stays in the library's own requirements is modernc.org/sqlite, because the library keeps white-box tests that open a real database through unexported methods.

Upgrading: move the driver modules with the library. drivers/mssql, drivers/mysql, drivers/oracle and drivers/sqlite at v0.1.x import a package that left the root module in this release, so holding one of them while moving the library to v1.13.0 does not build. Move them to v0.2.0 — worth doing for its own sake, because those predicates covered all six engines and requiring any one driver used to pull the other five into your module graph. drivers/postgres was never affected.

Installing the CLI. go install github.com/jcsvwinston/quark/cmd/quark@latest keeps working and installs the same program. @latest now resolves the .../cmd/quark module, whose tags are cmd/quark/vX.Y.Z, and this release publishes cmd/quark v1.0.0: the series is new, the program is not, and quark version reports that series from here on. What breaks is a selector naming a root version — a nested module's directory is outside its parent, so go install .../cmd/quark@v1.13.0 fails while the same command against v1.12.0 or earlier still works. Pin cmd/quark/v1.0.0 instead.

Six fuzz targets over the surfaces that read untrusted input — the identifier, ON-clause and JSON-path guards, the raw-query masker, the db tag reader and the six dialects' quoting — each asserting a property rather than merely not crashing, with a short seeded lane on every pull request. One of them found the bug below on the way in.

A db tag's column name is read the same way on every path. ColumnFromDBTag trimmed the name only when the tag carried options after a comma, while parseDBTag trimmed unconditionally, so on a tag with no options the two readers of one string gave two answers.

Modules. cmd/quark v1.0.0 debuts; the five driver modules go to v0.2.0 with no change to what they register.

v1.12.0​

Quark's part of the suite starter (arc A2): quark init can write the Nucleus module that mounts the ORM, and the integration examples cover the routers people actually use.

quark init --with nucleus writes internal/<app>/module.go, a Nucleus module wrapping the *quark.Client: an example Note model registered and migrated on start, GET/POST /api/notes through quark.For[Note], the module's own policy rows and CSRF exemption, and the driver module for the chosen dialect blank-imported so a duplicate title answers 409. It also writes the nucleus.yml the framework needs to boot, naming the same database as .quark.yml, and never overwrites either file. The module is emitted as source: Quark carries no framework dependency, so nothing in this repository compiles against Nucleus. The printed next steps name nucleus new <app> --with quark as the full-application path.

Framework examples. examples/chi, examples/echo and examples/gin are modules of their own — one shared client, the request context per query, quark.IsUniqueViolation mapped to 409 — each with a test driving its handlers; the new Frameworks guide covers net/http, chi, Echo, Gin and Nucleus.

drivers/postgres registers its listener through the public quarkdriver.ListenerFactory contract (the second step of the audit finding QK-8) and requires Quark v1.11.0.

v1.11.0​

The minor that pays Quark's share of the maturity audit of 2026-09-03.

The migrator takes the schema lock by default. Migrator.Up and Down acquire the engine's cluster-wide migration lock before touching the schema — pg_advisory_lock on PostgreSQL, GET_LOCK on MySQL and MariaDB, sp_getapplock on SQL Server, DBMS_LOCK on Oracle — so two replicas running migrate up at the same time apply each migration once. SQLite, which has no distributed lock and a single writer, proceeds without one. WithoutLock(), WithLockTimeout, WithLockName and WithLogger tune it; progress goes through the client's logger (Client.Logger()), and the quark migrate commands keep printing their plain sentences.

Transactional migrations. A migration written as UpTx / DownTx receives a *sql.Tx; where the engine rolls DDL back (PostgreSQL, SQLite, SQL Server) the migration and its ledger row commit together. Where DDL commits itself, only the ledger row is atomic with the last statement, and the migrator says so at debug level.

A public listener contract. quarkdriver.ListenerFactory takes an IdentifierValidator interface instead of the internal guard type the previous constructor named, which no module outside this repository could implement. The previous names stay, deprecated and adapted, so drivers/postgres keeps working and adopts the public contract in its next release.

Also: the README explains what go.mod lists versus what a binary links, commits and pull-request titles are in English (CI checks the title), and the five drivers/* modules require Quark v1.10.1.

v1.10.1​

A maintenance release for the driver-module seam opened in v1.9.0.

quark.New("postgres", …) with lib/pq linked, and quark.New("sqlite3", …) with mattn/go-sqlite3, failed in v1.10.0 asking you to import a module that was already there: the registration check only looked for the alias (pgx, sqlite). Both names are now accepted, and when a driver for the same engine is linked under another name the message says so.

When a driver is registered by a plain import instead of its drivers/<engine> module, the client now logs a warning: without the module, IsUniqueViolation, deadlock retry and replica failover answer false in silence.

The five driver modules require this release line and build on their own; CI verifies each one without a workspace. The README, the installation and getting-started guides, the deployment and events pages and every example import drivers/<engine>; the comparison table is dated and corrected (GORM ships generics since 1.30; ent runs on database/sql). Examples with a driver are their own modules (cd examples/<engine> && go run .). The 40 MB test binary that was tracked in the repository is gone.

v1.10.0​

When a driver module is missing, the error now names it.

v1.9.0 moved each engine's driver into its own module, but opening a database without one still failed with Go's own message — sql: unknown driver "sqlite" (forgotten import?) — which names neither the module nor the line to add. It now prints the go get and the import _, and it understands the names people type: postgresql, postgres and pq all point at drivers/postgres, mariadb at drivers/mysql.

For an engine Quark does not publish a driver for, it stays quiet and lets the original error through.

v1.9.0​

The database drivers move out of the library, each into its own module. Quark never registered a driver, but it carried the driver error types — a MySQL deadlock is a *mysql.MySQLError, and naming that type imports MySQL — so every binary carried all five engines whichever one it talked to. A program that links the root package and nothing else goes from 24 MB to 6, and from 171 modules to 129.

Nothing about your configuration changes. Add one blank import for the engine you use:

import _ "github.com/jcsvwinston/quark/drivers/postgres"

Available: drivers/postgres, drivers/mysql, drivers/sqlite, drivers/mssql, drivers/oracle. A module registers the driver and how that driver reports a unique violation, a deadlock and a lost connection — which matters because none of those checks fails when it cannot recognise an error: it answers "no". Without the module, a duplicate key would be reported as an internal error and a deadlocked transaction would stop being retried, neither of them visibly.

PostgreSQL is the exception and needs no classifier: every PostgreSQL driver exposes its SQLSTATE through a method, so the same code covers lib/pq, pq and pgx alike.

The LISTEN/NOTIFY listener moves to drivers/postgres, since it needs the pgx connection underneath database/sql. EventPayload and EventListener are now aliases, so code that already uses them does not change.

Full notes: v1.9.0.

v1.8.0​

A minor release that closes the core and CLI findings of an end-to-end audit of the library. Most of it is about queries and commands that were accepted and then quietly did something other than what they said: a column name the engine never had, a sort direction it ignored, a scaffolded project whose first command failed. It also adds NewWithDB, which mounts Quark on a *sql.DB the host application already owns.

Added​

  • Qualified column names under a join. With a join in the query, filtering on a column whose name exists in both tables — the primary key included — could not be expressed: SQLGuard rejected the qualified orders.id as an invalid identifier, and the engine rejected the bare id as ambiguous. Where, OrderBy, GroupBy, Select and Col now accept the table.column form when the query has joins, validating each segment with the rules they always applied and quoting the two separately. Without joins nothing changes.

  • WithStrictColumns() — column names checked against the model. The identifier guard is lexical, so Where("agee", ">", 1) passed it as well-formed; on SQLite an unknown identifier then degrades to a string literal, and the query returned every row of the table without reporting an error. This option validates each column against the registered model in Where, WhereIn, WhereBetween, OrderBy, GroupBy, Select, Having and the aggregates, failing with ErrInvalidQuery that names the columns the model does have. Joins, raw and AST fragments, and SelectExpr aliases are exempt. It is opt-in: the default stays as it was.

  • WithoutAssociations() — write only this entity's row. Update saves the preloaded associations recursively, from the snapshot held in memory, so a change another writer made to one of those child rows between your read and your update is overwritten. There was no way to ask for a single-row update. There is now, on Update and on Create (belongs_to included), and when the recursive save does run Update logs a warning naming the associations it is about to rewrite. The default behaviour is unchanged.

  • WhereInOf and DeleteBatchOf. WhereIn and DeleteBatch take []any, so the ordinary case — a slice of ids returned by an earlier query — needed a conversion loop at every call site. These are package-level functions rather than methods because a Go method cannot introduce a second type parameter.

  • NewWithDB(driver, db, opts…) — Quark over a pool you already opened. An application that already holds a *sql.DB can hand it to Quark instead of configuring a second DSN and a second pool. A borrowed handle stays the caller's: Close does not close it, and WithOptions derives clients over the same handle.

Fixed​

  • OrderBy ignored any direction it did not recognise. Anything that was not DESC or desc — "Desc", "descending", a typo — was treated as ascending, silently. ASC and DESC now match case-insensitively, "" still means ascending, and any other value is ErrInvalidQuery, the same contract the operator whitelist has always had.

  • rel:"m2m" was accepted by half the ORM. Eager loading understood the short alias; Migrate and the recursive save did not, so the join table was never created and the mismatch only surfaced when a query hit the missing table. The schema parser now normalises the alias to many_to_many once, so every subsystem reads the same relation.

  • Preload queries were invisible to observability. The batched SELECTs a preload issues went straight to the executor, reaching neither QueryObserver nor the slow-query log: the N+1 a preload is there to prevent was measurable, the preload replacing it was not. Those queries now emit a QueryEvent with operation PRELOAD through the same pipeline as the rest.

  • quark init outside a Go module scaffolded a project that could not run. The generated runner imported a placeholder module path, and the closing message told you to run go run ./cmd/<app> migrate up, which failed immediately. init now walks up the tree for the module and, when there is none, writes a coherent go.mod (--module chooses the path); the steps it prints at the end are the ones that work.

  • quark seed create generated a seeder that nothing ever ran. The generated file registered itself nowhere, so seed run had no seeders to find, and the function that would have registered it was not documented. The new seed package (symmetric to migrate) fixes the direction: generated files call seed.Register from an init(), so a blank import of the seeders/ package — which quark init already wires — is enough. commands.RegisterSeeder remains as a shim.

  • CLI identifiers reached introspection SQL unvalidated. The values of --table, --model and the positional argument were interpolated into statements such as PRAGMA table_info(%s) and DESCRIBE … as given. They now pass through SQLGuard in inspect table, inspect sql, validate and model --from-table, and a rejection wraps ErrInvalidIdentifier.

  • migrate create --from-models blamed the models for a build failure. It answered "no model structs" whether the package had no tagged structs, could not be loaded for lack of a go.mod, or simply did not compile. The three are now distinct, and a compile error is reported with the compiler's own lines.

  • A missing database configuration did not say what was missing. The error now names the configuration keys, the environment variables that set them, and quark init.

Changed​

  • The CLI stopped printing an INFO line on every command. Each invocation logged quark client initialized before doing its work. The CLI now runs its logger at warning level; --debug restores the previous output.

  • Repository documentation. The supported-versions table in SECURITY.md still listed v1.2.x and v1.1.x five minors after the fact; it now states the policy — the two most recent tagged minors — without pinning numbers that go stale, and a release check fails if a superseded version is marked supported again. docs/ENGLISH_DOCS.md, a standalone reference from the v0.x era that had long stopped tracking the API, is retired in favour of this site.

v1.7.1​

A patch that closes a cache isolation gap under native row-level security.

Under RowLevelSecurityNative the engine enforces tenant isolation with a session variable and a CREATE POLICY, so the query builder injects no WHERE predicate and left the tenant out of the L2 cache key. Two tenants issuing the same query therefore hashed to the same key over the one cache store the client shares, and a .Cache() result filled by one tenant was served verbatim to the next. The engine's policies protected the database; nothing protected the cache. The native path now carries the tenant in the cache key only — the query still relies on the policy for filtering, so WHERE injection, soft-delete scoping and create auto-fill stay inert. The regression was reproduced against a real PostgreSQL engine with policies and a non-superuser role before the fix.

v1.7.0​

A minor release about telling one database failure from another — and about two places where Quark could not, on engines its own documentation recommends.

Added​

  • quark.IsUniqueViolation and quark.IsDeadlock. A handler that writes to the database needs to know whether a rejected insert was a duplicate it can explain to the user, or something it should not have swallowed. Until now the only exported signal was ErrConstraintViolation, which lumps unique, foreign-key, not-null and check violations into one value — enough to know the write was refused, not enough to answer with a 409 and name the field. The alternative was importing your driver and matching its error type by hand, which stops working the moment the application runs on a second engine.

    Both predicates match on the error code the driver reports, never on message text, so they are unaffected by the server's language and by wording changes between driver releases. Both walk the wrapping chain. See Errors for when to reach for each.

Fixed​

  • PostgreSQL failures went unrecognised under lib/pq. Quark classifies driver errors to decide three things on its own: whether to retry a transaction the engine chose as a deadlock victim, whether a duplicate link row can be ignored, and whether a read should fail over off an unreachable replica. That classification matched only the error type of pgx, while the installation guide prescribes lib/pq and the dialect accepts its driver names.

    On lib/pq, none of the three recognised anything — so WithDeadlockRetry never retried, and a downed read replica was never detected as one. Neither reported an error: the options were accepted, appeared active and did nothing. All three now read the code through the method both drivers expose, so either driver behaves the same.

  • On SQL Server, a rejected insert reported the wrong error entirely. Create sends the INSERT and the identity lookup as a single batch, and when the server rejects the insert it still answers the lookup — with NULL. Reading that into a plain integer failed, and that conversion error was returned in place of the driver's, so a duplicate key arrived as a scan error naming no constraint, no table and no column. No rejected insert on SQL Server was diagnosable, by a person or by a program. The engine's own error now reaches the caller, and ErrConstraintViolation wraps it as it already did on the other engines.

v1.6.1​

A patch release about the preflight that shipped in v1.6.0 believing more about your database than it had actually checked.

Fixed​

  • The row-level security preflight now reads the policy, not just its name. VerifyRLSPolicies asked PostgreSQL whether a policy called <table>_tenant_isolation existed and stopped there. A policy carrying that name with USING (true) — right name, no predicate — earned a green light while every tenant read every row. That is worse than no check at all: a green check is what stops an operator from looking.

    It now reads the policy's USING and WITH CHECK expressions and demands the two things that make them isolate: a reference to your tenant column, and a read of the session variable the router sets. It also catches a policy narrowed to one command by a later ALTER POLICY, and one that leaves the write path open. The error says which of those is wrong, not merely that something is.

    Scope, so the guarantee is not overread: it verifies the policy this package installs. A deployment that isolates through several hand-written policies is reported as a deviation rather than guessed at.

  • The preflight runs with the client the documentation prescribes . It used RawQuery, which is off by default, so quark.New("pgx", dsn) — the exact client quarktenant's own docs show — could not run it, and a boot guardrail failed indistinguishably from a real outage. It now reads the catalog directly, the way the install path already did. The doubled quarktenant: quarktenant: prefix is gone too.

  • verify-rls-policies stops accepting flags it ignores. --tenant-col and --native-rls-var now genuinely change the verdict, because the predicate check needs them: verifying with the default column against an installation made with another one used to return OK without having checked what you believed. Flags that cannot change a read-only verdict (--dry-run, --cast, --lock-name, --lock-timeout) are refused for this action instead of being silently dropped.

v1.6.0​

A minor release about the sharpest edge in multi-tenancy, plus the test kit the ORM never shipped.

Added​

  • quarktenant.VerifyRLSPolicies — a preflight for native row-level security. Configure RowLevelSecurityNative and forget the policy DDL, and Quark emits no tenant predicate at all: every tenant reads every row, with no error anywhere. Nothing checked for it. The new call verifies, per registered model, that row security is enabled, that FORCE is set (unless you opted out — without it the table owner bypasses the policy, and the application role usually is the owner), and that the policy exists. Call it at startup and fail the boot, or gate a deploy with the verify-rls-policies action of your tenant runner, which exits 1 when a table is unenforced — distinct from 2 for operational errors.
  • quarktest — the test kit. SQLite(tb) opens a client on a temporary file (not :memory:, where every pooled connection would get its own empty database), Migrate(tb, client, models…) brings the schema up in one line and surfaces tag typos immediately, and Tx(tb, client, fn) runs a test inside a transaction that always rolls back. A new testing guide walks through it, including when a real engine is still required.

Fixed​

  • quark migrate up applied only the first pending migration. Without --steps, up and down shared one flag variable, so up inherited down's default of 1: three pending migrations in a pipeline, one applied, exit code 0. --dry-run previewed a single migration for the same reason.
  • The documentation site did not build. The version marker was written as an HTML comment in an MDX page, which aborts compilation. It now uses MDX comment syntax, and a lint rule rejects HTML comments in .mdx.

Changed​

  • The documentation archive resumes. The site keeps a snapshot per published minor so readers pinned to an older release get the matching docs; that archive had frozen at 1.2.2. It resumes with this release, and a check now fails when a minor ships without its snapshot.
  • Editorial pass over the published documentation — shorter sentences, the conclusion first, and no wording that assumes knowledge of the project's internal history.

v1.5.2​

A CLI correctness patch.

Fixed​

  • A bare quark migrate up applies every pending migration again. The --steps flags of migrate up (default 0 = all pending) and migrate down (default 1) were registered on the same package variable, and pflag writes each default into the bound variable at registration time — so after startup the shared variable held 1, and migrate up without --steps applied only the FIRST pending migration and exited 0. With three pending migrations in a CI pipeline, one was applied and the job went green. The flags now use separate variables, and a regression test drives the real command line with two pending migrations (every earlier test used exactly one, where the truncation is invisible). --dry-run was affected the same way and is covered by the same fix.

v1.5.1​

A durability patch under native row-level security.

Fixed​

  • A write under RowLevelSecurityNative is durable when the call returns. Create (and every single-row operation) runs INSERT … RETURNING inside an implicit transaction whose commit used to run in a background goroutine when the operation's context ended — with no ordering guarantee against the call returning. A handler could answer 2xx while the row was not yet committed, and an immediate reader on another connection missed it (surfaced as a rare flake by an external end-to-end suite; v1.3.1 fixed a first variant of the same class). The executor now materializes the returned row, commits synchronously, and only then returns: writes are durable on return, commit failures surface as the call's own error, and single-row reads release their pooled connection immediately. The deferred commit remains only on the multi-row read path, where it affects connection release, never durability — the DeferredCommitFailures counter now covers exactly that path. The native-RLS guide's write-semantics section is rewritten accordingly.

v1.5.0​

The DX minor: everything the 2026-08-16 DX audit demanded from quark. No API breaks; every change is additive or turns a silent failure into a loud one.

Added​

  • quark migrate create <name> --from-models <dir> --dialect <d> renders domain DDL from your model structs through the SAME type mapping the runtime migrator uses: dialect-correct column types, topological table order, foreign keys, indexes, and a Down that drops in reverse dependency order.
  • Rich field vocabulary in model generate: nullable<T>, array<T>, json<T>, and belongs_to<Model> (emits the FK column plus the rel:/join: pair).
  • Automatic timestamps: Create fills zero created_at/updated_at and Update refreshes updated_at — the 18 hand-written timestamp hooks the audit counted are no longer needed.
  • quark init writes the embedded runner (cmd/<app>/main.go with the blank imports and commands.Main()) instead of only prescribing it.
  • Example: blocks on 19 subcommands.

Fixed​

  • Struct-tag typos fail fast. quark:"notnull", db:"price,lenght=10", column:"..." and friends die in RegisterModel/Migrate with a hint (ErrInvalidTag) instead of silently dropping constraints; pk:"True" is accepted case-insensitively.
  • Models without a primary key get an actionable error naming the model and the fix, instead of sql: no rows in result set downstream.
  • quark.New rejects invalid options (a string, a number, an uncalled constructor) naming each one, and an unknown driver without WithDialect is now an error instead of a WARN plus a silent PostgreSQL fallback.
  • Static builds: the mattn/go-sqlite3 error classifier sits behind a cgo build tag, so CGO_ENABLED=0 and cross-compiles build again (new static-build CI lane).
  • The CLI builds its Limits from DefaultLimits(), killing the partial-literal SafeMigrations WARN on every invocation.

v1.4.1​

A patch release: the CLI keeps its promises. Three defects surfaced by an external coverage exercise over the public module surface, plus a round of CLI papercuts. Two deliberate behaviour changes, no API breaks.

Fixed​

  • model generate --fields output compiles and declares its primary key. A time.Time or json.RawMessage field now emits its import block, id gets pk:"true" (the tag the ORM parses), and the template renders the computed quark:"..." tag — only with vocabulary the ORM understands (not_null on the from-table path).
  • The embed recipe no longer swallows errors. New entry point commands.Main() (execute, print to stderr, exit non-zero) is what every recipe now prescribes; a runner built from the old printed recipe (func main() { commands.Execute() }) exited 0 in silence on any failure. commands.Execute() keeps its signature and behaviour.
  • tenant provision completes under schema_per_tenant. It creates the schema and the quark_tenants row and explicitly skips the migration step (which needs a TenantRouter in your own binary); an id already registered is rejected with a clear "already provisioned" error before any DDL runs, so retries never crash on duplicate CREATE SCHEMA.
  • Papercuts. migrate status/version work on a fresh database (zero applied, no missing-table error) and status lists pending migrations; seed run/seed list honour registration order; quark init fills project.module/project.name from the directory's go.mod; the With docs example uses the real Join(table).On(...) signature.

Changed​

  • quarktenant.InstallRLSPolicies is now re-runnable: the rendered DDL drops the deterministic policy (DROP POLICY IF EXISTS) before recreating it, in the same transaction — re-running converges instead of failing with SQLSTATE 42710. Gate the call yourself if you relied on that failure.
  • Go toolchain to go1.26.6 (closes GO-2026-6090, GO-2026-6088, GO-2026-5972 in the standard library).

Narrative notes: docs/RELEASE_NOTES_v1.4.1.md.

v1.4.0​

A feature minor for safer reads and clearer limits. Nothing is a breaking change; every new behaviour is opt-in or a strictly-more-honest default.

Added​

  • Strict reads. WithStrictReads(mode) flags unbounded Iter()/Cursor() calls — StrictReadsWarn logs them, StrictReadsReject returns ErrInvalidQuery — and, when tracking is enabled on a context, detects the N+1 access pattern and points at the missing Preload. AllowUnbounded() on the builder is the explicit escape hatch for exports and back-fills.
  • Blocked-cleanup visibility. Client.BlockedPanicCleanups() counts native-RLS panic-path cleanups that a stuck database/sql never let finish, so a leaked transaction/connection pair is observable instead of only inferable from pool exhaustion — the counterpart to DeferredCommitFailures().

Fixed​

  • WithLimits fills zero numeric fields from DefaultLimits(). A partial literal like Limits{MaxResults: 500} no longer leaves QueryTimeout at zero (which made every query fail with an already-expired context). Booleans are not normalized — a partial literal that leaves SafeMigrations false now emits one structured WARN pointing at DefaultLimits().
  • Security: golang.org/x/text bumped to v0.39.0 (GO-2026-5970, reachable through database/sql).

Upgrade notes​

Drop-in. If you built a Limits literal by hand and relied on the old all-zero behaviour, start from DefaultLimits() and override — the WARN will tell you if you didn't.

v1.3.3​

A correctness patch for the native row-level-security strategy. Upgrade if you use RowLevelSecurityNative — especially if you call Create or Update from long-lived contexts (batch jobs, CLIs, workers).

Fixed​

  • Create and Update no longer hold their implicit transaction until your context ends. Both now scope the transaction to the operation, like every other native-RLS entry point already did. Previously, each write from a long-lived context kept one pooled connection idle in transaction (with its locks) until that context finished — enough writes could exhaust the pool, and any later DDL on the touched table (a migration, a DROP TABLE in tests) blocked behind those locks. As part of the same fix, a row you just wrote is visible to reads on the same context (read-your-writes), which previously only held across contexts.
  • The executor no longer leaks its pooled connection if the driver panics. A panic inside a query or transaction begin used to abandon the connection; the pool now gets it back on every path, including panics the standard library survives.
  • Driver and transaction errors are no longer masked as ErrNoRows. On the QueryRow path, a failure to begin the transaction or set the tenant context used to surface as a generic "no rows" result; the real error now reaches your Scan call intact, wrapped with the stage that failed, so errors.Is/errors.As work.

Upgrade notes​

Nothing to do. If you had sized pools defensively or added per-operation contexts to work around hangs during migrations, those workarounds are no longer needed — see the troubleshooting entry in the native row-level security guide if a DDL statement still hangs on releases before this one.

v1.3.2​

A robustness patch for the native row-level-security strategy. Upgrade if you use RowLevelSecurityNative, especially with connection pools sized close to your concurrency.

Fixed​

  • Connection acquisition under native RLS honours your context. Waiting for a pooled connection used to be uncancellable: with a saturated pool, a request could keep waiting past its own deadline. Acquisition now aborts with the caller's ctx.Err(); only the transaction's lifecycle stays detached from cancellation (the v1.3.1 write-durability behaviour is unchanged, and its semantics are now spelled out in the native row-level security guide's Limitations section).
  • A failed deferred commit is loud. If the deferred commit behind a native-RLS write fails, the failure is logged at error level even without a configured logger, and counted — Client.DeferredCommitFailures() exposes the count so operators can alert on it. Batch inserts are covered by the same regression tests as row-by-row writes.

Upgrade notes​

Nothing to do. Behaviour changes only affect failure paths that previously hung or went silent.

v1.3.1​

A correctness patch. Upgrade if you chain different set operators in one query, or if you use the native row-level-security strategy with writes.

Fixed​

  • Mixed set-operator chains are rejected honestly. A.Union(B).Intersect(C) used to emit flat SQL whose meaning depends on the engine (SQLite and Oracle evaluate left-to-right; PostgreSQL, SQL Server, MySQL and MariaDB give INTERSECT precedence) — the same query silently returned different rows on different engines. Chains that mix operators now return ErrUnsupportedFeature; materialize intermediate results in steps instead. Chaining the same operator, UNION with UNION ALL included, keeps working.
  • Native row-level security no longer loses writes. Under the RowLevelSecurityNative strategy, an INSERT … RETURNING ran inside an implicit transaction whose lifecycle was tied to the caller's context — and cancelling that context (which is how every request context ends) made the database roll the insert back instead of committing it. The transaction's lifecycle is now detached from the request context, so the deferred commit is deterministic. This changes cancellation semantics on the query paths: each statement still honours the context while it runs, but once a statement has executed, cancelling the request no longer reverts the write — the implicit transaction commits when the context ends, after control has returned to the caller (the exec path is unchanged: it commits synchronously before returning). See PostgreSQL Native RLS → Limitations for the full write-semantics contract.

Upgrade notes​

If you relied on mixed set-operator chains, split them into explicit steps — the previous behaviour differed per engine, so results were only ever trustworthy on one of them.

v1.3.0​

A drop-in minor. It rounds out the set-operator surface and makes its per-engine behaviour honest.

New​

  • INTERSECT ALL and EXCEPT ALL. IntersectAll and ExceptAll are the multiset variants of the existing Intersect / Except: they keep duplicate rows instead of collapsing them. Available on PostgreSQL and MariaDB.

Fixed​

  • Set operators now report unsupported engines cleanly. SQL Server and SQLite have INTERSECT / EXCEPT but no ALL variants, and Oracle only gained INTERSECT ALL / MINUS ALL in 21c — a version Quark does not assume without a runtime probe; asking for an ALL variant on any of them now returns ErrUnsupportedFeature instead of emitting SQL the engine rejects with a confusing parser error. The documentation for Intersect / Except also no longer claims MariaDB is unsupported — it has been supported since 10.3.

Upgrade notes​

Nothing to do. Everything above is additive; existing code behaves exactly as before.

v1.2.2​

A correctness and security patch. Upgrade if you provision tenants from the CLI, paginate compound selects, or batch upserts.

Security​

  • quark tenant provision built SQL by string concatenation. The tenant id and strategy were interpolated straight into CREATE DATABASE / CREATE SCHEMA / INSERT. The id is now validated against the TenantRouter contract before any SQL runs, DDL identifiers are dialect-quoted, and the registry INSERT is parameterised. If you provision tenants from names you do not fully control, upgrade.

Fixed​

  • Count() and Paginate were wrong on compound selects. They counted only the first operand of a UNION/INTERSECT/EXCEPT — a query whose List() returned 4 rows could report Count() == 2. The count is now wrapped as SELECT COUNT(*) FROM (<compound>).
  • Upsert/UpsertBatch with no conflict columns now return ErrInvalidQuery on every engine, instead of panicking on MySQL/MariaDB and behaving differently elsewhere.
  • Offset without Limit now renders the correct per-engine form, instead of being silently dropped (MySQL) or producing invalid SQL (SQLite, MariaDB).
  • SQL Server reads generated keys back from an upsert via MERGE … OUTPUT INSERTED.<pk> on both branches. Oracle's MERGE has no RETURNING, so the primary key stays zero there — a documented limitation, not a bug.
  • CLI commands now report reality. migrate up/down and seed run exit non-zero on an empty registry and print how to embed your models; validate loads your Go structs and compares columns in both directions; tenant migrate resolves the tenant's own DSN from tenant.dsn_template instead of migrating the default database; inspect table and model generate exit non-zero when the table does not exist; init --dialect bogus fails before writing any files. Flags that never did anything (--skip-seed, --tenant-id, --env) were removed.

New​

  • UpsertBatch chunks like CreateBatch, and both now size their chunks against per-dialect bind-parameter ceilings (~65k on PostgreSQL/MySQL/MariaDB, ~32k on SQLite, ~2100 on SQL Server) instead of a single universal limit of 2000.
  • INTERSECT and EXCEPT on MariaDB (10.3+). MySQL still returns ErrUnsupportedFeature: it gained them in 8.0.31, and Quark cannot assume that version without probing.

Upgrade notes​

No breaking changes. The --skip-seed, --tenant-id and --env CLI flags were removed; they were accepted and ignored, so any script passing them was already not getting the behaviour it asked for, but a script that passes them will now fail rather than silently do nothing.

v1.2.1​

A CLI, docs and raw-query-guard patch. No query-builder or CRUD changes.

Fixed​

  • The CLI could not connect to PostgreSQL at all. quark init wrote driver: postgresql, but the registered driver is pgx, so every database command failed with unknown driver; the Oracle driver was not even linked into the binary. The CLI now maps dialect names to registered drivers and links go-ora.
  • Failures exit non-zero. migrate, inspect, tenant, seed and init printed their error and exited 0 — which meant a failing migration passed a CI step. If you were relying on the exit code, this release is the one that makes it trustworthy.
  • sync no longer pretends. Its --dry-run, --safe, --no-transaction and --models flags did nothing and are gone. The command now checks the connection and prints how to wire client.Sync(...), which is what actually performs a schema sync (it needs your compiled model types).
  • The raw-query guard stopped rejecting valid SQL: -- inside a single-quoted string literal ('range--max') is allowed again, while -- outside one is still rejected.

New​

  • inspect --format json|yaml, and quark version / --version.

Security​

  • Dialect Quote() now doubles embedded closing quotes — defence in depth underneath the existing identifier allowlist.

Upgrade notes​

No breaking changes. Expect previously-green CI steps to start failing honestly if they were running migrations that did not actually apply.

v1.2.0​

Scaling features that were deferred at v1.1.

New​

  • Cross-instance cache-stampede protection. Opt in with WithCacheCrossInstance(), backed by the optional CacheLocker capability (implemented by the memory and redis stores). One instance wins the lock and recomputes; the others wait and re-read, so a hot key expiring does not send every process in your fleet to the database at once. If the lock backend fails, it degrades to the previous in-process behaviour rather than erroring.
  • Shard key from the entity. Entities that implement ShardKeyer can route writes with WithShardKeyOf(ctx, entity) instead of restating the key at the call site.
  • Cross-shard reads. ScatterGather and ScatterCount run a query on every shard concurrently and merge the results, with an explicit ScatterMerge you supply. COUNT is the only aggregate merged for you.

Fixed​

  • The Update zero-value warning no longer fires on nil pointers.
  • Faster reads: the scan plan is memoised and result slices are pre-sized.

Security​

  • Toolchain pinned to go1.26.5 and pgx to v5.9.2, picking up the upstream security fixes released since the v1.1.5 pins.

Upgrade notes​

No breaking changes. Everything above is opt-in.

v1.1.5​

Fixed​

  • SQLGuard errors are now matchable. A rejected Where operator, a raw query missing placeholders, or a raw query matching a suspicious pattern now wrap quark.ErrInvalidQuery, so errors.Is(err, quark.ErrInvalidQuery) catches them. Previously they returned a message the sentinel did not wrap, so error handling written against the sentinel silently missed them. No SQL or behaviour change beyond the error value.

This release also lands a documentation-versus-code audit: the published docs for query-builder joins, the Dialect interface, SQLGuard's lexical validation, the CLI subcommands, PostgreSQL's CreateListener, batch hooks and the composite-primary-key cache tag were corrected to match the shipped API.

Upgrade notes​

No breaking changes.

v1.1.4​

Five cross-engine correctness fixes.

Fixed​

  • CreateBatch back-fills generated primary keys on MySQL and SQL Server. Those engines cannot read keys back from a multi-row insert, so when the primary key is auto-generated Quark now inserts per row and recovers each key via LastInsertId/SCOPE_IDENTITY. entity.ID is populated on every engine instead of being silently left at 0.
  • Recursive CTEs work on Oracle and SQL Server. WithRecursive no longer emits the RECURSIVE keyword there (both engines infer recursion structurally and reject the keyword), which previously failed with ORA-02000 or a T-SQL syntax error.
  • Paginated set operations work on Oracle and SQL Server. A Union/Intersect/Except with Limit/Offset and no explicit OrderBy now emits a positional ORDER BY 1 instead of ordering by the primary key, which those engines rejected under a compound select.
  • Batch and upsert lifecycle hooks fire. CreateBatch/UpdateBatch run BeforeCreate/BeforeUpdate per entity, and Upsert/UpsertBatch run BeforeCreate, so timestamp, default and derived-field hooks apply to batched and upserted rows. After* hooks remain single-write only.

Upgrade notes​

No breaking changes. If you relied on Before* hooks not running on batch paths, note that they now do.

v1.1.3​

Fixed​

  • CreateBatch back-fills the generated primary key on Oracle, via RETURNING … INTO, instead of leaving entity.ID at 0.
  • MariaDB JSON columns no longer drift in the schema diff. The introspector recognises a longtext carrying MariaDB's auto-added json_valid(col) check as json, so the diff stops proposing a cosmetic longtext → JSON alter.
  • Cache invalidation after a batch insert. CreateBatch now invalidates the table tag on PostgreSQL, SQLite and MariaDB, so a cached table-level read no longer serves stale rows after a batch insert.

Upgrade notes​

No breaking changes.

v1.1.2​

Fixed​

  • ApplyPlan creates a real primary key. Creating a table from a plan now renders the primary key — auto-increment for a single integer key, a table-level constraint for composite keys — so a plan-created table is interchangeable with one created by Migrate. Column gains a PrimaryKey field, populated by introspection on all six engines and compared by the diff. A primary-key change is reported but refused by the executor: applying it needs a table rebuild.
  • PlanMigration returns an empty plan on a freshly migrated database, as documented. Many-to-many join tables are now part of the desired schema, so the diff no longer proposes a destructive DROP of a table Quark itself created; and catalog-decorated defaults ('x'::text, ((1)), unquoted MySQL strings, bool-literal case) and type aliases (timestamp without time zone, tinyint(1), Oracle TIMESTAMP(6)) no longer produce cosmetic alter operations.

Upgrade notes​

No breaking changes.

v1.1.1​

Fixed​

  • Cache invalidation after an insert. Create now invalidates the model's table tag on every insert path — PostgreSQL/SQLite/MariaDB (RETURNING) and SQL Server (OUTPUT/SCOPE_IDENTITY) — so a cached table-level read no longer serves stale results after an insert.
  • Boolean column defaults migrate everywhere. default:"1" on a bool field now migrates on all six engines (PostgreSQL gets TRUE/FALSE).
  • ErrInvalidIdentifier is reachable via errors.Is on every validation path.
  • quark model generate --fields creates its --out directory and reports failures with a non-zero exit code.

Upgrade notes​

No breaking changes.

v1.1.0​

A hardening release: a systematic cross-engine pass over the whole public surface, plus the correctness fixes it surfaced — versioned migrations on SQL Server, a MariaDB schema-diff false positive, CreateBatch bind-parameter chunking, dialect-aware savepoints, multi-tenant write routing, and three eager-loading fixes. No new public API.

Upgrade notes​

No breaking changes.

v1.0.0​

The first stable release, and the commitment to keep the v0.13 public surface compatible for the whole v1.x line. Oracle joined the blocking CI matrix, which means all six dialects — PostgreSQL, MySQL, MariaDB, SQLite, SQL Server and Oracle — are now covered by tests that must pass before anything ships.

The benchmark suite gained a code-generation tier (ent and sqlc), which confirmed that Quark's own code generation is a type-safety feature and not a speedup. We had originally aimed for a multiple-times speedup from codegen; the measurements did not support it, so we stopped claiming it. See Benchmarks.

Upgrade notes​

No new public API beyond v0.13, and no breaking changes since v0.9.0.

v0.13.0​

New​

  • Read replicas. WithReplicas(dsns...) opens read-only pools and spreads reads across them while writes stay on the primary. Sticky(ctx) pins a read to the primary when you need read-your-writes. A read that hits a transient connection error on a replica fails over to the primary, and that replica drops out of rotation for a cooldown (WithReplicaDownCooldown, default 5s). Reads inside Client.Tx and under RowLevelSecurityNative always use the primary.
  • A runnable stress and load harness (latency percentiles, throughput, pool contention) under benchmarks/stress.

Fixed​

  • Lower per-query allocation: the copy-on-write query-builder clone now shares slices and appends within bounded capacity.

Upgrade notes​

No breaking changes. Replicas are opt-in.

v0.12.0​

New​

  • Compile-time column type-safety, on top of the code generator. quark gen emits a <Model>Columns value of typed column handles per model, and the query builder gains Query.WhereP — WHERE conditions with no magic column strings, where both the column and the bound value are checked at compile time. It is pure compile-time sugar: the string Where(...) API stays valid and the two are interchangeable.

Fixed​

  • The audit row diff is only built when an audit sink is configured (~9% fewer allocations on insert).

Upgrade notes​

No breaking changes.

v0.11.0​

New​

  • Opt-in code generation. quark gen parses your model package and emits a quark_gen.go per package, registering typed scanners on the read path and a typed INSERT binder for single-integer-primary-key models. Reflection remains the permanent default; codegen is fully opt-in and falls back to reflection for anything it does not cover.
  • A reproducible benchmark module (Quark vs hand-written database/sql vs GORM).

Profiling against that baseline measured what codegen actually buys: ~2–5% on scan and ~1% on insert. Quark's per-operation CPU is dominated by database/sql and the database engine, not by reflection. Generate for correctness and forward compatibility, not for speed.

Upgrade notes​

No breaking changes.

v0.10.0​

Fixed​

  • JSON[T] and Array[T] round-trip on SQL Server. A []byte→VARBINARY→NVARCHAR conversion was corrupting them.
  • Rolling back to a savepoint discards the hooks queued in that scope. After*, OnCommit and OnRollback callbacks registered inside a rolled-back savepoint no longer fire their side-effects on the outer commit.
  • Raw SQL under RowLevelSecurityNative emits a quark.tenant.raw_under_native_rls warning.

Upgrade notes​

No breaking changes. WithDeadlockRetry is now backed by a real cross-engine deadlock-retry integration test (PostgreSQL, MySQL, MariaDB).

v0.9.0​

New​

  • PostgreSQL engine-enforced multi-tenancy (RowLevelSecurityNative plus the quarktenant policy CLI).
  • Transactional After* hooks that fire post-commit, plus BeforeFind and AfterFind.
  • Tx.OnCommit / Tx.OnRollback and quark.TxFromContext.
  • An EventBus (Client.UseEventBus) and an optional audit log (Client.EnableAuditLog) written atomically with each write.

Upgrade notes​

Two breaking changes. Follow the v0.9.0 migration guide.

v0.4.0 – v0.8.0​

Composable query builder (typed AST, subqueries, CTEs, window functions, set operators, pessimistic locking); schema-as-code migrations (introspection, pure-Go diff, transactional and resumable apply, the quarkmigrate CLI, backfill); observability and L2 cache (OpenTelemetry metrics, span redaction, slow-query log, stampede protection, deadlock retry); per-column timezones and Array[T].

v0.3.0​

The first proper tag since v0.1.1. Bundles the early security and correctness fixes with rich types, dirty tracking, optimistic locking and soft-delete scopes.

v0.1.0​

Public release baseline.

Core​

  • Generic Query[T] entry point through quark.For[T](ctx, provider).
  • Immutable builder methods for predictable query composition.
  • CRUD helpers for create, update, delete, hard delete, upsert, and map updates.
  • Read helpers for Find, First, List, Count, aggregates, pagination, streaming, and cursors.
  • Validation through validate tags and model-level Validate(context.Context) error.
  • Lifecycle hooks for create, update, and delete paths.

Dialects​

  • SQLite, PostgreSQL, MySQL, MariaDB, SQL Server, and Oracle dialects.
  • Dialect-specific placeholders, identifier quoting, pagination, upsert SQL, generated-ID handling, JSON extraction, and DDL helpers.
  • Custom dialect registration through RegisterDialect.

Schema​

  • Migrate for table and many-to-many join-table creation.
  • Sync for additive changes, renames, and controlled destructive drops.
  • CreateIndex and AddForeignKey helpers.
  • Versioned Go migrations through github.com/jcsvwinston/quark/migrate.

Relations​

  • Preload for has_one, has_many, belongs_to, many-to-many, and polymorphic relations.
  • Recursive association persistence for common aggregate saves.
  • Tenant context propagation through association loads and saves where tenant columns are present.

Production Features​

  • Transactions, manual transactions, savepoints, and nested savepoint-style callbacks.
  • TenantRouter with database-per-tenant, schema-per-tenant, and row-level strategies.
  • Cache abstraction with memory and Redis stores.
  • Query observers, middleware, and OpenTelemetry middleware.
  • SQLGuard validation for identifiers, operators, and raw-query escape hatches.

Compatibility Notes​

  • The cmd/quark CLI ships and is installable via go install — it provides migration, tenant, and quark gen codegen subcommands (Operational Workflows).
  • DeleteBy and DeleteBatch perform hard deletes.
  • Select accepts simple identifiers rather than arbitrary SQL expressions.
  • Raw SQL APIs require AllowRawQueries: true.
  • Migration commands that use migrate.Migrator should use a client configured with AllowRawQueries: true.