Changelog
Scythe follows Keep a Changelog and Semantic Versioning.
For the latest changes, see the CHANGELOG.md in the repository root.
[0.14.0] - 2026-08-09
Section titled “[0.14.0] - 2026-08-09”This release checks scythe’s output against something other than scythe. Nullability inference is
measured against what live database engines actually return across all six of them. Generated files
now record the schema they came from, and scythe check can diff your DDL against the database the
code will actually run against. A Snowflake backend that had never executed anywhere runs in CI for
the first time.
Three of those checks found real bugs the model-only tests could not: Oracle returns NULL for an
empty string literal, typescript-duckdb had been reading rows positionally while indexing them by
name, and typescript-oracledb cast nullable columns to their non-null type. All three are fixed
below.
Upgrading: scythe check now exits 2 for findings and 1 for operational failures, so a CI
script keying on 1 needs updating. Unrecognized options on any [[sql.gen]] target — not just
TypeScript — are now an error rather than silently ignored. Identifiers derived from names with
consecutive capitals change spelling (CreateAPIKeyRow becomes CreateApiKeyRow). And every
generated file gains a provenance header, so the first regeneration after upgrading touches every
artifact. See Changed for the full list.
-
A per-target manifest override:
manifest = "..."on a[[sql.gen]]target names a partial manifest merged over the backend’s compiled-in one, so a project can retarget a few type mappings, naming fields or import rules without vendoring a whole manifest. The path resolves against the directory containingscythe.toml— the same rule every other path in the config follows since 0.13.0 — so generated output does not depend on where the command was run. The override is keyed per target rather than per backend name:rust-sqlxcovers five engines andjava-jdbcnine, each with its own type mappings, and a[[sql.gen]]target inherits its engine from the enclosing[[sql]]block, so two targets naming the same backend under different engines each get their own file.[types.scalars]and[types.containers]may only replace mappings the backend already defines — neutral type names are a fixed vocabulary, so a key outside it is a typo, and the error suggests the near miss;[imports.rules]does accept new keys, because retargeting a scalar requires an import rule keyed on the new type’s prefix. There is no[backend]section: manifest selection stays a pure function of(backend, engine). Every failure — unknown section, unknown key, missing file — failsscythe generatenaming the backend, the resolved absolute path and the offending key, and nothing falls back to the compiled-in manifest silently (#82) -
A live nullability conformance suite (
scythe-conformance), a dev-only workspace member that compares inferred nullability against what engines actually return rather than against scythe’s own model. Per (fixture, engine, column) it holds three facts side by side: the analyzer’s verdict, whether the generated code actually renders the column as optional (parsed out of the resolved type against the backend manifest, deliberately not copied from the analyzer, so the two can genuinely disagree), and the engine’s observed per-row nullness from a real query run. Four assertions relate them: fidelity (analyzer and generated code agree), soundness (an observed NULL implies the generated code renders the column optional), anti-vacuity (a column called nullable must be demonstrated NULL by some run, or the suite is satisfied by marking everything nullable), and join-group coherence (columns widened by the same outer join go NULL together). Accepted over-pessimism — the analyzer is stricter than an engine turns out to be — goes in a capped registry with a tracking issue per entry, and an entry that stops reproducing fails the build, so fixing the gap forces deleting the entry that excused it. A soundness failure is never suppressible by any registry entry. The crate is unpublished, has no CLI surface, and nothing inscythe generatetouches it -
Live drivers in that suite for all six engines — PostgreSQL, MySQL, MariaDB, SQLite, SQL Server and Oracle — each behind its own Cargo feature plus a
live-testsgate, with one CI job per engine. No driver is linked by default, socargo test --workspaceexercises the pure comparison logic without a container. Selecting an engine whose feature was not compiled in is a hard error naming the feature to enable, never a silent skip. Each engine’s isolation is its own problem: SQL Server gets a fresh database per connection, because T-SQL’s default schema belongs to the database principal rather than the session andUSEdoes not survive tiberius routing statements throughsp_executesql, so tables would otherwise land inmaster; Oracle gets a user per connection, because in Oracle a schema is a user. Fixtures are now analyzed under their own engine’s dialect rather than PostgreSQL’s, which is what made the Oracle empty-string bug below observable (#71) -
A provenance header in every generated file, recording the scythe version, backend, engine, a fingerprint of the schema and a fingerprint of the query set the file was generated from:
// scythe:provenance v=0.14.0 backend=go-pgx engine=postgresql schema=sch1:2e813606acee8b51 queries=q1:9c4e1f77a0b3d582The comment token follows the target language (
#for Python, Ruby and Elixir), and where a language requires particular first bytes the header goes second — after<?php, after Ruby’s# frozen_string_literal: true. Python’s variant carries a trailing# noqa: E501, since the line exceeds ruff’s default 88-column limit and would otherwise failruff checkon line 1 of every generated Python file. Ruby’squeries.rbssignature file gets one too.The schema fingerprint is a SHA-256 over a canonical rendering of the resolved catalog — tables, columns, enums, composites, domains, dialect — rather than over DDL text, so reformatting a migration or reordering statements does not move it while a real schema change does. It excludes column defaults (free-form AST text that churns on dependency bumps) and scythe’s own version, which would otherwise report every artifact as drifted on every release.
The query fingerprint covers the analyzed query set — each query’s name, command, parameter names and types, and resolved column names, types and nullability — rather than the SQL text, so reformatting a
.sqlfile or editing a comment does not report drift while a change that moves a generated signature does. Parameter names participate because every backend emits them as the generated function’s argument names: swappingWHERE name = $1forWHERE email = $1leaves both parameters typedstringbut rewrites the signature fromname: &strtoemail: &str, breaking every caller. Schema drift and query drift are reported as distinct findings,SC-PRV01andSC-PRV08.Generated code is only as correct as the schema snapshot scythe read, and nothing in the artifact recorded which snapshot that was. Code generated against a drifted local schema compiles, reviews as an ordinary diff, and meets a different migration state in production. Raised by Mads Hansen.
scythe checkverifies it through seven rules:SC-PRV01schema drift,SC-PRV03backend drift andSC-PRV04engine drift as errors;SC-PRV02scythe-version drift,SC-PRV05missing header,SC-PRV06malformed header andSC-PRV07unverifiable header as warnings. They are ordinary registry rules, so[lint.rules]and[lint.categories]can downgrade or disable any of them. A scythe upgrade alone is a warning by design — upgrading the tool must never fail a consumer’s CI before they have had a chance to regenerate. A missing artifact produces no finding at all, since the default.gitignoreexcludes**/generated/.It answers “was this generated from this schema?”, not “from these queries?” — editing a query file without touching the schema produces no mismatch (#68)
-
scythe check --database-urlnow also diffs your DDL against the live database. PostgreSQL only; blocks on other engines are skipped with a warning naming the block. SevenSC-DRFrules cover tables and columns missing from either side, type mismatches, nullability mismatches and enum value mismatches — all errors exceptSC-DRF02, a table present in the database but not in your DDL, which is a warning because every real database has aschema_migrationstable scythe knows nothing about.SC-DRF06is the rule that justifies the feature. Query verification cannot check nullability: preparing a statement makes PostgreSQL report type OIDs and nothing about NULL-ness. Readingpg_attribute.attnotnullis the only way scythe can tell you aNOT NULLin your DDL is not true in production, where the generated non-optional field fails to decode the first NULL row it meets.The catalog is read from
pg_catalog, notinformation_schema, which reportsUSER-DEFINEDfor every enum column and never names the type — the enum and type-mismatch rules would be undetectable through it. Type comparison is exact equality rather than the tolerant predicate query verification uses, which forgives string widening and so reported nothing when atextcolumn becameuuid. Types neither side can express in scythe’s neutral vocabulary are skipped rather than reported as mismatches. Views and materialized views are excluded from the nullability check, since PostgreSQL storesattnotnull = falsefor every view column.Opt-in via the flag: with no
--database-urlnothing connects, and unlikescythe inspect,checknever falls back to$DATABASE_URL— it cannot start requiring a database because that variable happens to be set, which is what keeps it usable in a pre-commit hook.This is the cheaper intermediate the issue proposes, not its headline
schema_source = "execute"design: scythe still builds its catalog by parsing DDL and does not execute your migrations against an ephemeral database, so DDL that only a real engine can resolve is still out of reach. Raised by u/Character-Forever-91 (#79) -
Nested struct types inferred from
json_agg(alias.*)androw_to_json(alias.*). A column that aggregates a whole relation now resolves to a generated struct with that relation’s fields instead of an opaque JSON scalar. PostgreSQL and CockroachDB only, and only onrust-sqlx,rust-tokio-postgres,go-pgxandpython-psycopg3— every other backend degrades to exactly the plainjsonmapping it produced before, including on Redshift, wherejson_aggdoes not exist.Element nullability is modelled on the element, not the fields.
json_aggover a LEFT JOIN emits a JSONnullelement, never an object of null fields, so an inner join yieldsVec<GetUserOrdersRowOrders>and an outer joinVec<Option<GetUserOrdersOuterRowOrders>>. Widening the fields instead would model a value PostgreSQL never produces while leaving the type unable to hold the one it does.JSON keys are the raw SQL column names, so a quoted
"createdAt"column gets#[serde(rename)]in Rust, ajson:"createdAt"struct tag in Go, and an explicit_from_jsonclassmethod in Python —Cls(**item)would passcreatedAtas an unexpected keyword argument. Enums reachable only through a nested struct are emitted with per-variant renames, since the driver’s ownrename_alltells serde nothing.jsonb_aggis deliberately not covered, nor isjson_aggover a scalar or a bare*. Two queries in one file deriving the same struct name deduplicate if their shapes match and are a hard error naming both if they do not. Your own@jsonannotations are untouched (#78) -
A
field_caseoption on a[[sql.gen]]target, acceptingsnake_case(the default) orcamelCase, honored by the 11 TypeScript backends and byjava-jdbc,java-r2dbc,kotlin-jdbc,kotlin-r2dbcandkotlin-exposed. It renames generated field and parameter names only; every backend still reads the driver row by the raw SQL column name, so the rename cannot break decoding.On TypeScript the naive version of this would ship a type that lies: 10 of the 11 backends return the driver’s row through a blind
rows[0] as StructNamecast, so renaming the declared field alone type-checks green and returnsundefinedfor every field at runtime —tsccertifies the bug. UndercamelCasethe function body therefore reconstructs the row field by field, reading raw keys and writing renamed ones (#87) -
Four JSDoc
javascript-*backends:javascript-pg,javascript-postgres,javascript-mysql2andjavascript-better-sqlite3. They emit plain ESM.jscarrying its types entirely in JSDoc comments —@typedef/@propertyfor row types,@param/@returnsfor functions — with no driver import statement, referencing driver types inline asimport("pg").PoolClient. Nullability is alwaysT | null, never the optional-property form. These are an emit mode on the existing TypeScript backends rather than new manifests, so the manifest count is unchanged.row_type = "zod",outer_join_unionsandfield_case = "camelCase"are rejected with an error naming the TypeScript backend to use instead: each needs syntax a plain.jsfile cannot carry. Output is validated in CI with realnode --checkandtsc --checkJs --strict(#81) -
--exit-zeroonscythe check, matching the flagauditandinspectalready had -
A
queries=fingerprint in the provenance header, covering the analyzed query set alongside the schema. The schema fingerprint said nothing about the.sqlfiles, so editing a query and forgetting to regenerate left an artifact thatscythe checkcalled clean. Computed over each query’s name, command, parameter names and types, and resolved column names, types and nullability — not the SQL text — so reformatting a query or editing a comment stays silent while a change that moves a generated signature does not. Parameter names participate because every backend emits them as the generated function’s argument name: swappingWHERE name = $1forWHERE email = $1leaves both typedstringbut rewrites the signature fromname: &strtoemail: &str, breaking every caller. Reported asSC-PRV08(query-drift, Error), distinct fromSC-PRV01, so a drift report names which of the two moved. An artifact whose header predates this field is not reported as malformed (#94) -
ErrorCode::InvalidConfig, so a mistake inscythe.tomlno longer surfaces underINTERNAL_ERRORand read as a scythe bug rather than something the user can fix.ErrorCodeis now#[non_exhaustive], so adding a future variant is no longer a breaking change (#102) -
Live nullability coverage for seven more rules:
MAX/AVGover an empty set,NULLIFwith equal operands,CASEwith noELSE, a scalar subquery matching no row,RIGHT JOINandFULL JOIN. Each is asserted against real engines rather than against the analyzer’s own model.FULL JOINjoins users to tags rather than users to orders, because the foreign key onorders.user_idmeans no order can ever be unmatched and the join would degenerate to aLEFT JOIN— proving half the rule while appearing to prove all of it. It is scoped away from MySQL and MariaDB, which parseFULLas a table alias and reject the query (#71)
Changed
Section titled “Changed”-
The README is a front page again, and its code is checked. It was 412 lines, 227 of them ten hand-written samples of “generated” code that no tool validated — and five of the ten were wrong, including a Go block still showing pre-0.14.0 field casing and typing
NUMERICas*string. It now carries one Rust sample copied verbatim from committed generated output, andREADME.mdis insnippet-runner’s reference set so CI compiles it. The feature list and the language/database matrix moved to the docs pages that own them. -
The documentation was audited against the source and corrected. Seven claims contradicted the implementation: the unknown-option rule was described as TypeScript-only;
[lint.sqruff] enabledwas documented as a working toggle when nothing reads it;[lint.sqruff.rules]was documented as the opposite of the allowlist it actually builds;--dialectonlint/fmtwas described as unset; the crate table omittedauditandinspect;scythe fmtwas described as running sqruff’s full default rule set, whenLT01is excluded there too; and[sql.gen.python|typescript|go|kotlin]were supported but undocumented. Per-language backend pages replace the combined Java/Kotlin page and the “Other” grab-bag, with the old URLs kept as stubs. -
llms.txtgeneration no longer destroys the code samples.minify.collapseCodeBlockswas enabled, which collapses whitespace inside code fences as well as prose — so the abridged file rendered the whole site as 258 lines with every sample unparseable. It is off; the changelog and Starlight’s anchor markup (22% of the corpus between them) are excluded; andllms.txtnow carries the facts a model gets wrong by default, above all that the annotation syntax is not sqlc’s. Backends, databases and guide are addressable as separate sets. -
Generated-code tool validation reports a skipped checker as a skip, not a pass.
validate_with_toolsreturnedOption<Vec<String>>, whereNonemeant “tool not installed” and every call site spelledif let Some(errors)— so a checker that was never installed was indistinguishable from one that ran and found nothing. In practice 31 of 76 backend tests were passing without any tool touching the generated code: 13 becausebiomewas installed nowhere, and 18 because Java, C#, Elixir and Rust have no validator at all.validate_python_toolswas the worst case, returningSome([])whenruffwas absent. The return type is nowToolValidation, reporting each checker separately asRan/Missing/Failed, and CI runs withSCYTHE_VALIDATE_STRICT=1, where a missing tool fails the build instead of being skipped (#98) -
Generated code is checked by
poly, this repository’s linter, rather than by a per-language collection of separately-installed binaries. poly bundles its engines in-process –oxcfor TypeScript,rufffor Python,magofor PHP – so one already-required tool replacesbiome, standaloneruff, thepython3 -m astsyntax pass andphp -l, and CI drops four install steps along with the Python, PHP and JDK toolchains it only needed in order to feed them.nodeandtsc --checkJs --strictremain for thejavascript-*JSDoc backends, since oxc lints JavaScript but does not typecheck JSDoc annotations;gofmtandruby -cremain because poly delegates those languages rather than bundling them. Kotlin loses its tool validation: poly delegates toktlint, and standing up a JVM plus a downloaded jar to lint generated Kotlin is out of proportion to what it catches –validate_structuralstill covers those backends, and an inventory test keeps the gap visible. Validation runs against a dedicatedgenerated-code-poly.tomlpassed explicitly, because poly resolves config by walking up from the file it is handed and a temporary file finds nothing, so what CI enforced would otherwise depend on where the system temp directory sits -
Unknown
[[sql.gen]]keys are rejected on every backend, not just TypeScript. 24 of the 52 backends had noapply_optionsat all and inherited a permissive default that accepted anything (#103) -
scythe checkexits2for findings and1for operational failures. It previously exited1for both, so a CI script could not distinguish “your schema drifted” from “your config file is missing”. Any script or hook keying on1for findings must change to2, or use--exit-zerofor an advisory gate. Warnings have never affected the exit code and still do not -
Unrecognized options on any
[[sql.gen]]target are now a hard error. Every backend previously inherited the defaultapply_options, which silently discarded any key it did not read —row_typ = "zod"parsed as valid TOML and did nothing, with no diagnostic. This was fixed for the 11 TypeScript backends first; the same typo behaving differently depending on target language was itself a trap, so theCodegenBackendtrait default now rejects every key unless a backend declares it known, closing the gap for the other 41 backends in one change (#103). Unknown keys fail generation, with a suggestion when the key is within edit distance 2 of a real one. A config carrying a forward-compatibility key on any target will fail on upgrade -
Identifiers derived from names with consecutive capitals change spelling. PascalCase conversion previously returned mixed-case input containing no underscore unchanged, so a query named
CreateAPIKeygenerated the functioncreateApiKeyreturning the typeCreateAPIKeyRow— the same query, spelled two ways. Both sides now normalize identically:CreateAPIKeyRowbecomesCreateApiKeyRow. This affects row, enum and composite type names on every backend, and function names on the 16 backends whose manifests use PascalCase function naming (allcsharp-*and allgo-*). Names that are snake_case, or PascalCase without consecutive capitals, are unaffected -
Two SQL columns whose names collapse onto one field name are now a hard error.
SELECT "USER_ID", user_id FROM tis legal SQL and passes the analyzer’s case-sensitive duplicate-alias check, which runs on raw names before conversion; it previously emitted a struct with two identically-named fields. This applies on every backend, including under the defaultsnake_case, and covers parameters as well as columns -
The
Auto-generated by scythe. Do not edit.banner is gone, replaced by the provenance header, which carries the same warning plus the version, backend, engine and schema fingerprint — and whichscythe checkverifies rather than merely stating. Removed from 40 of the 52 backends; the Go, Kotlin and Elixir backends never emitted it. Go’s// Code generated by scythe. DO NOT EDIT.line is unchanged, since the Go toolchain matches on it. Together with the header, the first regeneration after upgrading touches every generated file
-
[sql.gen.rust] serdeandderivefailed on three of the four Rust backends. Both keys are documented on the legacy table independently oftarget, and the CLI puts them into the options map whichever backend was selected — but onlyrust-tokio-postgresrecognized them.rust-sqlxacceptedstructs_onlyalone, andrust-tiberiusandrust-sibyldeclared no options at all, so the reject-by-default trait default turned the documented config intounknown option 'serde'. All four now accept both keys. Emitted output is unchanged when neither is set. -
scythe audit --list-rulesunder-reported the rule set by two. It printed 56 rules against a registry of 58, and--explain SC-A02reported no such rule. Both built their catalog from the active-rule set, which drops anything resolving tooff— correct for deciding what runs, wrong for a catalog.SC-A02andSC-C01are off by default but are registered rules a user can enable, and they now appear with theiroffseverity. What actually fires is unchanged. -
The schema fingerprint could report drift that was not real, and miss a change that was. Schema-qualifier stripping was gated on PostgreSQL although
Catalog::get_tableis dialect-blind, and it stripped onlypublic.whereget_tablestrips any prefix, somyschema.usersandusersdiverged. Enum values were emitted unescaped, soENUM ('a|b')andENUM ('a','b')produced an identical canonical line, and a value containing a tab or newline could forge an entire extra record.CREATE DOMAINwas absent from the canonical form. The escaping scheme escapes only when a delimiter is actually present, so every fingerprint in the corpus is unchanged andFINGERPRINT_ALGORITHM_TAGis deliberately not bumped — bumping it would hand every user a spurious drift report on upgrade. Eight fixture fingerprints are now pinned to their released values (#91) -
scythe migratewrote configs that could not generate. A stock sqlc-for-Go config producedbackend = "go-go", since the language and target were concatenated unconditionally. Kotlin was worse: it had no field in the legacy config at all, so a migrated Kotlin project silently emittedrust-sqlxoutput. This is the on-ramp for every sqlc user (#97) -
Generated Go failed to compile against a schema with no temporal or decimal column, because the import block was emitted unconditionally across
go-pgx,go-godrorandgo-gosnowflake.gofmt -eparses but does not typecheck, so it could never have caught an unused import (#100) -
ruby-sqlite3andruby-tiny-tdsemitted an.rbssignature contradicting the.rbbeside it, because the RBS generator hardcoded thejsontype instead of following the manifest. Manifest scalars are Ruby doc names rather than RBS types, so this needed a translation table, not a passthrough (#101) -
WITH t(a, b) AS (...)ignored the column alias list. A CTE’s explicit column names were never applied to the analyzed scope, so the outer query could not reference them:WITH t(a, b) AS (SELECT 1, 2) SELECT a, b FROM tfailed with “column a does not exist”, because a body projection carrying no names of its own labels its columnsunknown. The alias list is now applied at all three registration points – the recursive anchor’s seed, the widened recursive union shape, and the plain fall-through path – and an alias count that disagrees with the body’s column count is rejected the way PostgreSQL rejects it, rather than being matched positionally into mislabelled columns. Contributed by Znie (#107) -
LEAD/LAGwere always inferred nullable, even when both the argument and the three-argument default are non-null.IGNORE NULLSbails out, since it can return NULL regardless (#89) -
typescript-kyselyoutput now carries a note recording that it requiresCamelCasePlugin(#96) -
The grouped-fold row reads in
typescript-pgandtypescript-mysql2are cast in TypeScript mode, matching every other read site. The twojs_modesites stay uncast, sinceas Tis not valid JavaScript (#95) -
csharp-snowflakegenerated parameter bindings that Snowflake rejects. Generated code named each positional bindingp1,p2,p3, but Snowflake’s REST protocol keys?placeholders by bare ordinal, so the server read them as named bindings and the query failed. Bindings are now named"1","2","3". The backend had shipped since 0.6.0 without ever running anywhere: it was held out of the Snowflake integration job on the diagnosis that fakesnow’s binding-name heuristic was at fault, and 0.12.0 recorded that as a fakesnow limitation rather than a codegen one. The diagnosis was backwards — fakesnow was right to rejectp1, and real Snowflake would have rejected it too. The backend now runs against the shared fakesnow server in CI -
scythe migratesilently converted nothing when the project directory contained a glob metacharacter. The pattern used to find.sqlquery files was built by joining the base directory onto thequeriesentry, and the “is this already a glob, or a bare directory needing/*.sql” decision was then made on the joined string. Neither step escaped the base directory, so a project in a directory named e.g.a[b]had its[b]compiled as a glob character class, matched no files, and reportedMigration complete: 0 file(s) convertedinstead of converting anything — the same silent-zero failure mode #84 fixed forgenerate/check/lint/audit/fmtin 0.13.0. The base directory is now escaped withglob::Pattern::escapeand joined with/on every platform, and the directory-vs-glob decision is made on the raw pattern, never on the string after the base directory has been prefixed onto it (#88) -
task version:checkasserted that every crate’s own version matchedscythe-cli’s, including crates markedpublish = false. An unpublished crate never reaches crates.io, so its version carries no meaning andversion:syncdeliberately leaves it alone — which made the two steps contradict each other, with no version that could satisfy both. Unpublished crates are now exempt from the own-version check; their inter-crate pins are still checked -
Oracle’s empty string literal is NULL, and the analyzer did not know it. Oracle is alone in treating
''as NULL, soSELECT COALESCE(email, '') AS email_or_emptywas typed non-optional —Stringrather thanOption<String>— and the driver could not decode the first row where Oracle returned NULL. The literal now carries the right nullability under the Oracle dialect, which propagates throughCOALESCE,CASE ... ELSE ''and concatenation. The other five dialects are unaffected.NVLis not covered, though it was already optional by a different route. Found by the live Oracle conformance leg; a model-only fixture could never have caught it, since it compares the analyzer against the same model it is built from -
typescript-duckdbread every row positionally while indexing it by name. The generated code calledgetRows(), which returns positional arrays, then read fields by property name — so every field came backundefinedat runtime, with notscerror because the row cast is unchecked. It now callsgetRowObjects(). Present since the backend shipped in 0.6.0; there is notypescript-duckdbintegration project, which is why nothing caught it -
typescript-oracledbcast nullable columns to their non-null type. Three sites — both read paths and theRETURNINGout-binds path — cast to the column’s base type while the declared interface said| null, so a null column was typed as though it could never be null -
The TypeScript discriminated-union row type omitted some columns entirely. With
outer_join_unions = true, a column belonging to a join group that carries no discriminant — one where every projected column was already nullable in the schema — matched neither the base-field loop nor any union variant, so it was declared nowhere. A query selecting five columns produced a row type declaring three. The Zod form of the same function had the identical defect, despite its contract that the two shapes cannot drift apart -
Provenance verification could discard every lint finding. A target whose backend and engine pair failed to construct — a config with no
[[sql.gen]]block synthesizes arust-sqlxtarget, which does not support every enginecheckaccepts — aborted the run before findings were emitted, socheckexited with an error and an empty SARIF report while real findings existed. Verification now cannot unwind past emission. Related: the header was compared against the raw backend alias from the config rather than the canonical name, so a target written assqlxreported backend drift against its own output forever -
task version:syncinvalidated every generated artifact. The provenance header embeds the scythe version, so bumping it made all committed artifacts stale and failed the generated-freshness gate on the release commit itself — on every future release, not just this one.version:syncnow regenerates after bumping and rewrites the version in documented examples, and CI carries a guard comparing every committed header against the workspace version
[0.13.0] - 2026-08-07
Section titled “[0.13.0] - 2026-08-07”This release makes generation depend on committed inputs rather than on where the command was run from, and widens distribution to Node and Python.
- npm and PyPI wrapper packages for the CLI, so Node and Python teams can pin scythe as a dev
dependency without installing a Rust toolchain. The npm package is
scythe-cliand the PyPI package isscythe-sql; both expose the binary asscythe. Each resolves the host platform to a release target triple, downloads the matching asset, verifies its SHA-256 against the release checksums file, and unpacks it. They are shims over assets the release already produced, so the build matrix is unchanged (#80) - The two platform gaps are handled deliberately rather than silently. musl Linux has no published asset and the gnu binary dies at exec with an opaque loader error, so it fails at install time naming the platform. Windows on ARM also has no asset, but the x64 binary runs under emulation, so it falls back with a warning rather than blocking a configuration that works
- Both wrappers work behind a corporate proxy and a TLS-intercepting one: they honour
HTTPS_PROXY,NO_PROXYand npm’s or pip’s own proxy settings, and read a CA bundle fromnpm_config_cafileorNODE_EXTRA_CA_CERTS. The cached binary is written through a temp file and renamed into place, so an interrupted install leaves no truncated binary for the next run to trust task version:checkfails on either a crate whose own version disagrees withscythe-cli’s or an inter-crate pin that does.version:syncnow runs it, so a partial version bump can no longer reach a tag
scythe.tomlpaths resolved against the working directory, so--configwas close to unusable from outside the project.scythe generate --config /path/to/project/scythe.tomlrun from anywhere else silently found no schema and no queries, and ascythe.tomlcould not describe its own project independently of where it was invoked from. Paths now resolve against the directory containing the config file — see Breaking Changes below (#84)task generate:allregenerated with whateverscythewas first on$PATH. With an older release installed viacargo install, it silently rewrote committed output backwards, printedDone.per backend and exited 0. During the 0.12.0 release this reverted four files from the SQLiteint64fix back toint32, and the damage was indistinguishable from legitimate regeneration. The task now buildsscythe-clifrom the workspace and invokes it by absolute path, matching what CI’s freshness job already did (#85)task version:syncsilently skippedscythe-inspect, which was absent from bothsedfile lists and missing from the secondsed’s crate alternation. Becausepublish_cratestolerates an “already exists” error from crates.io, a release would publish five crates, skip the sixth and report success — while the publishedscythe-clideclared a dependency on the previousscythe-inspect, which resolves fine and therefore never surfaces- Snowflake typed the same underlying storage three different widths depending on spelling. Snowflake
aliases every integer spelling —
INT,INTEGER,BIGINT,SMALLINT,TINYINT— toNUMBER(38,0), but onlyBIGINTresolved toint64;SMALLINTandTINYINTfell through toint16andINT/INTEGERtoint32.REALandFLOAT4likewise reportedfloat32for what Snowflake stores as an 8-byte double. See Breaking Changes (#83) NUMBER(p,s)with a non-zero scale is now scale-aware. Through the DDL path this was already handled upstream bynormalize_data_type, but the AST path —CASTand parameter inference — reached the barenumberarm and typed a decimal asint64.NUMBER(p,0)and bareNUMBERkeepint64- Types that had no arm at all and fell through as unknown, erroring only later at the backend
boundary:
INT1,HUGEINT/UHUGEINT(DuckDB),MONEY/SMALLMONEY(MSSQL and PostgreSQL),XML(MSSQL and PostgreSQL),BYTEINT(Snowflake’s synonym forTINYINT) andGEOGRAPHY/GEOMETRY. The 128-bit DuckDB integers map todecimalrather than a silently truncatingint64— no integer neutral type is wide enough.GEOGRAPHY/GEOMETRYhad been documented asstringsince the Snowflake page was written, with no code behind it - An explicit
NUMBER(p,0)resolved todecimalthrough the schema path, because catalog normalization rewrote every two-tokenNUMBER(p,s)tonumeric(p,s)regardless of the scale, so a zero scale reached the decimal arm.NUMBER(38,0)is exactly what Snowflake’sDESCRIBE TABLEreports forINT, so a schema reverse-engineered from a live table typed its keysdecimalwhile the same table written withINTtyped themint64— the spelling-dependent inconsistency #83 set out to end, still reachable by a different route.oracle.mdhad documented the correct behaviour (NUMBER(*, 0)→int64) all along (#86) - The unit test guarding zero-scale
NUMBERpassed a hand-built string straight to the conversion function, which is not the spelling the schema path produces — so it stayed green while real schemas resolved the other way. The type-mapping regressions now drive the whole pipeline verify-binstallnever ran on a release re-run, becauserelease_assetsis skipped rather than successful when the assets already exist- The release workflow’s “already published” probe against crates.io sent no
User-Agent, which their data-access policy answers with403. The check therefore always reported “not published”, so a re-run replayed the full publish chain and its 30-second inter-crate sleeps - The documented pre-commit
rev:pins in the guide were bumped by nothing and validated by nothing, so they still pointed at the previous release.version:syncnow rewrites them andversion:checkfails on drift
Changed
Section titled “Changed”UNSIGNEDis now documented on the MariaDB page, which inherits it from the MySQL dialect but never mentioned it
Breaking Changes
Section titled “Breaking Changes”- Paths in
scythe.tomlresolve against the config file’s directory, not the process working directory. This coversschemaandqueriesglob patterns and[[sql.gen]].output. Absolute paths and patterns are unchanged, and a config invoked from its own directory — the overwhelmingly common case, including every project in this repository — behaves identically. Output moves with the inputs: leaving it working-directory-relative would turn a silent wrong read into a silent wrong write, scattering generated trees into whatever directory the command ran from. A pattern matching nothing is now a hard error naming the pattern, the config directory and the resolved pattern, since after this change an empty match is the most common symptom of a stale path (#84) - Snowflake
SMALLINT,TINYINT,INTandINTEGERnow generate 64-bit integers, andREALandFLOAT4now generate 64-bit floats. Regenerate; in the statically typed targets this changes field types and driver accessors (int→long,setInt→setLong). Languages that map both widths to one native type — Python, TypeScript, PHP — are unaffected (#83)
[0.12.0] - 2026-08-07
Section titled “[0.12.0] - 2026-08-07”typescript-kyselybackend targeting Kysely’ssqltemplate tag instead of a specific driver, so one generated file runs against any Kysely dialect. Covered end to end against all four built-in dialects —PostgresDialect,MysqlDialect(mysql2),SqliteDialect(better-sqlite3) andMssqlDialect(tedious + tarn) — plus a MariaDB project, each running against a live container. Aredshiftmanifest also ships, but has no integration project of its own — Redshift coverage is inherited from the wire-compatible PostgreSQL path, not directly tested. Supports the samerow_type(interface/zod) andouter_join_unionsoptions as the other TypeScript backends; the latter makes scythe’s Kysely output strictly more precise than a hand-written Kysely query can express, since Kysely has no way to know a joined column’s nullability is correlated with its schemaNOT NULLconstraint (#66)typescript-node-sqlitebackend targeting Node’s built-innode:sqlitemodule (DatabaseSync), with zero npm dependencies. Requires Node 23.4+ to run unflagged (--experimental-sqliteon Node 22).DatabaseSynchas notransaction()helper, so:batchqueries emit explicitBEGIN/COMMITwith a rethrowingROLLBACK(#66)typescript-wasm-sqlitebackend targeting the official@sqlite.org/sqlite-wasmbuild via its synchronous OO1 API (#66)- Both new SQLite backends generate fully synchronous code — no
async,awaitorPromiseanywhere, asserted in tests. This was the explicit ask in #66: these clients are synchronous, and routing them through Kysely “introduces async promise thrashing” structs_onlynow applies to every TypeScript backend, not justrust-sqlx. It suppresses the query functions and the driver import while still emitting interfaces, Zod schemas, enums and composites. Combined withrow_type = "zod"this produces the types-only output requested in #66scythe check --database-urlverifies inferred query types against a live PostgreSQL database using the extended query protocol (Parse/Describe), reporting mismatches as rules SC-VER01 through SC-VER05 (#65)- Opt-in
outer_join_unionsfor the TypeScript backends: outer-join nullability is expressed as a discriminated union rather than independent per-column optionals, so a shape like{ total: null, notes: "gift" }— unreachable whenorders.totalisNOT NULL— is no longer admitted by the generated type. Now supported forrow_type = "zod"as well, emitting a realz.union([...])(#64) - Generated TypeScript is now type-checked with
tsc --noEmitin every TypeScript integration project and in CI. The stricttsconfig.jsonin those projects was previously decorative:tsxonly transpiles, and the validation harness ran biome alone. This gate immediately caught several codegen defects listed under Fixed - SQLite coverage in the tool-validation suite via a new
sqlite_backend_test!macro. That suite previously exercised only PostgreSQL, MySQL and DuckDB, sotypescript-better-sqlite3had never been checked against the real TypeScript toolchain cargo binstallsupporttypescript-snowflakeandgo-gosnowflakeintegration coverage against the shared fakesnow server (#27, #61)- End-to-end coverage for
outer_join_unions(interface and Zod forms) andstructs_only, which previously had none — every assertion was a unit test over hand-built columns, so no generated project set either option,tscnever checked their output and no database ever ran it.typescript-kysely’s test now calls all of its generated functions; six were imported and never invoked - A CI job that regenerates all integration code and fails if the tree moved. The integration jobs test the committed files and never regenerate, so drift meant CI was validating output no current build produces — which is exactly how the
ruby-trilogyandgo-godrordefects above stayed hidden - Criterion benchmarks over the analyzer and codegen path (
task bench). The repo previously had none, so allocation changes in the hot path were unmeasurable - fakesnow now serves snowflake-jdbc its native Arrow result format, dispatching per session on the login request’s
CLIENT_APP_IDso the Node, Go and .NET drivers keep the JSON path unchanged
- The integration-test generator’s templates had been silently corrupted since July. The commit that migrated linting to poly ran its prose formatter over
tools/integration-test-generator/templates/*.jinja, because that config carried no exclusion for.jinjafiles — one was added later, but the damage was never repaired. The formatter stripped every newline and re-wrapped all seven language templates at 120 columns, which split string literals across lines and left//comments swallowing the code behind them. Word counts were identical before and after, so nothing was lost; the templates are restored from the last good revision with every subsequent change re-applied. The corruption stayed invisible for a month because it only reaches the tree when someone regenerates, and it surfaced as unrelated-looking compiler errors when they did.scripts/check-generated-syntax.shnow parses every generated harness, and the freshness job regenerates the scaffolding as well as the query code so drift in either is caught (#72) scythe inspectwas unusable against every live database. Check SC-INS13 calledround(float8, integer), which PostgreSQL does not define, raising SQLSTATE 42883 on every server version. Becauserun_all()was fail-fast, that one bad check aborted the entire inspection. The check is repaired andrun_all()now isolates a failing check instead of abandoning the run:batchqueries emitted TypeScript that does not compile. When a generated signature exceeded 80 characters the line-wrapping helper discarded the signature its caller passed and rebuilt one from the query’s own per-column params — so the function declared(db, name, email)while its body referenceditems, an identifier that was never declared. Affected seven TypeScript backends; any:batchquery with two or more params and a long enough name was broken- Oracle
CLOB,BLOB,NCLOBandBFILEcolumns failed at runtime in therust-sibylbackend withInterface("cannot return as a String"). These are now read through a LOB locator, and the read loop no longer discards the returned byte count (a short read previously truncated large LOBs silently) - Generated TypeScript did not escape SQL spliced into template literals. A backtick — idiomatic identifier quoting in MySQL/MariaDB,
`users`.`id`— terminated the literal early, a backslash escaped the following character, and a literal${opened a live interpolation (inside a Kyselysqltag, a parameter binding). Newly reachable once Kysely made MySQL a supported target - Elixir TDS encoded a
nilboolean parameter as0rather than NULL, becausenilis falsy in Elixir — silently writingfalsewhere the caller meant “unknown”. Also corrects the MSSQL type map:float32/float64were mapped to:decimalanddateto:datetime(#28) SUMandAVGresult types now follow engine semantics instead of echoing the argument type:sum(int)widens toint64,sum(bigint)todecimal, andavg(int|numeric)yieldsdecimal, matching PostgreSQLscythe check --database-urlprepared every[[sql]]block against the PostgreSQL connection regardless of the block’s configured engine, so a MySQL or MSSQL block produced a flood of spurious SC-VER01 errors and a non-zero exit. Non-PostgreSQL blocks are now skipped with a warning, as the flag’s own documentation already promisedscythe check --database-urlprinted the connection URL — including the password — into stderr and CI logs on a connection failure. Credentials are now redacted- Type verification treated
string,uuid,jsonandinetas mutually interchangeable, so an inferreduuidagainst a reportedjsonpassed silently — exactly the wrongly mapped catalog type SC-VER03 exists to catch. Only one-directional widening tostringis now accepted, and PostgreSQL domain types resolve to their base type - Boolean codegen options (
outer_join_unions,structs_only, and others) silently coerced any unrecognised value tofalse, so"on","TRUE"or a typo disabled the feature without complaint. They are now parsed strictly and reject invalid values with an error naming the option csharp-oracleemitted code that does not compile:OracleDecimalhas noToDecimal(),bytesparameters used the wrongOracleDbType, and binary columns used a reader method that does not exist forbyte[]- The Zod emitter mapped every
bytescolumn toz.instanceof(Buffer)regardless of backend — wrong for the two new SQLite backends, whose drivers yieldUint8Array, and unresolvable in a browser whereBufferdoes not exist. The grouped-struct Zod emitter additionally bypassed the column-aware path entirely, degrading an enum column to a barez.string()instead of referencing its generated enum schema typescript-better-sqlite3’s integration test was a no-op: the harness template had import and connection branches but no test body, somain()was a bareprocess.exitand the CI step passed while exercising nothing. It now runs a full create/read/update/delete round trip with assertions- The integration-test config templates emitted invalid TOML
- Kysely’s
:batchthrew at runtime when handed an existing transaction.Transaction<DB> extends Kysely<DB>, so the unconditionaldb.transaction()call nested a transaction inside itself; it now reuses the active one viadb.isTransaction - CI’s
poly fmt --checkwent red repeatedly becausecargo-sort(run bypoly lint) rewritesCargo.tomlwith a 4-space indent while poly’s formatter insists on 2, so the two rewrote each other indefinitely.Cargo.tomlis now excluded from poly’s formatter, andtask updatereformats after a dependency bump INSERT INTO t VALUES (...)with no column list registered no parameters at all — the generated function took none while the SQL carried placeholders, so it compiled and then failed at runtime on a bind-count mismatch. Values now bind positionally to the table’s columns in catalog order. Placeholders nested inside function calls orCASEbranches inINSERT ... VALUESandUPDATE ... SETwere swallowed by a catch-all match arm and are now collected, keeping the target column’s type, name and nullability. Contributed by @Zniece (#67)ruby-trilogyquoted onlyenum::*andstringparameters, souuid,date,time,datetime,json,inet,bytesandintervalwere interpolated bare — a UUID rendered asWHERE id = f3373249-..., which MySQL parses as an identifier. It is also the one Ruby backend that builds SQL by interpolation rather than binding, so any value containing an apostrophe broke the statement and was injectable. Trilogy’s client has no bind API (its C extension defines onlyquery,query_with_flagsandescape), so every non-numeric value now goes throughTrilogy#escape, with temporal typesstrftime-formatted andjsonserialised firstgo-godrordeclared OracleRETURNING ... INTOOUT variables from the column’s neutral type alone, ignoring nullability, then assigned them to pointer fields — code that does not compile. Nullable numeric and temporal params now bindsql.NullInt32/NullInt64/NullFloat64/NullTime; string-like columns keep a plain OUT var and take its address, since godror documentssql.NullStringas unsupported (Oracle cannot distinguish''from NULL)java-jdbcandkotlin-jdbcread temporal columns withrs.getObject(col, LocalDateTime.class), which snowflake-jdbc does not implement — itsgetObject(int, Class<T>)dispatches only on the legacyjava.sqltemporal types, so everyTIMESTAMP_NTZorTIMESTAMP_TZread threw against real Snowflake. Snowflake now uses the legacy getters with null-safe conversion; the other eight JDBC engines are unchanged- Oracle schemas written as SQL*Plus scripts failed to parse, freezing two integration projects at output an old build produced. The statement splitter only recognised
;, but such scripts terminate on a lone/and contain no top-level semicolons;CREATE SEQUENCE ... START WITH ... INCREMENT BY ...is also rejected by the parser, and trigger bodies are PL/SQL it cannot read. Sequences and triggers contribute no columns and are now skipped, asCREATE SCHEMAalready was validate_structuralknew 31 of the 52 registered backends; the other 21 reported “unknown backend” instead of being checked. Enrolling them surfaced two real defects:python-oracledb,python-pyodbcandpython-snowflakeemitted lines that violate ruff’s line length for any query with a few columns, andtypescript-snowflakeemittedanyfor row and bind typesscythe checkretained every analyzed query and verifiable block even when--database-urlwas absent, though both are only read when it is set- CI’s generated-code freshness gate had three holes that let real drift through unnoticed.
integration_tests/Taskfile.yamlhand-maintained a list of backends to regenerate that had drifted to 71 of the generator’s ownbuild_backends()99 entries, so every Redshift, MSSQL and Snowflake backend was silently excluded and never regenerated by CI; the list now comes from a new--listflag on the generator instead of a hand-copied one. The gate also compared withgit diff --exit-code, which cannot see untracked files, so a newly added backend with no committed output yet would pass green with nothing to regenerate against — it now usesgit status --porcelain. Andscripts/check-generated-syntax.shprintedSKIPPEDand returned success whenever a language checker binary was missing, while the job that ran it installed no PHP, Ruby, Python or Go toolchain at all, so every one of those checks had been silently doing nothing since it was added. The script now fails on a missing checker in--strictmode (implied byCI=true), and the job installs all four toolchains. Regenerating the 29 backends the gate had never covered turned up no behavioral drift — 13 files changed, all formatting (#72) - Bare
FLOAT(no precision) normalized incorrectly on two dialects, though not the defect #73 reported: the silent narrowing ofFLOAT(53)it described does not exist, sincenormalize_data_typealready maps any precision above 24 todouble precisionon every dialect. The real bugs were in the two unparameterized paths. The neutral-type resolver’s bare"float"string arm defaulted tofloat32unconditionally, wrong on PostgreSQL, MSSQL and Oracle, where bareFLOATis 8-byte double precision; andDataType::Float(None)— sqlparser’s node for the same bareFLOAT— normalized todouble precisionfor every dialect, wrong on MySQL, whose bareFLOATis a genuine 4-byte type. Both paths are now dialect-aware: MySQL’s bareFLOATresolves tofloat32, every other dialect tofloat64 - MySQL
UNSIGNEDinteger columns (INT UNSIGNED,BIGINT UNSIGNED, etc.) failed codegen outright withBackendError::UnknownType("bigint unsigned")— a hard failure on an ordinary MySQL schema, not a silently wrong type.normalize_data_typepreserved the MySQL display width (e.g."bigint(20) unsigned"), which the neutral-type resolver had no matching arm for and which itsstrip_precisionhelper couldn’t rescue, since the parenthesized width isn’t trailing. The normalizer now discards the display width for every unsigned integer type, giving the resolver a clean string to match (#74) BITcolumns lost their declared width during normalization, collapsingBIT(1)andBIT(n>1)to the same bare"bit"string and making them indistinguishable downstream. The width is now preserved ("bit(1)","bit(8)"), so the neutral-type resolver can tell a boolean-ishBIT(1)from a genuine multi-bit value — which PostgreSQL treats as a bit string (bytes) and MySQL treats as an integer bitfield (int64). MSSQL’sBITis untouched: it has no width and was already correctlybool, and none of the 11 MSSQL integration harnesses changed when regenerated, which is the evidence the fix is scoped to PostgreSQL and MySQL (#75)- A scalar subquery’s inferred type inherited only its projected column’s own nullability, ignoring that the subquery itself evaluates to
NULLwhen it matches zero rows:(SELECT name FROM users WHERE id = $1)was typed non-nullable wheneverusers.namewasNOT NULL, even though the subquery is nullable by construction. It’s now nullable unless the query is provably guaranteed to return exactly one row — an ungrouped aggregate — in which case the aggregate’s own nullability already accounts for the empty-input case. That carve-out stops short of windowed aggregates:COUNT(*) OVER ()looks like a single-row aggregate but produces one output row per input row, so it stays subject to the general zero-rows-means-NULLrule (#76) WITH RECURSIVEqueries were typed from the anchor branch alone, discarding the recursive branch’s analysis withlet _ = ...— under-reporting nullability whenever the recursive term introduced aNULLthe anchor didn’t have, such as aLEFT JOINor an explicitNULLliteral filling a column the anchor fills with aNOT NULLvalue. The full anchor-UNION-recursive query is now re-analyzed and kept, taking the sameSetOperationpath as any otherUNION— which also means a column-count mismatch between the anchor and recursive branches now surfaces as an error instead of being silently swallowed (#77)- SQLite integration harnesses are retyped to match the widened
int64/float64neutral types from the dialect-aware fixes above.rust-sqlx-sqlitehad not compiled since93b76b9, the earlier commit in this same release that widened SQLiteREALtofloat64: its harness template still declaredlet total: f32, soorder.total == totalcould not type-check against the now-f64generated field. CI runs this suite on every push, so the job had been red since that commit landed; a concurrent GitHub Actions outage kept the failure from getting the scrutiny a red required job normally gets.go-database-sql-sqlitehad the same defect from theINTEGER→int64change, failing with eight type errors because its harness declaredvar createdUserID int32while the generated code returnedint64; its template grouped SQLite with MySQL, MSSQL and Snowflake, which legitimately use 32-bit ids, and SQLite now has its own arm. The remaining statically typed SQLite harnesses — C#, Java, Kotlin and the four TypeScript projects — were compile-checked and were already correct. Both fixed here - Every generated
Gemfiledeclared no gem source, so all six Ruby integration projects failed to install. The same formatter pass had joined the magic comment and the source directive onto one line —# frozen_string_literal: true source "https://rubygems.org"— and since#opens a Ruby comment, thesourcecall was silently commented out. The file stayed valid Ruby, which is why it survived: Bundler simply reported that gems were missing “in locally installed gems”. CI additionally ran an older Bundler than the committedGemfile.lockfiles were written with, which an older Bundler cannot read, so the Ruby steps now pinbundler: latest - Every generated
mix.exswas a syntax error, failing six integration jobs before any Elixir test ran. Its template had been reflowed as prose and re-wrapped at 120 columns, putting the whole module on one line, which Elixir rejected atmix.exs:1:63. Formatting only — project name, version,elixirc_pathsand every dependency are unchanged. Together withpyproject.tomlbelow, this is the last functionally broken residue of that formatter pass;composer.jsonandtsconfig.jsonwere reflowed by it too but remain valid, since JSON ignores whitespace - Five Kotlin projects had their generated file committed as
Queries.ktwhile the generator writesqueries.kt. On a case-insensitive filesystem those are one path, so generation overwrote in place and git never recorded the rename — meaning those projects shipped generated code that no current build produces, visible only on Linux. All nine Kotlin projects now use the generator’s name. Java keepsQueries.java, which is correct, as Java requires the filename to match the public class go-godror-oracledid not compile: its harness passedint64where the generatedCreateOrderexpectsfloat64. Oracle’sNUMBER(10, 2)is a scaled decimal and correctly maps tofloat64, so the harness was stale, not the type mappinggo-gosnowflakefailed with missinggo.sumentries across the driver’s Azure, AWS and Arrow dependency tree. It was the only one of the eight Go integration steps not runninggo mod tidy, and since the generatedgo.modcarries direct dependencies only while Go 1.17+ module-graph pruning needs the indirect set resolved, that step could never have succeeded- Every generated Python integration project had an unparseable
pyproject.toml. Its template’s first line had been collapsed into[project] name = "..." version = "..." requires-python = "..." dependencies = [, which looks plausible but is invalid TOML, souvfailed withTOML parse error at line 1, column 11before running anything. This broke seven of the eight integration jobs — every one that touches a Python backend — and is unrepaired residue from the same formatter pass that reflowed the Jinja templates as prose ind895d04; the follow-up repair ind4d6694missed this file. The other two TOML templates were checked and are intact - Manifest selection no longer depends on the process working directory. At 56 call sites, one per backend constructor, manifest loading preferred a CWD-relative
backends/<name>/manifest.tomlover the compiled-in manifest, so identical inputs produced different generated code depending on wherescythewas invoked, with nothing in the output recording which manifest was used. The lookup was also engine-blind: everyrust-sqlxengine variant probed the same PostgreSQL file, andjava-jdbccollapsed nine engines onto one path, so a MySQL or SQLite target run from a directory containingbackends/would have silently received PostgreSQL type mappings. Manifests are now compiled in and selected purely from(backend, engine). The lookup was undocumented — no CLI flag, no config key, no log line, discoverable only by reading the source — and fired in no integration project and no CI job; all 13 stub manifests were byte-identical to their compiled-in counterparts, so removing it changes no generated output. Note that this is the determinism half of #82 only: there is no user-facing manifest override yet, and the issue stays open for one, since a global override directory would reintroduce the engine collision above. Also corrects the MSSQL type-mapping docs, which claimedTINYINTmaps to a nonexistentint8neutral type — it maps toint16
Changed
Section titled “Changed”- Breaking (output):
scythe checknow writes its report to stdout instead of stderr, and no longer prints the trailingCheck passed.line or the warnings-only summary. Scripts parsing either will need updating - Breaking (types): SQLite
REALandINTEGERcolumns now resolve tofloat64andint64instead offloat32andint32. Both of SQLite’s storage classes are 8 bytes wide with no narrower variant —REALis defined as an 8-byte IEEE float, andINTEGERholds up to 8 bytes — but the type mapper applied PostgreSQL’s 4-byte widths (real/integergenuinely arefloat4/int4there) to every engine. Statically typed SQLite targets change accordingly (f32→f64,int32→int64,float/int/getFloat/getInt→double/long/getDouble/getLong); becauseINTEGER PRIMARY KEYis SQLite’s rowid alias, every SQLite primary key retypes along with it. PostgreSQL is unaffected (#70) - Breaking (API): generated Kysely functions now take
QueryExecutorProviderrather thanKysely<DB>. The previous<DB = any>generic did nothing useful, and the narrower type rejected callers holding a connection or a controlled transaction — both of which Kysely’sRawBuilder.executeaccepts - The Snowflake
NUMBER(p, s)normalizer now preserves precision and scale inCatalog::Column::sql_type(consumed by therust-sibylbackend). This does not change inferred neutral types:sql_type_to_neutralstrips precision before mapping, sonumeric(10,2)andnumericresolve identically. Theint64→float64shift visible in the Snowflake integration output came from regenerating stale committed files, not from this change. (Supersedes an earlier entry in this section which credited this commit with fixing money-column truncation across all seven Snowflake projects — that truncation was a real bug, but it was fixed for Oracle in 0.11.0 and the Snowflake output was merely out of date.) better-sqlite3moves to^12.11.1in the integration projects, and the SQLite CI job to Node 24.node:sqliteis only unflagged from Node 23.4, and better-sqlite3 11.10 will not load on Node 24+- The documentation site migrated from zensical to Astro Starlight, which incidentally fixed
guide/auditandguide/inspectbeing unreachable on the live site - fakesnow’s shared query-request wrapper (moved to
integration_tests/fakesnow/fakesnow_server.py, see Removed) now always emits the plain Snowflake JSON rowset format — every cell stringified, matching real Snowflake’s wire format — instead of doing so only for the Node driver. Query execution is serialized with anasyncio.Lockso a client’s HTTP retry cannot re-run a statement concurrently with the still-in-flight original. The login handler now advertisesCLIENT_RESULT_COLUMN_CASE_INSENSITIVE, which snowflake-jdbc needs to resolve lowercase generated-code column lookups (getInt("id")) against fakesnow’s uppercase column labels go-gosnowflake’s generated harness pointed atsql/snowflake/schema_emu.sql, a leftover from an abandoned Docker-emulator plan that lackedAUTOINCREMENT; it now usessql/snowflake/schema.sqllike every other Snowflake backendelixir-tds-mssqlis back in CI — its step was added in7b3ec72, removed in5edaaa6while the backend was broken, and restored once the type mapping was fixed. Itsintegration_tests/Taskfile.yamlentry is new (#28)
Removed
Section titled “Removed”integration_tests/typescript-snowflake/fakesnow_server.pymoved tointegration_tests/fakesnow/fakesnow_server.py— it is shared infrastructure for every non-Python Snowflake driver, not a TypeScript-specific fixture- The root-level
backends/directory (23 files): 13 manifests byte-identical to their compiled-in counterparts undercrates/scythe-codegen/manifests/, plus 10 Jinja templates that no code path reads — a vestige of an abandoned template-based architecture, since every backend emits strings directly. It existed only to be picked up by the working-directory-relative manifest lookup removed above.scythe-backend’s renderer tests, the sole remaining reader, now use a fixture undercrates/scythe-backend/tests/fixtures/ naming.field_casemanifest option. It was deserialized from all 106 manifests and never read — field names come fromto_snake_caseinresolve.rsregardless of what’s declared, so the 73 manifests declaringcamelCaseorPascalCasewere silently ignored, whilefn_caseon those same manifests is honored, which is what made the gap easy to miss. Implementing it instead would rename fields in generated code for most backends and break every downstream caller that destructures a row, so the option is deleted rather than wired up — universal snake_case field naming is now intentional instead of accidental. Regenerating all 99 integration backends afterward produced no diff, which is the proof the option was dead (#69)
Unverified / Skipped in CI
Section titled “Unverified / Skipped in CI”These backends have codegen support but are not exercised against a live database. The equivalent list under [0.6.8] describes that release and is now out of date; this one supersedes it.
Snowflake (#27) — python-snowflake,
typescript-snowflake, go-gosnowflake, java-jdbc-snowflake and kotlin-jdbc-snowflake all run
against the shared fakesnow server. The two JDBC suites were
unblocked this release by teaching fakesnow to serve snowflake-jdbc its native Arrow result format.
Still excluded:
csharp-snowflake— the codegen defect was fixed this release, but the harness fails earlier: Snowflake.Data names its bind parametersp1/p2/p3, which fakesnow’s binding-name heuristic treats as named rather than positional. A fakesnow limitation, not a codegen onephp-pdo-snowflake— uncoverable. Snowflake has no PDO driver; access requires the proprietary closed-source ODBC driver preinstalled on the runner
Oracle — elixir-jamdb (DBConnection.ConnectionPool dispatch error with jamdb_oracle) and
ruby-oci8 (native gem needs Oracle Instant Client SDK headers unavailable in CI).
SQLite — php-pdo-sqlite has no CI job. The createUser arity mismatch noted under [0.6.8] is
resolved (generated signature and harness call now agree, and the harness parses), but it has never
been run against a database.
[0.11.0] - 2026-07-04
Section titled “[0.11.0] - 2026-07-04”- Full
:grouped/@group_bynested code generation across every backend. A:groupedquery now emits a child struct plus a parent struct carrying achildrencollection, and a query function that runs the flat SQL and folds rows into an order-preserving list of parents keyed by the grouping column — all client-side, with the SQL unchanged from:many. Previously:groupedsilently degraded to a flat:manyproxy despite the docs promising nesting (#55). Implemented for all Rust, Python, TypeScript, C#, Go, Ruby, PHP, Elixir, and Java/Kotlin backends, each with language-native structs, collection types, and fold idioms. CodegenBackend::generate_grouped_structsandgenerate_grouped_query_fntrait methods (inputs bundled in aGroupedQueryFncontext struct) with default implementations that return a clear “grouped queries are not yet supported by ‘’” error, so future backends opt in incrementally without panicking. - Positional param-naming escape hatch:
-- @param $N <name>[: <description>]overrides the inferred/pNfallback name for a placeholder by position, flowing the chosen name to every language. The existing docs-only-- @param <name>: <description>form is unchanged (#53). - Lint rule SC-S07
unbound-sql-param(error): flags any$Npresent in the SQL body but absent from the generated parameter signature, backstopping the whole class of silent param drops.
- Params inside a FROM-clause derived table (subquery) are no longer discarded — the sub-analyzer’s collected params and positional counter are merged back into the parent scope (#52, Case C).
- Placeholders nested inside an
UPDATE … SETarithmetic expression such asSET credits = credits + $2are now collected instead of silently dropped; param collection recurses throughBinaryOp/UnaryOp/Nestedexpressions (but not subqueries, which own their own param scope). Caught by SC-S07 (#52). - Unsupported inline named placeholders (
:name) now fail fast with a query-pointed error instead of emitting broken codegen (#52, Cases A/B).
Changed
Section titled “Changed”- Workspace crate versions bumped 0.10.0 → 0.11.0 across all six crates, with cross-crate path-dep version pins updated.
sqruff-libupgraded 0.38 → 0.39 (cargo upgrade --incompatible); lockfile refreshed.
[0.10.0] - 2026-06-14
Section titled “[0.10.0] - 2026-06-14”scythe inspect <database-url>subcommand — live-database operational health checks. Connects viatokio-postgresand runs a set ofpg_catalogqueries that detect issues only visible in a running database, then emits findings in the same human / SARIF 2.1.0 / JSON reporter shapes used byscythe audit. URL resolution: positional argument, then$DATABASE_URL, then$SCYTHE_DATABASE_URL. Builds a per-invocationtokio::runtime::Builder::new_current_thread()runtime so the rest of the CLI (lint,audit,generate) stays synchronous.- New
scythe-inspectcrate (crates/scythe-inspect/) carrying aDbDriverasync trait, aPostgresDriverimplementation backed bytokio-postgres, and aMysqlDriverstub that returnsInspectError::Unsupported("mysql")fromconnectandrun_all. The stub exists to keep the trait shape engine-agnostic; a real MySQL driver lands in Phase 3 (v0.13.0). - Three Postgres operational checks at Phase 0, clean-room reimplemented from the equivalent supabase/splinter lints (no source code copied; ATTRIBUTIONS.md updated): SC-INS01 missing-fk-index (warn — foreign-key columns with no covering index force a sequential scan on every join through the constraint; splinter 0001), SC-INS02 policy-exists-rls-disabled (error — table has
CREATE POLICYdefinitions butROW LEVEL SECURITYis disabled, so the policies never apply; splinter 0006), and SC-INS03 duplicate-index (warn — two or more indexes with identical definitions modulo name; splinter 0009). scythe inspect --list-checksprints the check catalog (id, name, severity, description) without connecting, so users can discover the rule set offline.scythe inspect --format <human|sarif|json>,--severity <off|warn|error>,--exit-zero,--output <PATH>,--dialect <postgres|mysql>— mirror the audit subcommand surface for consistency. Exit code 2 on remaining error-severity findings unless--exit-zerois set; exit 0 otherwise. Severity floor filtering applies before emission.- Public
scythe-inspectpre-commit hook published via.pre-commit-hooks.yaml. CI-mode hook:always_run: true,pass_filenames: false, requires$DATABASE_URL(or$SCYTHE_DATABASE_URL) in the hook environment. Local pre-commit runs without the variable fail loudly with the same error as the CLI. Phase 1 (v0.11.0) will addscythe.toml[inspect]URL sourcing so local use becomes natural. - New documentation page
docs/guide/inspect.mdcovering quick-start, check catalog, severity/exit-code semantics, GitHub Actions CI recipe withservices: postgres, pre-commit usage, whatscythe inspectdoes not do (yet), and the phased roadmap through v0.14.0. docs/guide/cli-reference.mdextended with theinspectsubcommand and every flag;docs/guide/pre-commit-hooks.mdadds the newscythe-inspecthook row and section; README addsscythe inspectto the feature list and a Documentation link.- New CI workflow
.github/workflows/inspect-live.ymlspins uppostgres:16-alpineas a service and runscargo test -p scythe-inspect --features live-tests. Triggered on PRs that touchcrates/scythe-inspect/**. Defaultcargo testruns stay DB-free. ATTRIBUTIONS.mdextended with a “Live inspection rules inspired by splinter (scythe-inspect)” subsection citing splinter lints 0001, 0006, 0009 against SC-INS01, SC-INS02, SC-INS03 respectively.
Changed
Section titled “Changed”- Workspace crate versions bumped 0.9.0 → 0.10.0 across all six crates (the five existing crates plus the new
scythe-inspect), with cross-crate path-dep version pins updated. scythe lintandscythe auditare unaffected — Phase 0 adds the inspect surface without touching the static pipeline.
[0.9.0] - 2026-06-14
Section titled “[0.9.0] - 2026-06-14”scythe auditsubcommand — static security analyzer for SQL. Reads.sqlfiles, runs a built-in security rule pack, and emits findings as human-readable text, SARIF 2.1.0 (with CWE tags for code-scanning ingest), or JSON. Exits non-zero when any rule fires, so it slots into CI gates.scythe audit --list-rules— print the rule catalog (id, name, severity, category, description) grouped by category, then exit 0. Reflects user-loaded rules fromscythe.tomlso the catalog is honest.scythe audit --explain <RULE_ID>— print the description and CWE references for a rule by id, then exit 0. Useful for figuring out why a rule fired without going to the docs.scythe audit --severity <off|warn|error>— drop findings below the given level so CI gates can graduate from warnings to errors.scythe audit --exit-zero— always exit 0 after emitting findings, for advisory CI integrations that publish findings but don’t gate the build.scythe audit -o, --output <PATH>— write reporter output to a file instead of stdout. Useful for SARIF/JSON artifacts in CI.scythe audit --ignore-suppressions— disable inline-- scythe-audit: ignore[...]annotations for periodic strict scans.scythe audit --dialect <postgres|mysql|sqlite|mssql|oracle|snowflake>— set the SQL dialect for explicit-file mode (config mode already inherits the dialect from[[sql]].engine).- New docs page
docs/guide/audit.mdcovering quick-start, rule catalog, suppression syntax, user-defined rules, available matchers, and CI integration recipes (GitHub Actions SARIF, GitLab SAST, pre-commit).docs/guide/cli-reference.mdextended with theauditsubcommand and every flag. Severitynow derivesPartialOrd/Ordand gains aSeverity::parse_clihelper so CLI consumers can resolveoff/warn/errorto a typed minimum.- Eleven canonical security rules ship in
scythe-lint’sauditmodule: SC-SEC01 dangerous-function (CWE-78), SC-SEC02 grant-all (CWE-269), SC-SEC03 grant-to-public (CWE-269), SC-SEC04 superuser-role (CWE-269) covering SUPERUSER/CREATEDB/CREATEROLE/REPLICATION/BYPASSRLS, SC-SEC05 literal-password (CWE-798), SC-SEC06 weak-hash-in-auth (CWE-327, CWE-916), SC-SEC07 select-star-pii (CWE-200), SC-SEC08 cartesian-join (CWE-400), SC-SEC09 unbounded-like (CWE-1333), SC-SEC10 security-definer-no-search-path (CWE-426), and SC-SEC11 session-mutation (CWE-269) covering SET ROLE / SET SESSION AUTHORIZATION / RESET ROLE. - Hybrid matcher framework: rule metadata lives in TOML, AST-matching logic lives in named Rust functions registered against a
MatcherRegistry. Adding a rule that reuses an existing matcher is now a TOML stanza, not a Rust file. Canonical rules ship in-tree viainclude_str!so the default registry has zero runtime config dependencies. - User-defined audit rules via
scythe.toml:[[audit.rule]]for inline rules andextra_rules = ["./path.toml"]to load separate files. IDs must start withUSER-; collisions with canonicalSC-SEC*IDs are rejected at load time with the offending ID and source path. - Inline suppressions:
-- scythe-audit: ignore[SC-SEC02,SC-SEC09] reason="vetted"attaches to the next statement and suppresses the listed rule IDs for every line of that statement (terminated by a blank line or;). Reason clauses are parsed and discarded. Malformed annotations are silently ignored. LintContext.dialect: SqlDialectfield, threaded through every rule call site, so matchers can dialect-filter viadialects = [...]in the rule spec.RuleFileTOML schema withschema_version = 1for forward-compatible rule files.- New
migrationrule category and nine canonical migration-safety rules under theSC-MIG*prefix: SC-MIG01 ban-drop-table, SC-MIG02 ban-drop-column, SC-MIG03 require-concurrent-index-creation, SC-MIG04 renaming-column, SC-MIG05 constraint-missing-not-valid, SC-MIG06 ban-drop-database-or-schema, SC-MIG07 renaming-table, SC-MIG08 ban-truncate-cascade, SC-MIG09 ban-alter-column-type. Each rule targets a class of irreversible or lock-prone Postgres DDL change that breaks zero-downtime deployments. All declaredialects = ["postgres"]. Seven matcher functions back them:drop_statement(parameterised bykinds = ["table", "column", "database", "schema"]so a single matcher serves SC-MIG01/SC-MIG02/SC-MIG06),create_index_concurrency,alter_table_rename_column,constraint_missing_not_valid,alter_table_rename_table,truncate_cascade,alter_column_type. The matcher framework is unchanged. - Four additional column-type-preference migration rules backed by a single new
column_type_disallowedmatcher: SC-MIG10 prefer-bigint-over-int (fires onint/integer/int4/smallint/int2— 32-bit keys overflow at 2^31 and widening requires a write-blocking ALTER), SC-MIG11 prefer-text-over-varchar (fires onvarchar(n)/character varying(n)/char(n)— Postgres stores these identically totext; a length bump is write-blocking), SC-MIG12 prefer-timestamptz (fires ontimestamp/timestamp without time zone— naive timestamps silently shift on session timezone changes), SC-MIG13 prefer-identity-over-serial (fires onserial/bigserial/smallserial— SERIAL is legacy implicit-sequence shorthand;GENERATED AS IDENTITYis the SQL-standard replacement). The matcher walksCREATE TABLEcolumns andALTER TABLE … ADD COLUMNoperations, using exact-match and prefix-before-(semantics to avoid false-positives (e.g.bigintdoes not fire whenintis disallowed). Emitstable,column,actual_type, andsuggested_typebindings. - The
scythe auditdispatcher now also runs rules in the newmigrationcategory;--list-rulesgroups SC-MIG* under a separate[migration]heading. - Three additional constraint-lock migration rules covering the next class of Squawk-derived ALTER hazards: SC-MIG14 disallowed-unique-constraint (fires on
ALTER TABLE … ADD CONSTRAINT … UNIQUE (…)— builds the index inline under ACCESS EXCLUSIVE; safe pattern isCREATE UNIQUE INDEX CONCURRENTLYfollowed byADD CONSTRAINT … UNIQUE USING INDEX), SC-MIG15 adding-primary-key-constraint (fires onALTER TABLE … ADD CONSTRAINT … PRIMARY KEY (…)— same lock hazard, sameUSING INDEXworkaround), SC-MIG16 ban-create-domain-with-constraint (fires onCREATE DOMAIN … CHECK (…)— Postgres validates every row of every table using the domain under ACCESS EXCLUSIVE and the constraint cannot be split intoNOT VALID+VALIDATE). Two new matchers back them:add_constraint_without_using_index(parameterised bykinds = ["unique", "primary_key"]so a single matcher serves SC-MIG14/SC-MIG15, and distinguishes the plainUNIQUE/PRIMARY KEYtable constraints from the… USING INDEXvariants) andcreate_domain_with_constraint. - Two NULL-contract-integrity migration rules: SC-MIG17 ban-drop-not-null (error — fires on
ALTER TABLE … ALTER COLUMN … DROP NOT NULL; relaxing a NOT NULL contract breaks deployed application versions and ORM mappings that still treat the column as non-null) and SC-MIG18 adding-not-nullable-field (warn — fires onALTER TABLE … ADD COLUMN … NOT NULLwithout aDEFAULT; rewrites every existing row on Postgres <11 and breaks deployed application versions that insert without the new column). Two new matchers back them:alter_column_drop_not_nullandadd_column_not_null_no_default. Both rules declaredialects = ["postgres"]. - Two splinter-inspired rules covering function search-path hygiene and pg_upgrade-blocking column types: SC-SEC12 function-search-path-mutable (warn — fires on
CREATE FUNCTIONwithoutSET search_path = …and notSECURITY DEFINER; complementary to SC-SEC10 which owns the escalating DEFINER case at error severity, so the two rules never double-count on the same statement) and SC-MIG19 unsupported-reg-types (error — fires when a column type isregcollation/regconfig/regdictionary/regnamespace/regoper/regoperator/regproc/regprocedure; reg* OID types other thanregclassblockpg_upgradeand do not survive logical dump/restore). One new matcher (function_search_path_mutable); SC-MIG19 reuses the existingcolumn_type_disallowedmatcher with an emptysuggestedand a regtypedisallowedlist. Detection patterns inspired by supabase/splinter lints 0011 and 0018 — seeATTRIBUTIONS.md. ATTRIBUTIONS.mdat the repo root listing external projects whose detection patterns informed scythe rules. Initial entry credits supabase/splinter and documents the no-license caveat (clean-room reimplementation only).- Row Level Security rule pack — three rules under the new
SC-RLS*prefix (stillcategory = "security"): SC-RLS01 policy-references-user-metadata (error, CWE-639 — fires onCREATE POLICYwhose USING or WITH CHECK reads fromuser_metadata, an end-user-editable JWT claim; safe path uses server-setapp_metadata), SC-RLS02 policy-always-permissive (error, CWE-285 — fires on a permissive policy whose USING or WITH CHECK is a tautology like(true),(1 = 1), orNULLon a write-side command; SELECT policies and restrictive policies are excluded), SC-RLS03 policy-uses-uncached-auth-function (warn, CWE-405 — fires on a bareauth.uid()/auth.jwt()/auth.role()/auth.email()/current_setting(…)call in the policy expression without wrapping in a scalar subquery; wrapping lets Postgres cache the result as an InitPlan instead of re-evaluating per row). Three new matchers walk the typedCreatePolicy.using/.with_checkExprASTs. SC-RLS03 specifically stops atExpr::Subqueryboundaries — that’s the safe form. Detection patterns inspired by supabase/splinter lints 0015, 0024, 0003 — seeATTRIBUTIONS.md. - CHECK-constraint quality rule SC-CHK01 check-constraint-always-true (warn,
category = "antipattern"): fires when a CHECK constraint expression is a tautology (true,1 = 1,NULL, parenthesised variants). Covers column-level CHECK inCREATE TABLE, table-level CHECK inCREATE TABLE, andALTER TABLE … ADD CONSTRAINT … CHECK. A tautological CHECK enforces nothing — almost always signals a copy-paste mistake or unfinished migration. New matchercheck_constraint_always_true. New canonical TOML filerules/quality.tomlcarrying theSC-CHK*rule namespace. scythe auditnow dispatches rules in theAntipatterncategory alongsideSecurityandMigration, so non-security canonical rules surface in audit output.--list-rulesgroups SC-CHK* under a separate[antipattern]heading.scythe lintnow runs the canonical SC-SEC*, SC-RLS*, SC-MIG*, and SC-CHK* audit packs alongside the existing schema-aware safety/codegen/naming rules and sqruff. Dialect gating: rules whosedialectslist excludes the configured[[sql]].engineare silently skipped, so amysqlproject does not see postgres-onlySC-MIG*findings without explicit opt-in. No CLI flag is required — the rules ship indefault_registry()and respect the same[lint]severity overrides as the rest of the rule set.- Public
scythe-auditpre-commit hook published via.pre-commit-hooks.yaml. Runs the canonical audit rule packs over staged.sqlfiles with noscythe.tomlrequired. Defaults to the postgres dialect; override per-hook viaargs: [--dialect, mysql]. The existingscythe-linthook now also picks up audit rules whenever ascythe.tomlis present. Documented indocs/guide/pre-commit-hooks.mdanddocs/guide/audit.md. - Oracle bindings upgraded to sibyl 0.7. The codegen emitter (
crates/scythe-codegen/src/backends/rust_sibyl.rs) was rewritten for sibyl 0.7’s broken APIs:sibyl::preludeis gone (top-level re-exports used directly),Varchar::as_str()now returns&strinstead ofResult<&str>, andDate::timestamp()was removed (chrono::NaiveDateTime now built from thedate_and_time()tuple). The integration test template selects["tokio", "nonblocking"]; withoutnonblocking, sibyl 0.7’simpl Debug for LOBhas everyfn fmtbody cfg-gated away and the lib fails to build. The Oracle manifest now mapsdecimaltof64because sibyl 0.7 has noToSql/FromSqlforrust_decimal::Decimal— flagged as a precision trade-off for follow-up. - sqlx 0.8 → 0.9 in the Rust integration test crates (
rust-sqlx,rust-sqlx-mysql,rust-sqlx-mariadb,rust-sqlx-sqlite,rust-sqlx-redshift). sqlx 0.9 tightenedraw_sqlandqueryto requireSqlSafeStr; the integration test template now wraps runtime SQL strings withsqlx::AssertSqlSafe.
- Five
test_enginescodegen tests that were failing onmainagainst the previous baseline are green. Three were neutral-type mappings falling through to the unknown-type literal fallback: MSSQLDATETIMEOFFSET→datetime_tz(was"datetimeoffset"), RedshiftSUPER→json(was"super"), OracleNUMBER(p, s)with a non-zero scale →decimal(wasint64becausenormalize_data_typewas ignoring theCustom-token scale parameter). Two were stale fixture expectations: OracleNUMBER(10)correctly maps toint64(10 digits overflows int32), and SnowflakeINTEGERcorrectly maps toint32(sqlparser parses it dialect-agnostically asDataType::Integer(None); dialect-aware widening to int64 is tracked as a separate follow-up).
Changed
Section titled “Changed”- The four Postgres-specific audit rules (SC-SEC04 superuser-role, SC-SEC05 literal-password, SC-SEC10 security-definer-no-search-path, SC-SEC11 session-mutation) now declare
dialects = ["postgres"]and no-op on non-PostgreSQL dialects instead of producing false positives. Behaviour is unchanged for the default PostgreSQL workflow. - Pre-commit hook chain aligned with the polyrepo’s shared
kreuzberg-dev/pre-commit-hooks v2.1.10source. Nine individual hook repos collapsed into a single consolidated source for general file checks, markdown, Rust (fmt/clippy/sort/machete/deny), shell (shfmt/shellcheck), typos, and ai-rulez governance.taplo-formatandbiome-formatstay as separate repos.rustdoc-lint,markdownlint-rumdl-strict, andrust-max-linesare listed in the config but commented out with TODOs — scythe’s current codebase trips each one (~449 missing-doc errors, 35 long-line markdown files, 4 source files over 1,000 LOC); each is its own focused remediation. A new_typos.tomlallowlists SQL aliases (ba), a singularize edge case (statu), the typos default dictionary’s surprise prefix entries (CHEC→CHECK,SELEC→SELECT) that fire on plural SQL keywords, and excludes lockfiles where hex commit hashes routinely trip false matches. - Sibyl-driven Oracle integration test now reads
schema.sqlinstead ofschema_full.sql.schema_full.sqlcontained PL/SQLCREATE SEQUENCE … INCREMENT BY …blocks that sqlparser cannot parse; the trimmedschema.sqlcarries only theCREATE TABLEDDL scythe actually needs for type inference. The test database setup still usesschema_full.sqlseparately.
[0.8.0] - 2026-05-26
Section titled “[0.8.0] - 2026-05-26”- Kotlin
extension_functionsbackend option (opt-in, default off) forkotlin-jdbcandkotlin-r2dbc. When enabled, query functions are generated as idiomatic Kotlin extension functions on the connection receiver (fun Connection.getUser(id: Int)called asconnection.getUser(id)) with expression bodies for value-returning queries.kotlin-r2dbcis reworked into asuspendextension onio.r2dbc.spi.Connection, moving the connection lifecycle to the caller. (#43) - PHP
namespacebackend option forphp-pdoandphp-amphp. Any value emitsnamespace <value>;; an empty string omits the declaration. Default remainsApp\Generated, so existing output is unchanged. Enables PSR-4 framework integration (Laravel, Symfony, etc.). (#46)
- Schema parser no longer crashes on psql client meta-commands.
pg_dump 18+anddbmateemit\restrict/\unrestrictlines that are not SQL; scythe now strips any line whose first non-whitespace character is\before parsing, so plain-format Postgres 18 dumps are consumed as-is. (#49) python-psycopg3,python-asyncpg, andpython-aiomysqlnow emitimport uuidandfrom typing import Anywhen their type mappings useuuid.UUID/dict[str, Any]. Generated modules previously raisedNameErroron import. (#48)
[0.7.0] - 2026-05-20
Section titled “[0.7.0] - 2026-05-20”scythe-corenow captures unknown-- @<name> <value>annotation lines asCustomAnnotation { name, value, line }triples onAnnotations.customandAnalyzedQuery.custom. Lets crate consumers layer their own annotation vocabularies (e.g. HTTP routing metadata) on top of scythe without coupling the SQL compiler to any one domain. Native annotations (@name,@returns,@param,@nullable,@nonnull,@json,@optional,@group_by,@deprecated) are unaffected — only previously-ignored unknowns are captured.scythe-coregained an optionalserdefeature that addsSerialize/Deserializederives to the public IR types (AnalyzedQuery,AnalyzedColumn,AnalyzedParam,EnumInfo,CompositeInfo,CompositeFieldInfo,GroupByConfig,QueryCommand,Annotations,ParamDoc,JsonMapping,CustomAnnotation). Off by default.Catalog::tables_iter()accessor returning(&String, &Table)pairs, complementing the existingtables()(which returns names only).
- sqlparser 0.62 compatibility: handle multi-alias select items, object-name insert targets, and unsupported table-query insert targets so
cargo clippy --workspace -- -D warningsis clean.
[0.6.13] - 2026-05-10
Section titled “[0.6.13] - 2026-05-10”- Generated Rust code is now rustfmt-clean — scythe invokes rustfmt on generated
.rsfiles to ensure long function signatures are properly formatted across multiple lines, eliminating unnecessary diffs when downstream projects runcargo fmt
[0.6.12] - 2026-05-07
Section titled “[0.6.12] - 2026-05-07”- The 0.6.11 ON CONFLICT preprocessor scanned the raw SQL byte string, so text inside
--line comments and'…'literals could trigger the predicate-stripping path and chew into the surrounding INSERT body. The scanner now runs against an ASCII-uppercase mask where comments + string literals are replaced with same-length spaces, so positions still line up but only structural SQL is matched.
[0.6.11] - 2026-05-07
Section titled “[0.6.11] - 2026-05-07”- PostgreSQL: accept
INSERT … ON CONFLICT (cols) WHERE … DO …(the index-inference form for partial unique indexes). sqlparser-rs through 0.61 doesn’t recognise the predicate, so scythe now strips it for the parser pass while keeping the original SQL for codegen and runtime, where Postgres validates and uses the predicate to pick the matching partial index. Mirrors the existing dialect-preprocess pattern used for Oracle and MSSQL.
[0.6.10] - 2026-05-06
Section titled “[0.6.10] - 2026-05-06”- Clippy warnings in
scythe-lintstyle rules (collapsible_match) andtypescript-postgresbackend (unnecessary_sort_by)
Changed
Section titled “Changed”- Fixture data for pending engines (MSSQL, Oracle, Redshift, Snowflake) moved from
engines_pending/totesting_data/engines_pending/— all fixtures now under one directory - Updated pre-commit hooks: ai-rulez v4.1.6, rumdl v0.1.88, cargo-sort v2.1.4
- Bumped integration test dependencies:
rand0.8.5 → 0.8.6,pgx/v55.7.4 → 5.9.2,gosnowflake1.10.1 → 1.13.3,snowflake-sdk1.15.0 → 2.0.4,snowflake-jdbc3.16.1 → 4.0.2
[0.6.9] - 2026-04-15
Section titled “[0.6.9] - 2026-04-15”scythe fmtandscythe lintnow auto-detect SQL dialect fromscythe.tomlwhen files are passed directly (e.g. by pre-commit hooks)- PHP amphp: autoload vendor deps, use
query()instead ofexec() - Ruby SQLite: handle
:execCreateUser/CreateOrder with post-insert fetch - PHP SQLite: pass
statusparam tocreateUser - Oracle CI: install Instant Client SDK headers for ruby-oci8
- Snowflake CI: simplified to Python fakesnow only (no Docker emulator)
- Kotlin SQLite: Float literal types for total values
- Elixir jamdb Oracle: use
DBConnection.executeandschema_full.sql - Elixir Ecto: use Postgrex directly, fix
:oneempty result handling - MariaDB C#:
GetValue().ToString()for UUID columns (wasGetString()) - Oracle Go: EZ Connect format (
//host:port/service) for godror
[0.6.8] - 2026-04-15
Section titled “[0.6.8] - 2026-04-15”- MSSQL integration tests across 10 backends (Rust tiberius, Python pyodbc, Go go-mssqldb, TypeScript mssql, Java JDBC, Kotlin JDBC, C# SqlClient, Elixir TDS, Ruby TinyTds, PHP PDO)
- Redshift integration tests across 13 backends (all PostgreSQL-compatible drivers with Redshift-specific manifests)
- Snowflake integration tests across 7 backends (Python, TypeScript, Go, Java, Kotlin, C#, PHP)
- MSSQL CI job with SQL Server 2022 Docker
- Redshift CI job using PostgreSQL container with PG-compatible schema
- Snowflake CI job with snowflake-emulator Docker + fakesnow for Python
- MSSQL
OUTPUT INSERTEDpreprocessing: converts toRETURNINGfor parser, preserves original SQL in codegen - Redshift
IDENTITY(N,N)schema preprocessing: strips before parsing - Snowflake type mappings:
TIMESTAMP_NTZ,TIMESTAMP_TZ,TIMESTAMP_LTZ,VARIANT - 89 total integration test backends (up from 69)
- CI:
libaio1→libaio1t64for Ubuntu 24.04 (Oracle job) - CI: SQLite
create_if_missing(true)+touchstep - CI: removed committed macOS
.bundle/config - Go codegen:
@pNplaceholder rewriting for MSSQL - Rust tiberius codegen:
Compat<TcpStream>type,&dyn ToSqlparam binding, stringFromSqlhandling - Ruby TinyTds codegen: type-aware param escaping (integers/booleans not escaped)
- TypeScript mssql codegen: explicit
sql.*type bindings for params - Template fixes for Redshift (no enums,
schema_pg_compat.sql, status as string) - Elixir:
elixirc_pathsincludesgenerated/for all backends - TypeScript:
String()coercion for decimal total comparisons
Unverified / Skipped in CI
Section titled “Unverified / Skipped in CI”The following backends have codegen support but are not tested in CI due to driver/infra limitations:
MSSQL:
elixir-tds— Elixirtdslibrary parameter type encoding fails (#28)
Oracle:
elixir-jamdb—DBConnection.ConnectionPooldispatch error withjamdb_oracleruby-oci8— native gem requires Oracle Instant Client SDK headers not available in CI
SQLite:
php-pdo-sqlite— generatedcreateUserparam count mismatch with test template
Snowflake (#27):
python-snowflake, typescript-snowflake, and go-gosnowflake all run in CI against a shared
fakesnow server
(integration_tests/fakesnow/fakesnow_server.py) — gosnowflake connects with protocol=http&insecureMode=true
to skip TLS/OCSP the same way the Node driver does. The remaining three are still excluded:
java-jdbc-snowflake/kotlin-jdbc-snowflake— both use the snowflake-jdbc driver, which fakesnow forces into its JSON result format (fakesnow has no Arrow chunk-download endpoint). snowflake-jdbc’s JSON-formatResultSetdoesn’t implementgetObject(int, LocalDateTime.class), so any query touching aTIMESTAMP_NTZcolumn throws regardless of how the connection is configured. Enabling these needs an Arrow chunk-download endpoint infakesnow_server.py; the JDBC-side wiring (insecure TLS URL parameters) was deliberately not landed, since it can’t be verified end to end until that blocker is lifted.csharp-snowflake— not attempted. The Snowflake.Data driver needs its own TLS/OCSP and result-format investigation, which was not carried out, so no claim is made either way about whether it can work.php-pdo-snowflake— genuinely uncoverable in CI:composer.jsononly declaresext-pdo_odbc, and the proprietarypdo_snowflakePHP extension isn’t installable through Composer, PECL, or any standard CI package manager. It requires Snowflake’s closed-source ODBC driver preinstalled on the runner.
[0.6.7] - 2026-04-12
Section titled “[0.6.7] - 2026-04-12”- Oracle integration tests across 9 backends (Python oracledb, TypeScript oracledb, Go godror, Java JDBC, Kotlin JDBC, C# Oracle, Elixir jamdb, Ruby oci8, Rust sibyl)
- Oracle CI job with Oracle XE 21 and Instant Client
- Oracle SQL support:
:Nplaceholder preprocessing,RETURNING ... INTOoutput bind codegen - Oracle
orders.sqlqueries withRETURNING INTOsupport structs_onlyoption for Rust sqlx backend (skipssqlx::query!()macros that require compile-time DB)
Changed
Section titled “Changed”- Java codegen: emit
package generated;andpublic class Queries { ... }wrapper — eliminates hand-written wrapper files - Kotlin codegen: emit
package generatedheader - Java output path:
src/main/java/generated/Queries.java; Kotlin:src/main/kotlin/generated/queries.kt - Rust sqlx integration tests output to
src/queries.rswithstructs_onlymode - Oracle dialect uses
OracleDialectfrom sqlparser (wasGenericDialect)
- Go database-sql MySQL: fixed connection failure when
MYSQL_URLusesmysql://URL format - Ruby mysql2 MySQL: regenerated code to use
stmt.affected_rows(fixes incorrectDELETErow counts) - Java/Kotlin JDBC: enum columns read via
valueOf(toUpperCase())instead of brokengetObject() - Java/Kotlin JDBC: PostgreSQL enum params use
setObject(Types.OTHER), others usesetString(getValue()) - Java/Kotlin JDBC MariaDB:
RETURNINGqueries useexecute()+getResultSet()(MySQL Connector/J doesn’t supportexecuteQuery()for DML RETURNING) - Rust sqlx MariaDB: UUID columns cast to
CHARin all queries (sqlx can’t decode MariaDB BINARY UUID) - Rust sqlx MariaDB/MySQL: use
last_insert_id()from result instead ofLAST_INSERT_ID()SQL function (pool connection mismatch) - Rust sqlx:
raw_sql()for multi-statement schema loading (PG and SQLite) - MariaDB manifests: UUID mapped to
Stringfor Rust sqlx, Java JDBC, Kotlin JDBC (drivers return String, not UUID object) - Java imports:
java.time.*wildcard for all temporal types
[0.6.6] - 2026-04-12
Section titled “[0.6.6] - 2026-04-12”- MariaDB integration tests across all 11 supported backends (Rust sqlx, Python aiomysql, TypeScript mysql2, Go database/sql, Java JDBC, Kotlin JDBC, C# MySqlConnector, Elixir MyXQL, Ruby mysql2, Ruby trilogy, PHP PDO)
- MariaDB CI job running all 11 backends against MariaDB 11
- MariaDB
orders.sqlqueries withINSERT...RETURNINGsupport
[0.6.5] - 2026-04-12
Section titled “[0.6.5] - 2026-04-12”- Java JDBC and Kotlin JDBC: Oracle backend support
- tokio-postgres: enums now implement
FromSqlandToSqltraits natively, enabling direct use as query parameters and row fields without manual string conversion - Ruby mysql2:
affected_rowsnow called on the statement instead of the client, fixing incorrect return values for exec queries
[0.6.4] - 2026-04-10
Section titled “[0.6.4] - 2026-04-10”- Integration tests now run all generated code against real databases (PostgreSQL, MySQL, SQLite) across all 39 backends and 10 languages
- CI split into 3 parallel jobs (PostgreSQL, MySQL, SQLite) covering all backends
- New MySQL/SQLite SQL queries: GetUserOrders, CountUsersByStatus, GetUserWithTags
- tokio-postgres: enum parameters now use
::text::enum_namecasts for proper PostgreSQL enum handling - tokio-postgres: enum columns in SELECT/RETURNING use
::textcast for correct deserialization - sqlx: RETURNING clauses now include enum type annotations (
"status: UserStatus") - sqlx: aggregate functions (COUNT, SUM) get non-null override annotations (
"column_name!") - C# Npgsql: enum extension methods moved to top-level static classes (fixes CS1109)
- C# Microsoft.Data.Sqlite: fixed type mappings (int32->long, float32->double for SQLite)
- Elixir exqlite: updated to Exqlite 0.36 prepare/bind/step API
- Elixir myxql/exqlite/ecto: generated code now properly wrapped in
defmodule - Python aiomysql:
?placeholders correctly rewritten to%s - Go pgx: added missing
timeanddecimalimports in generated code - Ruby trilogy: parameterized queries use string interpolation (trilogy lacks prepared statement support)
- TypeScript pg-zod: enum columns use correct Zod schema references
[0.6.3] - 2026-04-10
Section titled “[0.6.3] - 2026-04-10”fmtandlintcommands now auto-detect the SQL dialect from the configenginefield (CLI--dialectflag still takes precedence)
- Sqruff rule
LT01excluded by default — it incorrectly splits compound operators (>=,<=,<@) - Compound operators inside CHECK constraints no longer get split by the formatter (e.g.,
>=becoming> =)
[0.6.2] - 2026-04-10
Section titled “[0.6.2] - 2026-04-10”Changed
Section titled “Changed”- tokio-postgres:
from_rowis now infallible (returnsSelfinstead ofResult) matching tokio-postgresrow.get()conventions - tokio-postgres: all query functions uniformly return
Result<T, tokio_postgres::Error>instead of mixed error types - tokio-postgres: extracted
ERROR_TYPEconstant to reduce string duplication in signatures
:optcommand now correctly generates row structs (was missing from struct generation match)
[0.6.1] - 2026-04-10
Section titled “[0.6.1] - 2026-04-10”:optquery command across all backends — returns optional/nullable single row (distinct from:onewhich expects exactly one row)- Serde and custom derive support for tokio-postgres backend via
serdeandderiveoptions apply_options()method on tokio-postgres backend for runtime configurationis_column_nullable()helper on analyzer scope for nullable column lookupscollect_param_from_expr_with_type_nullable()for nullable-aware parameter collectionversion:synctask in Taskfile for updating all crate versions at once
Changed
Section titled “Changed”- tokio-postgres:
clientparameter now accepts&(impl GenericClient + Sync)instead of concrete&Client - tokio-postgres: batch functions no longer wrap operations in implicit transactions
- INSERT parameter analysis now propagates column nullability to parameters
- Changelog retroactively aligned with Cargo.toml version history (0.1.0–0.6.0)
[0.6.0] - 2026-04-08
Section titled “[0.6.0] - 2026-04-08”- Microsoft SQL Server engine (6 backends: tiberius, pyodbc, mssql, sqlclient, tiny_tds, tds)
- Oracle Database engine (6 backends: sibyl, oracledb, godror, oracle, oci8, jamdb)
- MariaDB engine with native UUID support, RETURNING clause, and dedicated manifests
- Amazon Redshift engine (PostgreSQL-based with SUPER type support)
- Snowflake engine with VARIANT/OBJECT/ARRAY types
- 17 new database backends and 51 type mapping manifests
- Pre-commit/prek hooks for scythe users
Changed
Section titled “Changed”- Flattened docs structure for better organization
- Expanded to 10 total databases with 70+ backend drivers across 10 languages
- Extracted shared
rewrite_pg_placeholdersfunction (eliminated 26+ duplicated functions) - Extracted shared
load_or_default_manifestfunction (eliminated 49 duplicated code blocks) - CockroachDB documentation TOML snippet duplicate key issue
- Python DuckDB missing datetime import
- TypeScript DuckDB import type issue
- Go godror PascalCase conversion issue
- Go unconditional imports problem
- SQLx hardcoded PgPool issue
- Tiberius unwrap error handling
- Kotlin wasNull null handling
- Ruby batch operation fix
- Sibyl error swallowing issue
- Go
interface{}updated toanykeyword
[0.5.0] - 2026-04-08
Section titled “[0.5.0] - 2026-04-08”- CockroachDB engine support
- DuckDB engine support
:groupedoperation support- Kotlin Exposed backend
- R2DBC backend support
- Homebrew bottles for distribution
- Integration test generator for all 39 backend test suites
[0.4.0] - 2026-04-08
Section titled “[0.4.0] - 2026-04-08”- Real
:batchoperations across all backends - PHP AMPHP backend
- Custom type overrides feature
@optionalannotation support- Elixir Ecto backend
- Ruby Trilogy backend
- Pydantic/msgspec row types for Python
- Zod v4 schemas for TypeScript
- GenOptions infrastructure for per-backend configuration
Changed
Section titled “Changed”- Extended Quick Start documentation with all 10 languages
[0.3.0] - 2026-04-07
Section titled “[0.3.0] - 2026-04-07”- Snippet-runner tool for validating documentation code snippets across 13 languages
- PHP namespace support and Generator for
:manyqueries - C# SQLite async API
- Ruby module
Queriesencapsulation across all 3 backends
Changed
Section titled “Changed”- C# all backends: Enum.TryParse with descriptive InvalidOperationException
- Python aiosqlite: Decimal maps to
decimal.Decimalinstead of float - Go database-sql MySQL: Decimal maps to float64
- Ruby: SCREAMING_SNAKE_CASE enum variants
- PHP: Final class
Querieswrapper
- 8 backend-specific fixes across PHP, Ruby, C#, Rust, Python, and Go
[0.2.0] - 2026-04-07
Section titled “[0.2.0] - 2026-04-07”- Engine-aware backend architecture —
get_backend(name, engine)loads engine-specific manifests - 12 new language backends for MySQL and SQLite: go-database-sql, python-aiomysql, python-aiosqlite, typescript-mysql2, typescript-better-sqlite3, ruby-mysql2, ruby-sqlite3, csharp-mysqlconnector, csharp-microsoft-sqlite, elixir-myxql, elixir-exqlite
- Multi-backend CLI config via
[[sql.gen]]array syntax in scythe.toml - Full MySQL support across all 10 languages (Rust, Go, Python, TypeScript, Java, Kotlin, C#, Elixir, Ruby, PHP)
- Full SQLite support across all 10 languages
- 33 real integration tests against PostgreSQL, MySQL, and SQLite
supported_engines()method on CodegenBackend trait for engine validationmanifest()method on CodegenBackend trait for direct manifest accessfile_footer()method on CodegenBackend trait for class wrappers (C#)- Engine-specific manifest files for multi-DB backends (java-jdbc, kotlin-jdbc, php-pdo, rust-sqlx)
- Docker Compose setup for integration testing (PostgreSQL + MySQL)
Changed
Section titled “Changed”get_backend()now requires engine parameter for database-aware code generation- Backend constructors accept engine parameter and load appropriate manifests
- PG-only backends reject non-PostgreSQL engines with clear error messages
- Python codegen: multiline SQL now uses triple-quoted strings
- Python codegen: added missing
import decimalto file headers - TypeScript pg codegen: multiline SQL now uses backtick template literals
- C# codegen: generated code now wrapped in
public static class Queries { } - C# codegen: enum parameters use
.ToString().ToLower()with::enum_typeSQL cast - C# codegen: enum columns deserialized via
Enum.Parse<T>(reader.GetString(i), true) - PHP codegen: MySQL
?placeholders use positional arrays instead of named params - PHP codegen: enum params use
->value, enum columns use::from(), DateTimeImmutable for timestamps - Go codegen: added missing
timeanddecimalimports to file header - Java codegen: added import statements to file header
- Ruby mysql2 codegen:
affected_rowscalled on statement instead of client
[0.1.0] - 2026-04-06
Section titled “[0.1.0] - 2026-04-06”- SQL-to-code generation for 13 language backends:
- Rust (sqlx, tokio-postgres)
- Python (psycopg3, asyncpg)
- TypeScript (postgres.js, pg)
- Go (pgx v5)
- Java (JDBC with records)
- Kotlin (JDBC with data classes)
- C# (Npgsql with records)
- Elixir (Postgrex with defstruct)
- Ruby (pg gem with Data.define)
- PHP (PDO with readonly classes)
- Database dialect support: PostgreSQL, MySQL, SQLite
- SQL annotation system (@name, @returns, @param, @nullable, @nonnull, @json, @deprecated)
- Smart type inference with nullability propagation (JOIN, COALESCE, aggregates, CASE)
- Language-neutral type vocabulary with per-backend type mapping via manifest.toml
- 93 SQL lint rules (22 scythe-specific + 71 via sqruff integration)
- SQL formatting via sqruff integration
- CLI commands: generate, check, lint, fmt, migrate
- sqlc migration tool (convert sqlc.yaml to scythe.toml, migrate query annotations)
- 275 JSON test fixtures with auto-generated test code
- Real language tool validation (ruff, biome, gofmt, ktlint, ruby -c, php -l)
- Template-based backend architecture (manifest.toml + MiniJinja templates)
- Trait-based CodegenBackend for extensible language support